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