1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package com.android.dialer.searchfragment.common;
18 
19 import android.content.Context;
20 import android.support.annotation.NonNull;
21 import android.support.v4.util.SimpleArrayMap;
22 import android.telephony.PhoneNumberUtils;
23 import android.text.TextUtils;
24 import com.android.dialer.dialpadview.DialpadCharMappings;
25 import java.util.regex.Pattern;
26 
27 /** Utility class for filtering, comparing and handling strings and queries. */
28 public class QueryFilteringUtil {
29 
30   /**
31    * The default character-digit map that will be used to find the digit associated with a given
32    * character on a T9 keyboard.
33    */
34   private static final SimpleArrayMap<Character, Character> DEFAULT_CHAR_TO_DIGIT_MAP =
35       DialpadCharMappings.getDefaultCharToKeyMap();
36 
37   /** Matches strings with "-", "(", ")", 2-9 of at least length one. */
38   private static final Pattern T9_PATTERN = Pattern.compile("[\\-()2-9]+");
39 
40   /**
41    * Returns true if the query is of T9 format and the name's T9 representation belongs to the query
42    *
43    * <p>Examples:
44    *
45    * <ul>
46    *   <li>#nameMatchesT9Query("7", "John Smith") returns true, 7 -> 'S'
47    *   <li>#nameMatchesT9Query("55", "Jessica Jones") returns true, 55 -> 'JJ'
48    *   <li>#nameMatchesT9Query("56", "Jessica Jones") returns true, 56 -> 'Jo'
49    *   <li>#nameMatchesT9Query("7", "Jessica Jones") returns false, no names start with P,Q,R or S
50    * </ul>
51    *
52    * <p>When the 1st language preference uses a non-Latin alphabet (e.g., Russian) and the character
53    * mappings for the alphabet is defined in {@link DialpadCharMappings}, the Latin alphabet will be
54    * used first to check if the name matches the query. If they don't match, the non-Latin alphabet
55    * will be used.
56    *
57    * <p>Examples (when the 1st language preference is Russian):
58    *
59    * <ul>
60    *   <li>#nameMatchesT9Query("7", "John Smith") returns true, 7 -> 'S'
61    *   <li>#nameMatchesT9Query("7", "Павел Чехов") returns true, 7 -> 'Ч'
62    *   <li>#nameMatchesT9Query("77", "Pavel Чехов") returns true, 7 -> 'P' (in the Latin alphabet),
63    *       7 -> 'Ч' (in the Russian alphabet)
64    * </ul>
65    */
nameMatchesT9Query(String query, String name, Context context)66   public static boolean nameMatchesT9Query(String query, String name, Context context) {
67     if (!T9_PATTERN.matcher(query).matches()) {
68       return false;
69     }
70 
71     query = digitsOnly(query);
72     if (getIndexOfT9Substring(query, name, context) != -1) {
73       return true;
74     }
75 
76     // Check matches initials
77     // TODO(calderwoodra) investigate faster implementation
78     int queryIndex = 0;
79 
80     String[] names = name.toLowerCase().split("\\s");
81     for (int i = 0; i < names.length && queryIndex < query.length(); i++) {
82       if (TextUtils.isEmpty(names[i])) {
83         continue;
84       }
85 
86       if (getDigit(names[i].charAt(0), context) == query.charAt(queryIndex)) {
87         queryIndex++;
88       }
89     }
90 
91     return queryIndex == query.length();
92   }
93 
94   /**
95    * Returns the index where query is contained in the T9 representation of the name.
96    *
97    * <p>Examples:
98    *
99    * <ul>
100    *   <li>#getIndexOfT9Substring("76", "John Smith") returns 5, 76 -> 'Sm'
101    *   <li>#nameMatchesT9Query("2226", "AAA Mom") returns 0, 2226 -> 'AAAM'
102    *   <li>#nameMatchesT9Query("2", "Jessica Jones") returns -1, Neither 'Jessica' nor 'Jones' start
103    *       with A, B or C
104    * </ul>
105    */
getIndexOfT9Substring(String query, String name, Context context)106   public static int getIndexOfT9Substring(String query, String name, Context context) {
107     query = digitsOnly(query);
108     String t9Name = getT9Representation(name, context);
109     String t9NameDigitsOnly = digitsOnly(t9Name);
110     if (t9NameDigitsOnly.startsWith(query)) {
111       return 0;
112     }
113 
114     int nonLetterCount = 0;
115     for (int i = 1; i < t9NameDigitsOnly.length(); i++) {
116       char cur = t9Name.charAt(i);
117       if (!Character.isDigit(cur)) {
118         nonLetterCount++;
119         continue;
120       }
121 
122       // If the previous character isn't a digit and the current is, check for a match
123       char prev = t9Name.charAt(i - 1);
124       int offset = i - nonLetterCount;
125       if (!Character.isDigit(prev) && t9NameDigitsOnly.startsWith(query, offset)) {
126         return i;
127       }
128     }
129     return -1;
130   }
131 
132   /**
133    * Returns true if the subparts of the name (split by white space) begin with the query.
134    *
135    * <p>Examples:
136    *
137    * <ul>
138    *   <li>#nameContainsQuery("b", "Brandon") returns true
139    *   <li>#nameContainsQuery("o", "Bob") returns false
140    *   <li>#nameContainsQuery("o", "Bob Olive") returns true
141    * </ul>
142    */
nameContainsQuery(String query, String name)143   public static boolean nameContainsQuery(String query, String name) {
144     if (TextUtils.isEmpty(name)) {
145       return false;
146     }
147 
148     return Pattern.compile("(^|\\s)" + Pattern.quote(query.toLowerCase()))
149         .matcher(name.toLowerCase())
150         .find();
151   }
152 
153   /** @return true if the number belongs to the query. */
numberMatchesNumberQuery(String query, String number)154   public static boolean numberMatchesNumberQuery(String query, String number) {
155     return PhoneNumberUtils.isGlobalPhoneNumber(query)
156         && indexOfQueryNonDigitsIgnored(query, number) != -1;
157   }
158 
159   /**
160    * Checks if query is contained in number while ignoring all characters in both that are not
161    * digits (i.e. {@link Character#isDigit(char)} returns false).
162    *
163    * @return index where query is found with all non-digits removed, -1 if it's not found.
164    */
indexOfQueryNonDigitsIgnored(@onNull String query, @NonNull String number)165   static int indexOfQueryNonDigitsIgnored(@NonNull String query, @NonNull String number) {
166     return digitsOnly(number).indexOf(digitsOnly(query));
167   }
168 
169   /**
170    * Replaces characters in the given string with their T9 representations.
171    *
172    * @param s The original string
173    * @param context The context
174    * @return The original string with characters replaced with T9 representations.
175    */
getT9Representation(String s, Context context)176   public static String getT9Representation(String s, Context context) {
177     StringBuilder builder = new StringBuilder(s.length());
178     for (char c : s.toLowerCase().toCharArray()) {
179       builder.append(getDigit(c, context));
180     }
181     return builder.toString();
182   }
183 
184   /** @return String s with only digits recognized by Character#isDigit() remaining */
digitsOnly(String s)185   public static String digitsOnly(String s) {
186     StringBuilder sb = new StringBuilder();
187     for (int i = 0; i < s.length(); i++) {
188       char c = s.charAt(i);
189       if (Character.isDigit(c)) {
190         sb.append(c);
191       }
192     }
193     return sb.toString();
194   }
195 
196   /**
197    * Returns the digit on a T9 keyboard which is associated with the given lower case character.
198    *
199    * <p>The default character-key mapping will be used first to find a digit. If no digit is found,
200    * try the mapping of the current default locale if it is defined in {@link DialpadCharMappings}.
201    * If the second attempt fails, return the original character.
202    */
getDigit(char c, Context context)203   static char getDigit(char c, Context context) {
204     Character digit = DEFAULT_CHAR_TO_DIGIT_MAP.get(c);
205     if (digit != null) {
206       return digit;
207     }
208 
209     SimpleArrayMap<Character, Character> charToKeyMap =
210         DialpadCharMappings.getCharToKeyMap(context);
211     if (charToKeyMap != null) {
212       digit = charToKeyMap.get(c);
213       return digit != null ? digit : c;
214     }
215 
216     return c;
217   }
218 }
219