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.cp2;
18 
19 import android.content.ContentResolver;
20 import android.content.Context;
21 import android.database.CharArrayBuffer;
22 import android.database.ContentObserver;
23 import android.database.Cursor;
24 import android.database.DataSetObserver;
25 import android.database.MatrixCursor;
26 import android.net.Uri;
27 import android.os.Bundle;
28 import android.provider.ContactsContract.CommonDataKinds.Nickname;
29 import android.provider.ContactsContract.CommonDataKinds.Organization;
30 import android.provider.ContactsContract.CommonDataKinds.Phone;
31 import android.support.annotation.IntDef;
32 import android.support.annotation.Nullable;
33 import android.support.v4.util.ArraySet;
34 import android.text.TextUtils;
35 import android.util.ArrayMap;
36 import com.android.dialer.searchfragment.common.Projections;
37 import com.android.dialer.searchfragment.common.QueryFilteringUtil;
38 import java.lang.annotation.Retention;
39 import java.lang.annotation.RetentionPolicy;
40 import java.util.ArrayList;
41 import java.util.Collections;
42 import java.util.List;
43 import java.util.Locale;
44 import java.util.Map;
45 import java.util.Set;
46 
47 /**
48  * Wrapper for a cursor containing all on device contacts.
49  *
50  * <p>This cursor removes duplicate phone numbers associated with the same contact and can filter
51  * contacts based on a query by calling {@link #filter(String, Context)}.
52  */
53 final class ContactFilterCursor implements Cursor {
54 
55   private final Cursor cursor;
56   // List of cursor ids that are valid for displaying after filtering.
57   private final List<Integer> queryFilteredPositions = new ArrayList<>();
58   private final ContactTernarySearchTree contactTree;
59 
60   private int currentPosition = 0;
61 
62   @Retention(RetentionPolicy.SOURCE)
63   @IntDef({
64     Qualification.NUMBERS_ARE_NOT_DUPLICATES,
65     Qualification.NEW_NUMBER_IS_MORE_QUALIFIED,
66     Qualification.CURRENT_MORE_QUALIFIED
67   })
68   private @interface Qualification {
69     /** Numbers are not duplicates (i.e. neither is more qualified than the other). */
70     int NUMBERS_ARE_NOT_DUPLICATES = 0;
71     /** Number are duplicates and new number is more qualified than the existing number. */
72     int NEW_NUMBER_IS_MORE_QUALIFIED = 1;
73     /** Numbers are duplicates but current/existing number is more qualified than new number. */
74     int CURRENT_MORE_QUALIFIED = 2;
75   }
76 
77   /**
78    * @param cursor with projection {@link Projections#CP2_PROJECTION}.
79    * @param query to filter cursor results.
80    * @param context of the app.
81    */
ContactFilterCursor(Cursor cursor, @Nullable String query, Context context)82   ContactFilterCursor(Cursor cursor, @Nullable String query, Context context) {
83     this.cursor = createCursor(cursor);
84     contactTree = buildContactSearchTree(context, this.cursor);
85     filter(query, context);
86   }
87 
88   /**
89    * Returns a new cursor with contact information coalesced.
90    *
91    * <p>Here are some sample rows and columns that might exist in cp2 database:
92    *
93    * <ul>
94    *   <li>display Name (William), contactID (202), mimeType (name), data1 (A Pixel)
95    *   <li>display Name (William), contactID (202), mimeType (phone), data1 (+1 650-200-3333)
96    *   <li>display Name (William), contactID (202), mimeType (phone), data1 (+1 540-555-6666)
97    *   <li>display Name (William), contactID (202), mimeType (organization), data1 (Walmart)
98    *   <li>display Name (William), contactID (202), mimeType (nickname), data1 (Will)
99    * </ul>
100    *
101    * <p>These rows would be coalesced into new rows like so:
102    *
103    * <ul>
104    *   <li>display Name (William), phoneNumber (+1 650-200-3333), organization (Walmart), nickname
105    *       (Will)
106    *   <li>display Name (William), phoneNumber (+1 540-555-6666), organization (Walmart), nickname
107    *       (Will)
108    * </ul>
109    */
createCursor(Cursor cursor)110   private static Cursor createCursor(Cursor cursor) {
111     // Convert cursor rows into Cp2Contacts
112     List<Cp2Contact> cp2Contacts = new ArrayList<>();
113     Map<Integer, Integer> contactIdsToPosition = new ArrayMap<>();
114     cursor.moveToPosition(-1);
115     while (cursor.moveToNext()) {
116       Cp2Contact contact = Cp2Contact.fromCursor(cursor);
117       cp2Contacts.add(contact);
118       contactIdsToPosition.put(contact.contactId(), cursor.getPosition());
119     }
120     cursor.close();
121 
122     // Group then combine contact data
123     List<Cp2Contact> coalescedContacts = new ArrayList<>();
124     for (Integer contactId : contactIdsToPosition.keySet()) {
125       List<Cp2Contact> duplicateContacts = getAllContactsWithContactId(contactId, cp2Contacts);
126       coalescedContacts.addAll(coalesceContacts(duplicateContacts));
127     }
128 
129     // Sort the contacts back into the exact same order they were inside of {@code cursor}
130     Collections.sort(coalescedContacts, (o1, o2) -> compare(contactIdsToPosition, o1, o2));
131     MatrixCursor newCursor = new MatrixCursor(Projections.CP2_PROJECTION, coalescedContacts.size());
132     for (Cp2Contact contact : coalescedContacts) {
133       newCursor.addRow(contact.toCursorRow());
134     }
135     return newCursor;
136   }
137 
coalesceContacts(List<Cp2Contact> contactsWithSameContactId)138   private static List<Cp2Contact> coalesceContacts(List<Cp2Contact> contactsWithSameContactId) {
139     StringBuilder companyName = new StringBuilder();
140     StringBuilder nickName = new StringBuilder();
141     List<Cp2Contact> phoneContacts = new ArrayList<>();
142     for (Cp2Contact contact : contactsWithSameContactId) {
143       if (contact.mimeType().equals(Phone.CONTENT_ITEM_TYPE)) {
144         phoneContacts.add(contact);
145       } else if (contact.mimeType().equals(Organization.CONTENT_ITEM_TYPE)) {
146         // Since a contact can have more than one company name but they aren't visible to the user
147         // in our search UI, we can lazily concatenate them together to make them all searchable.
148         companyName.append(" ").append(contact.companyName());
149       } else if (contact.mimeType().equals(Nickname.CONTENT_ITEM_TYPE)) {
150         // Since a contact can have more than one nickname but they aren't visible to the user
151         // in our search UI, we can lazily concatenate them together to make them all searchable.
152         nickName.append(" ").append(contact.nickName());
153       }
154     }
155 
156     removeDuplicatePhoneNumbers(phoneContacts);
157 
158     List<Cp2Contact> coalescedContacts = new ArrayList<>();
159     for (Cp2Contact phoneContact : phoneContacts) {
160       coalescedContacts.add(
161           phoneContact
162               .toBuilder()
163               .setCompanyName(companyName.length() == 0 ? null : companyName.toString())
164               .setNickName(nickName.length() == 0 ? null : nickName.toString())
165               .build());
166     }
167     return coalescedContacts;
168   }
169 
compare( Map<Integer, Integer> contactIdsToPosition, Cp2Contact o1, Cp2Contact o2)170   private static int compare(
171       Map<Integer, Integer> contactIdsToPosition, Cp2Contact o1, Cp2Contact o2) {
172     int position1 = contactIdsToPosition.get(o1.contactId());
173     int position2 = contactIdsToPosition.get(o2.contactId());
174     return Integer.compare(position1, position2);
175   }
176 
removeDuplicatePhoneNumbers(List<Cp2Contact> phoneContacts)177   private static void removeDuplicatePhoneNumbers(List<Cp2Contact> phoneContacts) {
178     for (int i = 0; i < phoneContacts.size(); i++) {
179       Cp2Contact contact1 = phoneContacts.get(i);
180       for (int j = i + 1; j < phoneContacts.size(); /* don't iterate by default */ ) {
181         Cp2Contact contact2 = phoneContacts.get(j);
182         int qualification = getQualification(contact2.phoneNumber(), contact1.phoneNumber());
183         if (qualification == Qualification.CURRENT_MORE_QUALIFIED) {
184           phoneContacts.remove(contact2);
185         } else if (qualification == Qualification.NEW_NUMBER_IS_MORE_QUALIFIED) {
186           phoneContacts.remove(contact1);
187           break;
188         } else if (qualification == Qualification.NUMBERS_ARE_NOT_DUPLICATES) {
189           // Keep both contacts
190           j++;
191         }
192       }
193     }
194   }
195 
196   /**
197    * @param number that may or may not be more qualified than the existing most qualified number
198    * @param mostQualifiedNumber currently most qualified number associated with same contact
199    * @return {@link Qualification} where the more qualified number is the number with the most
200    *     digits. If the digits are the same, the number with the most formatting is more qualified.
201    */
getQualification(String number, String mostQualifiedNumber)202   private static @Qualification int getQualification(String number, String mostQualifiedNumber) {
203     // Ignore formatting
204     String numberDigits = QueryFilteringUtil.digitsOnly(number);
205     String qualifiedNumberDigits = QueryFilteringUtil.digitsOnly(mostQualifiedNumber);
206 
207     // If the numbers are identical, return version with more formatting
208     if (qualifiedNumberDigits.equals(numberDigits)) {
209       if (mostQualifiedNumber.length() >= number.length()) {
210         return Qualification.CURRENT_MORE_QUALIFIED;
211       } else {
212         return Qualification.NEW_NUMBER_IS_MORE_QUALIFIED;
213       }
214     }
215 
216     // If one number is a suffix of another, then return the longer one.
217     // If they are equal, then return the current most qualified number.
218     if (qualifiedNumberDigits.endsWith(numberDigits)) {
219       return Qualification.CURRENT_MORE_QUALIFIED;
220     }
221     if (numberDigits.endsWith(qualifiedNumberDigits)) {
222       return Qualification.NEW_NUMBER_IS_MORE_QUALIFIED;
223     }
224     return Qualification.NUMBERS_ARE_NOT_DUPLICATES;
225   }
226 
getAllContactsWithContactId( int contactId, List<Cp2Contact> contacts)227   private static List<Cp2Contact> getAllContactsWithContactId(
228       int contactId, List<Cp2Contact> contacts) {
229     List<Cp2Contact> contactIdContacts = new ArrayList<>();
230     for (Cp2Contact contact : contacts) {
231       if (contact.contactId() == contactId) {
232         contactIdContacts.add(contact);
233       }
234     }
235     return contactIdContacts;
236   }
237 
238   /**
239    * Returns a ternary search trie based on the contact at the cursor's current position with the
240    * following terms inserted:
241    *
242    * <ul>
243    *   <li>Contact's whole display name, company name and nickname.
244    *   <li>The T9 representations of those values
245    *   <li>The T9 initials of those values
246    *   <li>All possible substrings a contact's phone number
247    * </ul>
248    */
buildContactSearchTree(Context context, Cursor cursor)249   private static ContactTernarySearchTree buildContactSearchTree(Context context, Cursor cursor) {
250     ContactTernarySearchTree tree = new ContactTernarySearchTree();
251     cursor.moveToPosition(-1);
252     while (cursor.moveToNext()) {
253       int position = cursor.getPosition();
254       Set<String> queryMatches = new ArraySet<>();
255       addMatches(context, queryMatches, cursor.getString(Projections.DISPLAY_NAME));
256       addMatches(context, queryMatches, cursor.getString(Projections.COMPANY_NAME));
257       addMatches(context, queryMatches, cursor.getString(Projections.NICKNAME));
258       for (String query : queryMatches) {
259         tree.put(query, position);
260       }
261       String number = QueryFilteringUtil.digitsOnly(cursor.getString(Projections.PHONE_NUMBER));
262       Set<String> numberSubstrings = new ArraySet<>();
263       numberSubstrings.add(number);
264       for (int start = 0; start < number.length(); start++) {
265         numberSubstrings.add(number.substring(start, number.length()));
266       }
267       for (String substring : numberSubstrings) {
268         tree.put(substring, position);
269       }
270     }
271     return tree;
272   }
273 
274   /**
275    * Returns a set containing:
276    *
277    * <ul>
278    *   <li>The white space divided parts of phrase
279    *   <li>The T9 representation of the white space divided parts of phrase
280    *   <li>The T9 representation of the initials (i.e. first character of each part) of phrase
281    * </ul>
282    */
addMatches(Context context, Set<String> existingMatches, String phrase)283   private static void addMatches(Context context, Set<String> existingMatches, String phrase) {
284     if (TextUtils.isEmpty(phrase)) {
285       return;
286     }
287     String initials = "";
288     phrase = phrase.toLowerCase(Locale.getDefault());
289     existingMatches.add(phrase);
290     for (String name : phrase.split("\\s")) {
291       if (TextUtils.isEmpty(name)) {
292         continue;
293       }
294       existingMatches.add(name);
295       existingMatches.add(QueryFilteringUtil.getT9Representation(name, context));
296       initials += name.charAt(0);
297     }
298     existingMatches.add(QueryFilteringUtil.getT9Representation(initials, context));
299   }
300 
301   /**
302    * Filters out contacts that do not match the query.
303    *
304    * <p>The query can have at least 1 of 3 forms:
305    *
306    * <ul>
307    *   <li>A phone number
308    *   <li>A T9 representation of a name (matches {@link QueryFilteringUtil#T9_PATTERN}).
309    *   <li>A name
310    * </ul>
311    *
312    * <p>A contact is considered a match if:
313    *
314    * <ul>
315    *   <li>Its phone number contains the phone number query
316    *   <li>Its name represented in T9 contains the T9 query
317    *   <li>Its name contains the query
318    *   <li>Its company contains the query
319    * </ul>
320    */
filter(@ullable String query, Context context)321   public void filter(@Nullable String query, Context context) {
322     if (query == null) {
323       query = "";
324     }
325     queryFilteredPositions.clear();
326     if (TextUtils.isEmpty(query)) {
327       for (int i = 0; i < cursor.getCount(); i++) {
328         queryFilteredPositions.add(i);
329       }
330     } else {
331       queryFilteredPositions.addAll(contactTree.get(query.toLowerCase(Locale.getDefault())));
332     }
333     Collections.sort(queryFilteredPositions);
334     currentPosition = 0;
335     cursor.moveToFirst();
336   }
337 
338   @Override
moveToPosition(int position)339   public boolean moveToPosition(int position) {
340     currentPosition = position;
341     return currentPosition < getCount()
342         && cursor.moveToPosition(queryFilteredPositions.get(currentPosition));
343   }
344 
345   @Override
move(int offset)346   public boolean move(int offset) {
347     currentPosition += offset;
348     return moveToPosition(currentPosition);
349   }
350 
351   @Override
getCount()352   public int getCount() {
353     return queryFilteredPositions.size();
354   }
355 
356   @Override
isFirst()357   public boolean isFirst() {
358     return currentPosition == 0;
359   }
360 
361   @Override
isLast()362   public boolean isLast() {
363     return currentPosition == getCount() - 1;
364   }
365 
366   @Override
getPosition()367   public int getPosition() {
368     return currentPosition;
369   }
370 
371   @Override
moveToFirst()372   public boolean moveToFirst() {
373     return moveToPosition(0);
374   }
375 
376   @Override
moveToLast()377   public boolean moveToLast() {
378     return moveToPosition(getCount() - 1);
379   }
380 
381   @Override
moveToNext()382   public boolean moveToNext() {
383     return moveToPosition(++currentPosition);
384   }
385 
386   @Override
moveToPrevious()387   public boolean moveToPrevious() {
388     return moveToPosition(--currentPosition);
389   }
390 
391   // Methods below simply call the corresponding method in cursor.
392   @Override
isBeforeFirst()393   public boolean isBeforeFirst() {
394     return cursor.isBeforeFirst();
395   }
396 
397   @Override
isAfterLast()398   public boolean isAfterLast() {
399     return cursor.isAfterLast();
400   }
401 
402   @Override
getColumnIndex(String columnName)403   public int getColumnIndex(String columnName) {
404     return cursor.getColumnIndex(columnName);
405   }
406 
407   @Override
getColumnIndexOrThrow(String columnName)408   public int getColumnIndexOrThrow(String columnName) {
409     return cursor.getColumnIndexOrThrow(columnName);
410   }
411 
412   @Override
getColumnName(int columnIndex)413   public String getColumnName(int columnIndex) {
414     return cursor.getColumnName(columnIndex);
415   }
416 
417   @Override
getColumnNames()418   public String[] getColumnNames() {
419     return cursor.getColumnNames();
420   }
421 
422   @Override
getColumnCount()423   public int getColumnCount() {
424     return cursor.getColumnCount();
425   }
426 
427   @Override
getBlob(int columnIndex)428   public byte[] getBlob(int columnIndex) {
429     return cursor.getBlob(columnIndex);
430   }
431 
432   @Override
getString(int columnIndex)433   public String getString(int columnIndex) {
434     return cursor.getString(columnIndex);
435   }
436 
437   @Override
copyStringToBuffer(int columnIndex, CharArrayBuffer buffer)438   public void copyStringToBuffer(int columnIndex, CharArrayBuffer buffer) {
439     cursor.copyStringToBuffer(columnIndex, buffer);
440   }
441 
442   @Override
getShort(int columnIndex)443   public short getShort(int columnIndex) {
444     return cursor.getShort(columnIndex);
445   }
446 
447   @Override
getInt(int columnIndex)448   public int getInt(int columnIndex) {
449     return cursor.getInt(columnIndex);
450   }
451 
452   @Override
getLong(int columnIndex)453   public long getLong(int columnIndex) {
454     return cursor.getLong(columnIndex);
455   }
456 
457   @Override
getFloat(int columnIndex)458   public float getFloat(int columnIndex) {
459     return cursor.getFloat(columnIndex);
460   }
461 
462   @Override
getDouble(int columnIndex)463   public double getDouble(int columnIndex) {
464     return cursor.getDouble(columnIndex);
465   }
466 
467   @Override
getType(int columnIndex)468   public int getType(int columnIndex) {
469     return cursor.getType(columnIndex);
470   }
471 
472   @Override
isNull(int columnIndex)473   public boolean isNull(int columnIndex) {
474     return cursor.isNull(columnIndex);
475   }
476 
477   @Override
deactivate()478   public void deactivate() {
479     cursor.deactivate();
480   }
481 
482   @Override
requery()483   public boolean requery() {
484     return cursor.requery();
485   }
486 
487   @Override
close()488   public void close() {
489     cursor.close();
490   }
491 
492   @Override
isClosed()493   public boolean isClosed() {
494     return cursor.isClosed();
495   }
496 
497   @Override
registerContentObserver(ContentObserver observer)498   public void registerContentObserver(ContentObserver observer) {
499     cursor.registerContentObserver(observer);
500   }
501 
502   @Override
unregisterContentObserver(ContentObserver observer)503   public void unregisterContentObserver(ContentObserver observer) {
504     cursor.unregisterContentObserver(observer);
505   }
506 
507   @Override
registerDataSetObserver(DataSetObserver observer)508   public void registerDataSetObserver(DataSetObserver observer) {
509     cursor.registerDataSetObserver(observer);
510   }
511 
512   @Override
unregisterDataSetObserver(DataSetObserver observer)513   public void unregisterDataSetObserver(DataSetObserver observer) {
514     cursor.unregisterDataSetObserver(observer);
515   }
516 
517   @Override
setNotificationUri(ContentResolver cr, Uri uri)518   public void setNotificationUri(ContentResolver cr, Uri uri) {
519     cursor.setNotificationUri(cr, uri);
520   }
521 
522   @Override
getNotificationUri()523   public Uri getNotificationUri() {
524     return cursor.getNotificationUri();
525   }
526 
527   @Override
getWantsAllOnMoveCalls()528   public boolean getWantsAllOnMoveCalls() {
529     return cursor.getWantsAllOnMoveCalls();
530   }
531 
532   @Override
setExtras(Bundle extras)533   public void setExtras(Bundle extras) {
534     cursor.setExtras(extras);
535   }
536 
537   @Override
getExtras()538   public Bundle getExtras() {
539     return cursor.getExtras();
540   }
541 
542   @Override
respond(Bundle extras)543   public Bundle respond(Bundle extras) {
544     return cursor.respond(extras);
545   }
546 }
547