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.phonelookup.cp2; 18 19 import android.content.Context; 20 import android.content.SharedPreferences; 21 import android.database.Cursor; 22 import android.net.Uri; 23 import android.provider.ContactsContract; 24 import android.provider.ContactsContract.CommonDataKinds.Phone; 25 import android.provider.ContactsContract.Contacts; 26 import android.provider.ContactsContract.DeletedContacts; 27 import android.provider.ContactsContract.Directory; 28 import android.support.annotation.Nullable; 29 import android.support.v4.util.ArrayMap; 30 import android.support.v4.util.ArraySet; 31 import android.text.TextUtils; 32 import com.android.dialer.DialerPhoneNumber; 33 import com.android.dialer.common.Assert; 34 import com.android.dialer.common.LogUtil; 35 import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor; 36 import com.android.dialer.common.concurrent.Annotations.LightweightExecutor; 37 import com.android.dialer.configprovider.ConfigProvider; 38 import com.android.dialer.inject.ApplicationContext; 39 import com.android.dialer.logging.Logger; 40 import com.android.dialer.phonelookup.PhoneLookup; 41 import com.android.dialer.phonelookup.PhoneLookupInfo; 42 import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info; 43 import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info.Cp2ContactInfo; 44 import com.android.dialer.phonelookup.database.contract.PhoneLookupHistoryContract.PhoneLookupHistory; 45 import com.android.dialer.phonenumberproto.PartitionedNumbers; 46 import com.android.dialer.storage.Unencrypted; 47 import com.android.dialer.util.PermissionsUtil; 48 import com.google.common.collect.ImmutableMap; 49 import com.google.common.collect.ImmutableSet; 50 import com.google.common.collect.Iterables; 51 import com.google.common.collect.Maps; 52 import com.google.common.util.concurrent.Futures; 53 import com.google.common.util.concurrent.ListenableFuture; 54 import com.google.common.util.concurrent.ListeningExecutorService; 55 import com.google.common.util.concurrent.MoreExecutors; 56 import com.google.protobuf.InvalidProtocolBufferException; 57 import java.util.ArrayList; 58 import java.util.List; 59 import java.util.Map; 60 import java.util.Map.Entry; 61 import java.util.Set; 62 import java.util.concurrent.Callable; 63 import java.util.function.Predicate; 64 import javax.inject.Inject; 65 66 /** PhoneLookup implementation for contacts in the default directory. */ 67 public final class Cp2DefaultDirectoryPhoneLookup implements PhoneLookup<Cp2Info> { 68 69 private static final String PREF_LAST_TIMESTAMP_PROCESSED = 70 "cp2DefaultDirectoryPhoneLookupLastTimestampProcessed"; 71 72 private final Context appContext; 73 private final SharedPreferences sharedPreferences; 74 private final ListeningExecutorService backgroundExecutorService; 75 private final ListeningExecutorService lightweightExecutorService; 76 private final ConfigProvider configProvider; 77 private final MissingPermissionsOperations missingPermissionsOperations; 78 79 @Nullable private Long currentLastTimestampProcessed; 80 81 @Inject Cp2DefaultDirectoryPhoneLookup( @pplicationContext Context appContext, @Unencrypted SharedPreferences sharedPreferences, @BackgroundExecutor ListeningExecutorService backgroundExecutorService, @LightweightExecutor ListeningExecutorService lightweightExecutorService, ConfigProvider configProvider, MissingPermissionsOperations missingPermissionsOperations)82 Cp2DefaultDirectoryPhoneLookup( 83 @ApplicationContext Context appContext, 84 @Unencrypted SharedPreferences sharedPreferences, 85 @BackgroundExecutor ListeningExecutorService backgroundExecutorService, 86 @LightweightExecutor ListeningExecutorService lightweightExecutorService, 87 ConfigProvider configProvider, 88 MissingPermissionsOperations missingPermissionsOperations) { 89 this.appContext = appContext; 90 this.sharedPreferences = sharedPreferences; 91 this.backgroundExecutorService = backgroundExecutorService; 92 this.lightweightExecutorService = lightweightExecutorService; 93 this.configProvider = configProvider; 94 this.missingPermissionsOperations = missingPermissionsOperations; 95 } 96 97 @Override lookup(DialerPhoneNumber dialerPhoneNumber)98 public ListenableFuture<Cp2Info> lookup(DialerPhoneNumber dialerPhoneNumber) { 99 if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { 100 return Futures.immediateFuture(Cp2Info.getDefaultInstance()); 101 } 102 return backgroundExecutorService.submit(() -> lookupInternal(dialerPhoneNumber)); 103 } 104 lookupInternal(DialerPhoneNumber dialerPhoneNumber)105 private Cp2Info lookupInternal(DialerPhoneNumber dialerPhoneNumber) { 106 String number = dialerPhoneNumber.getNormalizedNumber(); 107 if (TextUtils.isEmpty(number)) { 108 return Cp2Info.getDefaultInstance(); 109 } 110 111 Set<Cp2ContactInfo> cp2ContactInfos = new ArraySet<>(); 112 113 // Even though this is only a single number, use PartitionedNumbers to mimic the logic used 114 // during getMostRecentInfo. 115 PartitionedNumbers partitionedNumbers = 116 new PartitionedNumbers(ImmutableSet.of(dialerPhoneNumber)); 117 118 Cursor cursor = null; 119 try { 120 // Note: It would make sense to use PHONE_LOOKUP for valid numbers as well, but we use PHONE 121 // to ensure consistency when the batch methods are used to update data. 122 if (!partitionedNumbers.validE164Numbers().isEmpty()) { 123 cursor = 124 queryPhoneTableBasedOnE164( 125 Cp2Projections.getProjectionForPhoneTable(), partitionedNumbers.validE164Numbers()); 126 } else { 127 cursor = 128 queryPhoneLookup( 129 Cp2Projections.getProjectionForPhoneLookupTable(), 130 Iterables.getOnlyElement(partitionedNumbers.invalidNumbers())); 131 } 132 if (cursor == null) { 133 LogUtil.w("Cp2DefaultDirectoryPhoneLookup.lookupInternal", "null cursor"); 134 return Cp2Info.getDefaultInstance(); 135 } 136 while (cursor.moveToNext()) { 137 cp2ContactInfos.add( 138 Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor, Directory.DEFAULT)); 139 } 140 } finally { 141 if (cursor != null) { 142 cursor.close(); 143 } 144 } 145 return Cp2Info.newBuilder().addAllCp2ContactInfo(cp2ContactInfos).build(); 146 } 147 148 @Override isDirty(ImmutableSet<DialerPhoneNumber> phoneNumbers)149 public ListenableFuture<Boolean> isDirty(ImmutableSet<DialerPhoneNumber> phoneNumbers) { 150 if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { 151 LogUtil.w("Cp2DefaultDirectoryPhoneLookup.isDirty", "missing permissions"); 152 Predicate<PhoneLookupInfo> phoneLookupInfoIsDirtyFn = 153 phoneLookupInfo -> 154 !phoneLookupInfo.getDefaultCp2Info().equals(Cp2Info.getDefaultInstance()); 155 return missingPermissionsOperations.isDirtyForMissingPermissions( 156 phoneNumbers, phoneLookupInfoIsDirtyFn); 157 } 158 159 PartitionedNumbers partitionedNumbers = new PartitionedNumbers(phoneNumbers); 160 if (partitionedNumbers.invalidNumbers().size() > getMaxSupportedInvalidNumbers()) { 161 // If there are N invalid numbers, we can't determine determine dirtiness without running N 162 // queries; since running this many queries is not feasible for the (lightweight) isDirty 163 // check, simply return true. The expectation is that this should rarely be the case as the 164 // vast majority of numbers in call logs should be valid. 165 LogUtil.v( 166 "Cp2DefaultDirectoryPhoneLookup.isDirty", 167 "returning true because too many invalid numbers (%d)", 168 partitionedNumbers.invalidNumbers().size()); 169 return Futures.immediateFuture(true); 170 } 171 172 ListenableFuture<Long> lastModifiedFuture = 173 backgroundExecutorService.submit( 174 () -> sharedPreferences.getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L)); 175 return Futures.transformAsync( 176 lastModifiedFuture, 177 lastModified -> { 178 // We are always going to need to do this check and it is pretty cheap so do it first. 179 ListenableFuture<Boolean> anyContactsDeletedFuture = 180 anyContactsDeletedSince(lastModified); 181 return Futures.transformAsync( 182 anyContactsDeletedFuture, 183 anyContactsDeleted -> { 184 if (anyContactsDeleted) { 185 LogUtil.v( 186 "Cp2DefaultDirectoryPhoneLookup.isDirty", 187 "returning true because contacts deleted"); 188 return Futures.immediateFuture(true); 189 } 190 // Hopefully the most common case is there are no contacts updated; we can detect 191 // this cheaply. 192 ListenableFuture<Boolean> noContactsModifiedSinceFuture = 193 noContactsModifiedSince(lastModified); 194 return Futures.transformAsync( 195 noContactsModifiedSinceFuture, 196 noContactsModifiedSince -> { 197 if (noContactsModifiedSince) { 198 LogUtil.v( 199 "Cp2DefaultDirectoryPhoneLookup.isDirty", 200 "returning false because no contacts modified since last run"); 201 return Futures.immediateFuture(false); 202 } 203 // This method is more expensive but is probably the most likely scenario; we 204 // are looking for changes to contacts which have been called. 205 ListenableFuture<Set<Long>> contactIdsFuture = 206 queryPhoneTableForContactIds(phoneNumbers); 207 ListenableFuture<Boolean> contactsUpdatedFuture = 208 Futures.transformAsync( 209 contactIdsFuture, 210 contactIds -> contactsUpdated(contactIds, lastModified), 211 MoreExecutors.directExecutor()); 212 return Futures.transformAsync( 213 contactsUpdatedFuture, 214 contactsUpdated -> { 215 if (contactsUpdated) { 216 LogUtil.v( 217 "Cp2DefaultDirectoryPhoneLookup.isDirty", 218 "returning true because a previously called contact was updated"); 219 return Futures.immediateFuture(true); 220 } 221 // This is the most expensive method so do it last; the scenario is that 222 // a contact which has been called got disassociated with a number and 223 // we need to clear their information. 224 ListenableFuture<Set<Long>> phoneLookupContactIdsFuture = 225 queryPhoneLookupHistoryForContactIds(); 226 return Futures.transformAsync( 227 phoneLookupContactIdsFuture, 228 phoneLookupContactIds -> 229 contactsUpdated(phoneLookupContactIds, lastModified), 230 MoreExecutors.directExecutor()); 231 }, 232 MoreExecutors.directExecutor()); 233 }, 234 MoreExecutors.directExecutor()); 235 }, 236 MoreExecutors.directExecutor()); 237 }, 238 MoreExecutors.directExecutor()); 239 } 240 241 /** 242 * Returns set of contact ids that correspond to {@code dialerPhoneNumbers} if the contact exists. 243 */ 244 private ListenableFuture<Set<Long>> queryPhoneTableForContactIds( 245 ImmutableSet<DialerPhoneNumber> dialerPhoneNumbers) { 246 PartitionedNumbers partitionedNumbers = new PartitionedNumbers(dialerPhoneNumbers); 247 248 List<ListenableFuture<Set<Long>>> queryFutures = new ArrayList<>(); 249 250 // First use the valid E164 numbers to query the NORMALIZED_NUMBER column. 251 queryFutures.add( 252 queryPhoneTableForContactIdsBasedOnE164(partitionedNumbers.validE164Numbers())); 253 254 // Then run a separate query for each invalid number. Separate queries are done to accomplish 255 // loose matching which couldn't be accomplished with a batch query. 256 Assert.checkState( 257 partitionedNumbers.invalidNumbers().size() <= getMaxSupportedInvalidNumbers()); 258 for (String invalidNumber : partitionedNumbers.invalidNumbers()) { 259 queryFutures.add(queryPhoneLookupTableForContactIdsBasedOnRawNumber(invalidNumber)); 260 } 261 return Futures.transform( 262 Futures.allAsList(queryFutures), 263 listOfSets -> { 264 Set<Long> contactIds = new ArraySet<>(); 265 for (Set<Long> ids : listOfSets) { 266 contactIds.addAll(ids); 267 } 268 return contactIds; 269 }, 270 lightweightExecutorService); 271 } 272 273 /** Gets all of the contact ids from PhoneLookupHistory. */ 274 private ListenableFuture<Set<Long>> queryPhoneLookupHistoryForContactIds() { 275 return backgroundExecutorService.submit( 276 () -> { 277 Set<Long> contactIds = new ArraySet<>(); 278 try (Cursor cursor = 279 appContext 280 .getContentResolver() 281 .query( 282 PhoneLookupHistory.CONTENT_URI, 283 new String[] { 284 PhoneLookupHistory.PHONE_LOOKUP_INFO, 285 }, 286 null, 287 null, 288 null)) { 289 290 if (cursor == null) { 291 LogUtil.w( 292 "Cp2DefaultDirectoryPhoneLookup.queryPhoneLookupHistoryForContactIds", 293 "null cursor"); 294 return contactIds; 295 } 296 297 if (cursor.moveToFirst()) { 298 int phoneLookupInfoColumn = 299 cursor.getColumnIndexOrThrow(PhoneLookupHistory.PHONE_LOOKUP_INFO); 300 do { 301 PhoneLookupInfo phoneLookupInfo; 302 try { 303 phoneLookupInfo = 304 PhoneLookupInfo.parseFrom(cursor.getBlob(phoneLookupInfoColumn)); 305 } catch (InvalidProtocolBufferException e) { 306 throw new IllegalStateException(e); 307 } 308 for (Cp2ContactInfo info : 309 phoneLookupInfo.getDefaultCp2Info().getCp2ContactInfoList()) { 310 contactIds.add(info.getContactId()); 311 } 312 } while (cursor.moveToNext()); 313 } 314 } 315 return contactIds; 316 }); 317 } 318 319 private ListenableFuture<Set<Long>> queryPhoneTableForContactIdsBasedOnE164( 320 Set<String> validE164Numbers) { 321 return backgroundExecutorService.submit( 322 () -> { 323 Set<Long> contactIds = new ArraySet<>(); 324 if (validE164Numbers.isEmpty()) { 325 return contactIds; 326 } 327 try (Cursor cursor = 328 queryPhoneTableBasedOnE164(new String[] {Phone.CONTACT_ID}, validE164Numbers)) { 329 if (cursor == null) { 330 LogUtil.w( 331 "Cp2DefaultDirectoryPhoneLookup.queryPhoneTableForContactIdsBasedOnE164", 332 "null cursor"); 333 return contactIds; 334 } 335 while (cursor.moveToNext()) { 336 contactIds.add(cursor.getLong(0 /* columnIndex */)); 337 } 338 } 339 return contactIds; 340 }); 341 } 342 343 private ListenableFuture<Set<Long>> queryPhoneLookupTableForContactIdsBasedOnRawNumber( 344 String rawNumber) { 345 if (TextUtils.isEmpty(rawNumber)) { 346 return Futures.immediateFuture(new ArraySet<>()); 347 } 348 return backgroundExecutorService.submit( 349 () -> { 350 Set<Long> contactIds = new ArraySet<>(); 351 try (Cursor cursor = 352 queryPhoneLookup(new String[] {ContactsContract.PhoneLookup.CONTACT_ID}, rawNumber)) { 353 if (cursor == null) { 354 LogUtil.w( 355 "Cp2DefaultDirectoryPhoneLookup.queryPhoneLookupTableForContactIdsBasedOnRawNumber", 356 "null cursor"); 357 return contactIds; 358 } 359 while (cursor.moveToNext()) { 360 contactIds.add(cursor.getLong(0 /* columnIndex */)); 361 } 362 } 363 return contactIds; 364 }); 365 } 366 367 /** Returns true if any contacts were modified after {@code lastModified}. */ 368 private ListenableFuture<Boolean> contactsUpdated(Set<Long> contactIds, long lastModified) { 369 return backgroundExecutorService.submit( 370 () -> { 371 try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) { 372 return cursor.getCount() > 0; 373 } 374 }); 375 } 376 377 private Cursor queryContactsTableForContacts(Set<Long> contactIds, long lastModified) { 378 // Filter to after last modified time based only on contacts we care about 379 String where = 380 Contacts.CONTACT_LAST_UPDATED_TIMESTAMP 381 + " > ?" 382 + " AND " 383 + Contacts._ID 384 + " IN (" 385 + questionMarks(contactIds.size()) 386 + ")"; 387 388 String[] args = new String[contactIds.size() + 1]; 389 args[0] = Long.toString(lastModified); 390 int i = 1; 391 for (Long contactId : contactIds) { 392 args[i++] = Long.toString(contactId); 393 } 394 395 return appContext 396 .getContentResolver() 397 .query( 398 Contacts.CONTENT_URI, 399 new String[] {Contacts._ID, Contacts.CONTACT_LAST_UPDATED_TIMESTAMP}, 400 where, 401 args, 402 null); 403 } 404 405 private ListenableFuture<Boolean> noContactsModifiedSince(long lastModified) { 406 return backgroundExecutorService.submit( 407 () -> { 408 try (Cursor cursor = 409 appContext 410 .getContentResolver() 411 .query( 412 Contacts.CONTENT_URI, 413 new String[] {Contacts._ID}, 414 Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?", 415 new String[] {Long.toString(lastModified)}, 416 Contacts._ID + " limit 1")) { 417 if (cursor == null) { 418 LogUtil.w("Cp2DefaultDirectoryPhoneLookup.noContactsModifiedSince", "null cursor"); 419 return false; 420 } 421 return cursor.getCount() == 0; 422 } 423 }); 424 } 425 426 /** Returns true if any contacts were deleted after {@code lastModified}. */ 427 private ListenableFuture<Boolean> anyContactsDeletedSince(long lastModified) { 428 return backgroundExecutorService.submit( 429 () -> { 430 try (Cursor cursor = 431 appContext 432 .getContentResolver() 433 .query( 434 DeletedContacts.CONTENT_URI, 435 new String[] {DeletedContacts.CONTACT_DELETED_TIMESTAMP}, 436 DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?", 437 new String[] {Long.toString(lastModified)}, 438 DeletedContacts.CONTACT_DELETED_TIMESTAMP + " limit 1")) { 439 if (cursor == null) { 440 LogUtil.w("Cp2DefaultDirectoryPhoneLookup.anyContactsDeletedSince", "null cursor"); 441 return false; 442 } 443 return cursor.getCount() > 0; 444 } 445 }); 446 } 447 448 @Override 449 public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) { 450 destination.setDefaultCp2Info(subMessage); 451 } 452 453 @Override 454 public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) { 455 return phoneLookupInfo.getDefaultCp2Info(); 456 } 457 458 @Override 459 public ListenableFuture<ImmutableMap<DialerPhoneNumber, Cp2Info>> getMostRecentInfo( 460 ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) { 461 currentLastTimestampProcessed = null; 462 463 if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { 464 LogUtil.w("Cp2DefaultDirectoryPhoneLookup.getMostRecentInfo", "missing permissions"); 465 return missingPermissionsOperations.getMostRecentInfoForMissingPermissions(existingInfoMap); 466 } 467 468 ListenableFuture<Long> lastModifiedFuture = 469 backgroundExecutorService.submit( 470 () -> sharedPreferences.getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L)); 471 return Futures.transformAsync( 472 lastModifiedFuture, 473 lastModified -> { 474 // Build a set of each DialerPhoneNumber that was associated with a contact, and is no 475 // longer associated with that same contact. 476 ListenableFuture<Set<DialerPhoneNumber>> deletedPhoneNumbersFuture = 477 getDeletedPhoneNumbers(existingInfoMap, lastModified); 478 479 return Futures.transformAsync( 480 deletedPhoneNumbersFuture, 481 deletedPhoneNumbers -> { 482 483 // If there are too many invalid numbers, just defer the work to render time. 484 ArraySet<DialerPhoneNumber> unprocessableNumbers = 485 findUnprocessableNumbers(existingInfoMap); 486 Map<DialerPhoneNumber, Cp2Info> existingInfoMapToProcess = existingInfoMap; 487 if (!unprocessableNumbers.isEmpty()) { 488 existingInfoMapToProcess = 489 Maps.filterKeys( 490 existingInfoMap, number -> !unprocessableNumbers.contains(number)); 491 } 492 493 // For each DialerPhoneNumber that was associated with a contact or added to a 494 // contact, build a map of those DialerPhoneNumbers to a set Cp2ContactInfos, where 495 // each Cp2ContactInfo represents a contact. 496 ListenableFuture<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>> 497 updatedContactsFuture = 498 buildMapForUpdatedOrAddedContacts( 499 existingInfoMapToProcess, lastModified, deletedPhoneNumbers); 500 501 return Futures.transform( 502 updatedContactsFuture, 503 updatedContacts -> { 504 505 // Start build a new map of updated info. This will replace existing info. 506 ImmutableMap.Builder<DialerPhoneNumber, Cp2Info> newInfoMapBuilder = 507 ImmutableMap.builder(); 508 509 // For each DialerPhoneNumber in existing info... 510 for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) { 511 DialerPhoneNumber dialerPhoneNumber = entry.getKey(); 512 Cp2Info existingInfo = entry.getValue(); 513 514 // Build off the existing info 515 Cp2Info.Builder infoBuilder = Cp2Info.newBuilder(existingInfo); 516 517 // If the contact was updated, replace the Cp2ContactInfo list 518 if (updatedContacts.containsKey(dialerPhoneNumber)) { 519 infoBuilder 520 .clear() 521 .addAllCp2ContactInfo(updatedContacts.get(dialerPhoneNumber)); 522 // If it was deleted and not added to a new contact, clear all the CP2 523 // information. 524 } else if (deletedPhoneNumbers.contains(dialerPhoneNumber)) { 525 infoBuilder.clear(); 526 } else if (unprocessableNumbers.contains(dialerPhoneNumber)) { 527 // Don't ever set the "incomplete" bit for numbers which are empty; this 528 // causes unnecessary render time work because there will never be contact 529 // information for an empty number. It is also required to pass the 530 // assertion check in the new voicemail fragment, which verifies that no 531 // voicemails rows are considered "incomplete" (the voicemail fragment 532 // does not have the ability to fetch information at render time). 533 if (!dialerPhoneNumber.getNormalizedNumber().isEmpty()) { 534 // Don't clear the existing info when the number is unprocessable. It's 535 // likely that the existing info is up-to-date so keep it in place so 536 // that the UI doesn't pop when the query is completed at display time. 537 infoBuilder.setIsIncomplete(true); 538 } 539 } 540 541 // If the DialerPhoneNumber didn't change, add the unchanged existing info. 542 newInfoMapBuilder.put(dialerPhoneNumber, infoBuilder.build()); 543 } 544 return newInfoMapBuilder.build(); 545 }, 546 lightweightExecutorService); 547 }, 548 lightweightExecutorService); 549 }, 550 lightweightExecutorService); 551 } 552 553 private ArraySet<DialerPhoneNumber> findUnprocessableNumbers( 554 ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) { 555 ArraySet<DialerPhoneNumber> unprocessableNumbers = new ArraySet<>(); 556 PartitionedNumbers partitionedNumbers = new PartitionedNumbers(existingInfoMap.keySet()); 557 558 int invalidNumberCount = partitionedNumbers.invalidNumbers().size(); 559 Logger.get(appContext).logAnnotatedCallLogMetrics(invalidNumberCount); 560 561 if (invalidNumberCount > getMaxSupportedInvalidNumbers()) { 562 for (String invalidNumber : partitionedNumbers.invalidNumbers()) { 563 unprocessableNumbers.addAll(partitionedNumbers.dialerPhoneNumbersForInvalid(invalidNumber)); 564 } 565 } 566 return unprocessableNumbers; 567 } 568 569 @Override 570 public ListenableFuture<Void> onSuccessfulBulkUpdate() { 571 return backgroundExecutorService.submit( 572 () -> { 573 if (currentLastTimestampProcessed != null) { 574 sharedPreferences 575 .edit() 576 .putLong(PREF_LAST_TIMESTAMP_PROCESSED, currentLastTimestampProcessed) 577 .apply(); 578 } 579 return null; 580 }); 581 } 582 583 private ListenableFuture<Set<DialerPhoneNumber>> findNumbersToUpdate( 584 Map<DialerPhoneNumber, Cp2Info> existingInfoMap, 585 long lastModified, 586 Set<DialerPhoneNumber> deletedPhoneNumbers) { 587 return backgroundExecutorService.submit( 588 () -> { 589 Set<DialerPhoneNumber> updatedNumbers = new ArraySet<>(); 590 Set<Long> contactIds = new ArraySet<>(); 591 for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) { 592 DialerPhoneNumber dialerPhoneNumber = entry.getKey(); 593 Cp2Info existingInfo = entry.getValue(); 594 595 // If the number was deleted, we need to check if it was added to a new contact. 596 if (deletedPhoneNumbers.contains(dialerPhoneNumber)) { 597 updatedNumbers.add(dialerPhoneNumber); 598 continue; 599 } 600 601 // When the PhoneLookupHistory contains no information for a number, because for 602 // example the user just upgraded to the new UI, or cleared data, we need to check for 603 // updated info. 604 if (existingInfo.getCp2ContactInfoCount() == 0) { 605 updatedNumbers.add(dialerPhoneNumber); 606 } else { 607 // For each Cp2ContactInfo for each existing DialerPhoneNumber... 608 // Store the contact id if it exist, else automatically add the DialerPhoneNumber to 609 // our set of DialerPhoneNumbers we want to update. 610 for (Cp2ContactInfo cp2ContactInfo : existingInfo.getCp2ContactInfoList()) { 611 long existingContactId = cp2ContactInfo.getContactId(); 612 if (existingContactId == 0) { 613 // If the number doesn't have a contact id, for various reasons, we need to look 614 // up the number to check if any exists. The various reasons this might happen 615 // are: 616 // - An existing contact that wasn't in the call log is now in the call log. 617 // - A number was in the call log before but has now been added to a contact. 618 // - A number is in the call log, but isn't associated with any contact. 619 updatedNumbers.add(dialerPhoneNumber); 620 } else { 621 contactIds.add(cp2ContactInfo.getContactId()); 622 } 623 } 624 } 625 } 626 627 // Query the contacts table and get those that whose 628 // Contacts.CONTACT_LAST_UPDATED_TIMESTAMP is after lastModified, such that Contacts._ID 629 // is in our set of contact IDs we build above. 630 if (!contactIds.isEmpty()) { 631 try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) { 632 int contactIdIndex = cursor.getColumnIndex(Contacts._ID); 633 int lastUpdatedIndex = cursor.getColumnIndex(Contacts.CONTACT_LAST_UPDATED_TIMESTAMP); 634 cursor.moveToPosition(-1); 635 while (cursor.moveToNext()) { 636 // Find the DialerPhoneNumber for each contact id and add it to our updated numbers 637 // set. These, along with our number not associated with any Cp2ContactInfo need to 638 // be updated. 639 long contactId = cursor.getLong(contactIdIndex); 640 updatedNumbers.addAll( 641 findDialerPhoneNumbersContainingContactId(existingInfoMap, contactId)); 642 long lastUpdatedTimestamp = cursor.getLong(lastUpdatedIndex); 643 if (currentLastTimestampProcessed == null 644 || currentLastTimestampProcessed < lastUpdatedTimestamp) { 645 currentLastTimestampProcessed = lastUpdatedTimestamp; 646 } 647 } 648 } 649 } 650 return updatedNumbers; 651 }); 652 } 653 654 @Override 655 public void registerContentObservers() { 656 // Do nothing since CP2 changes are too noisy. 657 } 658 659 @Override 660 public void unregisterContentObservers() {} 661 662 @Override 663 public ListenableFuture<Void> clearData() { 664 return backgroundExecutorService.submit( 665 () -> { 666 sharedPreferences.edit().remove(PREF_LAST_TIMESTAMP_PROCESSED).apply(); 667 return null; 668 }); 669 } 670 671 @Override 672 public String getLoggingName() { 673 return "Cp2DefaultDirectoryPhoneLookup"; 674 } 675 676 /** 677 * 1. get all contact ids. if the id is unset, add the number to the list of contacts to look up. 678 * 2. reduce our list of contact ids to those that were updated after lastModified. 3. Now we have 679 * the smallest set of dialer phone numbers to query cp2 against. 4. build and return the map of 680 * dialerphonenumbers to their new Cp2ContactInfo 681 * 682 * @return Map of {@link DialerPhoneNumber} to {@link Cp2Info} with updated {@link 683 * Cp2ContactInfo}. 684 */ 685 private ListenableFuture<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>> 686 buildMapForUpdatedOrAddedContacts( 687 Map<DialerPhoneNumber, Cp2Info> existingInfoMap, 688 long lastModified, 689 Set<DialerPhoneNumber> deletedPhoneNumbers) { 690 // Start by building a set of DialerPhoneNumbers that we want to update. 691 ListenableFuture<Set<DialerPhoneNumber>> updatedNumbersFuture = 692 findNumbersToUpdate(existingInfoMap, lastModified, deletedPhoneNumbers); 693 694 return Futures.transformAsync( 695 updatedNumbersFuture, 696 updatedNumbers -> { 697 if (updatedNumbers.isEmpty()) { 698 return Futures.immediateFuture(new ArrayMap<>()); 699 } 700 701 // Divide the numbers into those that are valid and those that are not. Issue a single 702 // batch query for the valid numbers against the PHONE table, and in parallel issue 703 // individual queries against PHONE_LOOKUP for each invalid number. 704 // TODO(zachh): These queries are inefficient without a lastModified column to filter on. 705 PartitionedNumbers partitionedNumbers = 706 new PartitionedNumbers(ImmutableSet.copyOf(updatedNumbers)); 707 708 ListenableFuture<Map<String, Set<Cp2ContactInfo>>> validNumbersFuture = 709 batchQueryForValidNumbers(partitionedNumbers.validE164Numbers()); 710 711 List<ListenableFuture<Set<Cp2ContactInfo>>> invalidNumbersFuturesList = new ArrayList<>(); 712 for (String invalidNumber : partitionedNumbers.invalidNumbers()) { 713 invalidNumbersFuturesList.add(individualQueryForInvalidNumber(invalidNumber)); 714 } 715 716 ListenableFuture<List<Set<Cp2ContactInfo>>> invalidNumbersFuture = 717 Futures.allAsList(invalidNumbersFuturesList); 718 719 Callable<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>> computeMap = 720 () -> { 721 // These get() calls are safe because we are using whenAllSucceed below. 722 Map<String, Set<Cp2ContactInfo>> validNumbersResult = validNumbersFuture.get(); 723 List<Set<Cp2ContactInfo>> invalidNumbersResult = invalidNumbersFuture.get(); 724 725 Map<DialerPhoneNumber, Set<Cp2ContactInfo>> map = new ArrayMap<>(); 726 727 // First update the map with the valid number results. 728 for (Entry<String, Set<Cp2ContactInfo>> entry : validNumbersResult.entrySet()) { 729 String validNumber = entry.getKey(); 730 Set<Cp2ContactInfo> cp2ContactInfos = entry.getValue(); 731 732 Set<DialerPhoneNumber> dialerPhoneNumbers = 733 partitionedNumbers.dialerPhoneNumbersForValidE164(validNumber); 734 735 addInfo(map, dialerPhoneNumbers, cp2ContactInfos); 736 737 // We are going to remove the numbers that we've handled so that we later can 738 // detect numbers that weren't handled and therefore need to have their contact 739 // information removed. 740 updatedNumbers.removeAll(dialerPhoneNumbers); 741 } 742 743 // Next update the map with the invalid results. 744 int i = 0; 745 for (String invalidNumber : partitionedNumbers.invalidNumbers()) { 746 Set<Cp2ContactInfo> cp2Infos = invalidNumbersResult.get(i++); 747 Set<DialerPhoneNumber> dialerPhoneNumbers = 748 partitionedNumbers.dialerPhoneNumbersForInvalid(invalidNumber); 749 750 addInfo(map, dialerPhoneNumbers, cp2Infos); 751 752 // We are going to remove the numbers that we've handled so that we later can 753 // detect numbers that weren't handled and therefore need to have their contact 754 // information removed. 755 updatedNumbers.removeAll(dialerPhoneNumbers); 756 } 757 758 // The leftovers in updatedNumbers that weren't removed are numbers that were 759 // previously associated with contacts, but are no longer. Remove the contact 760 // information for them. 761 for (DialerPhoneNumber dialerPhoneNumber : updatedNumbers) { 762 map.put(dialerPhoneNumber, ImmutableSet.of()); 763 } 764 LogUtil.v( 765 "Cp2DefaultDirectoryPhoneLookup.buildMapForUpdatedOrAddedContacts", 766 "found %d numbers that may need updating", 767 updatedNumbers.size()); 768 return map; 769 }; 770 return Futures.whenAllSucceed(validNumbersFuture, invalidNumbersFuture) 771 .call(computeMap, lightweightExecutorService); 772 }, 773 lightweightExecutorService); 774 } 775 776 private ListenableFuture<Map<String, Set<Cp2ContactInfo>>> batchQueryForValidNumbers( 777 Set<String> validE164Numbers) { 778 return backgroundExecutorService.submit( 779 () -> { 780 Map<String, Set<Cp2ContactInfo>> cp2ContactInfosByNumber = new ArrayMap<>(); 781 if (validE164Numbers.isEmpty()) { 782 return cp2ContactInfosByNumber; 783 } 784 try (Cursor cursor = 785 queryPhoneTableBasedOnE164( 786 Cp2Projections.getProjectionForPhoneTable(), validE164Numbers)) { 787 if (cursor == null) { 788 LogUtil.w("Cp2DefaultDirectoryPhoneLookup.batchQueryForValidNumbers", "null cursor"); 789 } else { 790 while (cursor.moveToNext()) { 791 String validE164Number = Cp2Projections.getNormalizedNumberFromCursor(cursor); 792 Set<Cp2ContactInfo> cp2ContactInfos = cp2ContactInfosByNumber.get(validE164Number); 793 if (cp2ContactInfos == null) { 794 cp2ContactInfos = new ArraySet<>(); 795 cp2ContactInfosByNumber.put(validE164Number, cp2ContactInfos); 796 } 797 cp2ContactInfos.add( 798 Cp2Projections.buildCp2ContactInfoFromCursor( 799 appContext, cursor, Directory.DEFAULT)); 800 } 801 } 802 } 803 return cp2ContactInfosByNumber; 804 }); 805 } 806 807 private ListenableFuture<Set<Cp2ContactInfo>> individualQueryForInvalidNumber( 808 String invalidNumber) { 809 return backgroundExecutorService.submit( 810 () -> { 811 Set<Cp2ContactInfo> cp2ContactInfos = new ArraySet<>(); 812 if (invalidNumber.isEmpty()) { 813 return cp2ContactInfos; 814 } 815 try (Cursor cursor = 816 queryPhoneLookup(Cp2Projections.getProjectionForPhoneLookupTable(), invalidNumber)) { 817 if (cursor == null) { 818 LogUtil.w( 819 "Cp2DefaultDirectoryPhoneLookup.individualQueryForInvalidNumber", "null cursor"); 820 } else { 821 while (cursor.moveToNext()) { 822 cp2ContactInfos.add( 823 Cp2Projections.buildCp2ContactInfoFromCursor( 824 appContext, cursor, Directory.DEFAULT)); 825 } 826 } 827 } 828 return cp2ContactInfos; 829 }); 830 } 831 832 /** 833 * Adds the {@code cp2ContactInfo} to the entries for all specified {@code dialerPhoneNumbers} in 834 * the {@code map}. 835 */ 836 private static void addInfo( 837 Map<DialerPhoneNumber, Set<Cp2ContactInfo>> map, 838 Set<DialerPhoneNumber> dialerPhoneNumbers, 839 Set<Cp2ContactInfo> cp2ContactInfos) { 840 for (DialerPhoneNumber dialerPhoneNumber : dialerPhoneNumbers) { 841 Set<Cp2ContactInfo> existingInfos = map.get(dialerPhoneNumber); 842 if (existingInfos == null) { 843 existingInfos = new ArraySet<>(); 844 map.put(dialerPhoneNumber, existingInfos); 845 } 846 existingInfos.addAll(cp2ContactInfos); 847 } 848 } 849 850 private Cursor queryPhoneTableBasedOnE164(String[] projection, Set<String> validE164Numbers) { 851 return appContext 852 .getContentResolver() 853 .query( 854 Phone.CONTENT_URI, 855 projection, 856 Phone.NORMALIZED_NUMBER + " IN (" + questionMarks(validE164Numbers.size()) + ")", 857 validE164Numbers.toArray(new String[validE164Numbers.size()]), 858 null); 859 } 860 861 private Cursor queryPhoneLookup(String[] projection, String rawNumber) { 862 Uri uri = 863 Uri.withAppendedPath( 864 ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(rawNumber)); 865 return appContext.getContentResolver().query(uri, projection, null, null, null); 866 } 867 868 /** Returns set of DialerPhoneNumbers that were associated with now deleted contacts. */ 869 private ListenableFuture<Set<DialerPhoneNumber>> getDeletedPhoneNumbers( 870 ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap, long lastModified) { 871 return backgroundExecutorService.submit( 872 () -> { 873 // Build set of all contact IDs from our existing data. We're going to use this set to 874 // query against the DeletedContacts table and see if any of them were deleted. 875 Set<Long> contactIds = findContactIdsIn(existingInfoMap); 876 877 // Start building a set of DialerPhoneNumbers that were associated with now deleted 878 // contacts. 879 try (Cursor cursor = queryDeletedContacts(contactIds, lastModified)) { 880 // We now have a cursor/list of contact IDs that were associated with deleted contacts. 881 return findDeletedPhoneNumbersIn(existingInfoMap, cursor); 882 } 883 }); 884 } 885 886 private Set<Long> findContactIdsIn(ImmutableMap<DialerPhoneNumber, Cp2Info> map) { 887 Set<Long> contactIds = new ArraySet<>(); 888 for (Cp2Info info : map.values()) { 889 for (Cp2ContactInfo cp2ContactInfo : info.getCp2ContactInfoList()) { 890 contactIds.add(cp2ContactInfo.getContactId()); 891 } 892 } 893 return contactIds; 894 } 895 896 private Cursor queryDeletedContacts(Set<Long> contactIds, long lastModified) { 897 String where = 898 DeletedContacts.CONTACT_DELETED_TIMESTAMP 899 + " > ?" 900 + " AND " 901 + DeletedContacts.CONTACT_ID 902 + " IN (" 903 + questionMarks(contactIds.size()) 904 + ")"; 905 String[] args = new String[contactIds.size() + 1]; 906 args[0] = Long.toString(lastModified); 907 int i = 1; 908 for (Long contactId : contactIds) { 909 args[i++] = Long.toString(contactId); 910 } 911 912 return appContext 913 .getContentResolver() 914 .query( 915 DeletedContacts.CONTENT_URI, 916 new String[] {DeletedContacts.CONTACT_ID, DeletedContacts.CONTACT_DELETED_TIMESTAMP}, 917 where, 918 args, 919 null); 920 } 921 922 /** Returns set of DialerPhoneNumbers that are associated with deleted contact IDs. */ 923 private Set<DialerPhoneNumber> findDeletedPhoneNumbersIn( 924 ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap, Cursor cursor) { 925 int contactIdIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_ID); 926 int deletedTimeIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_DELETED_TIMESTAMP); 927 Set<DialerPhoneNumber> deletedPhoneNumbers = new ArraySet<>(); 928 cursor.moveToPosition(-1); 929 while (cursor.moveToNext()) { 930 long contactId = cursor.getLong(contactIdIndex); 931 deletedPhoneNumbers.addAll( 932 findDialerPhoneNumbersContainingContactId(existingInfoMap, contactId)); 933 long deletedTime = cursor.getLong(deletedTimeIndex); 934 if (currentLastTimestampProcessed == null || currentLastTimestampProcessed < deletedTime) { 935 // TODO(zachh): There's a problem here if a contact for a new row is deleted? 936 currentLastTimestampProcessed = deletedTime; 937 } 938 } 939 return deletedPhoneNumbers; 940 } 941 942 private static Set<DialerPhoneNumber> findDialerPhoneNumbersContainingContactId( 943 Map<DialerPhoneNumber, Cp2Info> existingInfoMap, long contactId) { 944 Set<DialerPhoneNumber> matches = new ArraySet<>(); 945 for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) { 946 for (Cp2ContactInfo cp2ContactInfo : entry.getValue().getCp2ContactInfoList()) { 947 if (cp2ContactInfo.getContactId() == contactId) { 948 matches.add(entry.getKey()); 949 } 950 } 951 } 952 Assert.checkArgument( 953 matches.size() > 0, "Couldn't find DialerPhoneNumber for contact ID: " + contactId); 954 return matches; 955 } 956 957 private static String questionMarks(int count) { 958 StringBuilder where = new StringBuilder(); 959 for (int i = 0; i < count; i++) { 960 if (i != 0) { 961 where.append(", "); 962 } 963 where.append("?"); 964 } 965 return where.toString(); 966 } 967 968 /** 969 * We cannot efficiently process invalid numbers because batch queries cannot be constructed which 970 * accomplish the necessary loose matching. We'll attempt to process a limited number of them, but 971 * if there are too many we fall back to querying CP2 at render time. 972 */ 973 private long getMaxSupportedInvalidNumbers() { 974 return configProvider.getLong("cp2_phone_lookup_max_invalid_numbers", 5); 975 } 976 } 977