summaryrefslogtreecommitdiff
path: root/java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java')
-rw-r--r--java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java919
1 files changed, 919 insertions, 0 deletions
diff --git a/java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java b/java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java
new file mode 100644
index 000000000..a79eb19db
--- /dev/null
+++ b/java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java
@@ -0,0 +1,919 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.dialer.phonelookup.cp2;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.DeletedContacts;
+import android.support.annotation.Nullable;
+import android.support.v4.util.ArrayMap;
+import android.support.v4.util.ArraySet;
+import android.text.TextUtils;
+import com.android.dialer.DialerPhoneNumber;
+import com.android.dialer.common.Assert;
+import com.android.dialer.common.LogUtil;
+import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor;
+import com.android.dialer.common.concurrent.Annotations.LightweightExecutor;
+import com.android.dialer.inject.ApplicationContext;
+import com.android.dialer.phonelookup.PhoneLookup;
+import com.android.dialer.phonelookup.PhoneLookupInfo;
+import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info;
+import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info.Cp2ContactInfo;
+import com.android.dialer.phonelookup.database.contract.PhoneLookupHistoryContract.PhoneLookupHistory;
+import com.android.dialer.phonenumberproto.PartitionedNumbers;
+import com.android.dialer.storage.Unencrypted;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.protobuf.InvalidProtocolBufferException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import javax.inject.Inject;
+
+/** PhoneLookup implementation for contacts in the default directory. */
+public final class Cp2DefaultDirectoryPhoneLookup implements PhoneLookup<Cp2Info> {
+
+ private static final String PREF_LAST_TIMESTAMP_PROCESSED =
+ "cp2DefaultDirectoryPhoneLookupLastTimestampProcessed";
+
+ // We cannot efficiently process invalid numbers because batch queries cannot be constructed which
+ // accomplish the necessary loose matching. We'll attempt to process a limited number of them,
+ // but if there are too many we fall back to querying CP2 at render time.
+ private static final int MAX_SUPPORTED_INVALID_NUMBERS = 5;
+
+ private final Context appContext;
+ private final SharedPreferences sharedPreferences;
+ private final ListeningExecutorService backgroundExecutorService;
+ private final ListeningExecutorService lightweightExecutorService;
+
+ @Nullable private Long currentLastTimestampProcessed;
+
+ @Inject
+ Cp2DefaultDirectoryPhoneLookup(
+ @ApplicationContext Context appContext,
+ @Unencrypted SharedPreferences sharedPreferences,
+ @BackgroundExecutor ListeningExecutorService backgroundExecutorService,
+ @LightweightExecutor ListeningExecutorService lightweightExecutorService) {
+ this.appContext = appContext;
+ this.sharedPreferences = sharedPreferences;
+ this.backgroundExecutorService = backgroundExecutorService;
+ this.lightweightExecutorService = lightweightExecutorService;
+ }
+
+ @Override
+ public ListenableFuture<Cp2Info> lookup(DialerPhoneNumber dialerPhoneNumber) {
+ return backgroundExecutorService.submit(() -> lookupInternal(dialerPhoneNumber));
+ }
+
+ private Cp2Info lookupInternal(DialerPhoneNumber dialerPhoneNumber) {
+ String number = dialerPhoneNumber.getNormalizedNumber();
+ if (TextUtils.isEmpty(number)) {
+ return Cp2Info.getDefaultInstance();
+ }
+
+ Set<Cp2ContactInfo> cp2ContactInfos = new ArraySet<>();
+
+ // Even though this is only a single number, use PartitionedNumbers to mimic the logic used
+ // during getMostRecentInfo.
+ PartitionedNumbers partitionedNumbers =
+ new PartitionedNumbers(ImmutableSet.of(dialerPhoneNumber));
+
+ Cursor cursor = null;
+ try {
+ // Note: It would make sense to use PHONE_LOOKUP for valid numbers as well, but we use PHONE
+ // to ensure consistency when the batch methods are used to update data.
+ if (!partitionedNumbers.validE164Numbers().isEmpty()) {
+ cursor =
+ queryPhoneTableBasedOnE164(
+ Cp2Projections.getProjectionForPhoneTable(), partitionedNumbers.validE164Numbers());
+ } else {
+ cursor =
+ queryPhoneLookup(
+ Cp2Projections.getProjectionForPhoneLookupTable(),
+ Iterables.getOnlyElement(partitionedNumbers.invalidNumbers()));
+ }
+ if (cursor == null) {
+ LogUtil.w("Cp2DefaultDirectoryPhoneLookup.lookupInternal", "null cursor");
+ return Cp2Info.getDefaultInstance();
+ }
+ while (cursor.moveToNext()) {
+ cp2ContactInfos.add(Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor));
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return Cp2Info.newBuilder().addAllCp2ContactInfo(cp2ContactInfos).build();
+ }
+
+ @Override
+ public ListenableFuture<Boolean> isDirty(ImmutableSet<DialerPhoneNumber> phoneNumbers) {
+ PartitionedNumbers partitionedNumbers = new PartitionedNumbers(phoneNumbers);
+ if (partitionedNumbers.invalidNumbers().size() > MAX_SUPPORTED_INVALID_NUMBERS) {
+ // If there are N invalid numbers, we can't determine determine dirtiness without running N
+ // queries; since running this many queries is not feasible for the (lightweight) isDirty
+ // check, simply return true. The expectation is that this should rarely be the case as the
+ // vast majority of numbers in call logs should be valid.
+ LogUtil.v(
+ "Cp2DefaultDirectoryPhoneLookup.isDirty",
+ "returning true because too many invalid numbers (%d)",
+ partitionedNumbers.invalidNumbers().size());
+ return Futures.immediateFuture(true);
+ }
+
+ ListenableFuture<Long> lastModifiedFuture =
+ backgroundExecutorService.submit(
+ () -> sharedPreferences.getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L));
+ return Futures.transformAsync(
+ lastModifiedFuture,
+ lastModified -> {
+ // We are always going to need to do this check and it is pretty cheap so do it first.
+ ListenableFuture<Boolean> anyContactsDeletedFuture =
+ anyContactsDeletedSince(lastModified);
+ return Futures.transformAsync(
+ anyContactsDeletedFuture,
+ anyContactsDeleted -> {
+ if (anyContactsDeleted) {
+ LogUtil.v(
+ "Cp2DefaultDirectoryPhoneLookup.isDirty",
+ "returning true because contacts deleted");
+ return Futures.immediateFuture(true);
+ }
+ // Hopefully the most common case is there are no contacts updated; we can detect
+ // this cheaply.
+ ListenableFuture<Boolean> noContactsModifiedSinceFuture =
+ noContactsModifiedSince(lastModified);
+ return Futures.transformAsync(
+ noContactsModifiedSinceFuture,
+ noContactsModifiedSince -> {
+ if (noContactsModifiedSince) {
+ LogUtil.v(
+ "Cp2DefaultDirectoryPhoneLookup.isDirty",
+ "returning false because no contacts modified since last run");
+ return Futures.immediateFuture(false);
+ }
+ // This method is more expensive but is probably the most likely scenario; we
+ // are looking for changes to contacts which have been called.
+ ListenableFuture<Set<Long>> contactIdsFuture =
+ queryPhoneTableForContactIds(phoneNumbers);
+ ListenableFuture<Boolean> contactsUpdatedFuture =
+ Futures.transformAsync(
+ contactIdsFuture,
+ contactIds -> contactsUpdated(contactIds, lastModified),
+ MoreExecutors.directExecutor());
+ return Futures.transformAsync(
+ contactsUpdatedFuture,
+ contactsUpdated -> {
+ if (contactsUpdated) {
+ LogUtil.v(
+ "Cp2DefaultDirectoryPhoneLookup.isDirty",
+ "returning true because a previously called contact was updated");
+ return Futures.immediateFuture(true);
+ }
+ // This is the most expensive method so do it last; the scenario is that
+ // a contact which has been called got disassociated with a number and
+ // we need to clear their information.
+ ListenableFuture<Set<Long>> phoneLookupContactIdsFuture =
+ queryPhoneLookupHistoryForContactIds();
+ return Futures.transformAsync(
+ phoneLookupContactIdsFuture,
+ phoneLookupContactIds ->
+ contactsUpdated(phoneLookupContactIds, lastModified),
+ MoreExecutors.directExecutor());
+ },
+ MoreExecutors.directExecutor());
+ },
+ MoreExecutors.directExecutor());
+ },
+ MoreExecutors.directExecutor());
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ /**
+ * Returns set of contact ids that correspond to {@code dialerPhoneNumbers} if the contact exists.
+ */
+ private ListenableFuture<Set<Long>> queryPhoneTableForContactIds(
+ ImmutableSet<DialerPhoneNumber> dialerPhoneNumbers) {
+ PartitionedNumbers partitionedNumbers = new PartitionedNumbers(dialerPhoneNumbers);
+
+ List<ListenableFuture<Set<Long>>> queryFutures = new ArrayList<>();
+
+ // First use the valid E164 numbers to query the NORMALIZED_NUMBER column.
+ queryFutures.add(
+ queryPhoneTableForContactIdsBasedOnE164(partitionedNumbers.validE164Numbers()));
+
+ // Then run a separate query for each invalid number. Separate queries are done to accomplish
+ // loose matching which couldn't be accomplished with a batch query.
+ Assert.checkState(partitionedNumbers.invalidNumbers().size() <= MAX_SUPPORTED_INVALID_NUMBERS);
+ for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
+ queryFutures.add(queryPhoneLookupTableForContactIdsBasedOnRawNumber(invalidNumber));
+ }
+ return Futures.transform(
+ Futures.allAsList(queryFutures),
+ listOfSets -> {
+ Set<Long> contactIds = new ArraySet<>();
+ for (Set<Long> ids : listOfSets) {
+ contactIds.addAll(ids);
+ }
+ return contactIds;
+ },
+ lightweightExecutorService);
+ }
+
+ /** Gets all of the contact ids from PhoneLookupHistory. */
+ private ListenableFuture<Set<Long>> queryPhoneLookupHistoryForContactIds() {
+ return backgroundExecutorService.submit(
+ () -> {
+ Set<Long> contactIds = new ArraySet<>();
+ try (Cursor cursor =
+ appContext
+ .getContentResolver()
+ .query(
+ PhoneLookupHistory.CONTENT_URI,
+ new String[] {
+ PhoneLookupHistory.PHONE_LOOKUP_INFO,
+ },
+ null,
+ null,
+ null)) {
+
+ if (cursor == null) {
+ LogUtil.w(
+ "Cp2DefaultDirectoryPhoneLookup.queryPhoneLookupHistoryForContactIds",
+ "null cursor");
+ return contactIds;
+ }
+
+ if (cursor.moveToFirst()) {
+ int phoneLookupInfoColumn =
+ cursor.getColumnIndexOrThrow(PhoneLookupHistory.PHONE_LOOKUP_INFO);
+ do {
+ PhoneLookupInfo phoneLookupInfo;
+ try {
+ phoneLookupInfo =
+ PhoneLookupInfo.parseFrom(cursor.getBlob(phoneLookupInfoColumn));
+ } catch (InvalidProtocolBufferException e) {
+ throw new IllegalStateException(e);
+ }
+ for (Cp2ContactInfo info :
+ phoneLookupInfo.getDefaultCp2Info().getCp2ContactInfoList()) {
+ contactIds.add(info.getContactId());
+ }
+ } while (cursor.moveToNext());
+ }
+ }
+ return contactIds;
+ });
+ }
+
+ private ListenableFuture<Set<Long>> queryPhoneTableForContactIdsBasedOnE164(
+ Set<String> validE164Numbers) {
+ return backgroundExecutorService.submit(
+ () -> {
+ Set<Long> contactIds = new ArraySet<>();
+ if (validE164Numbers.isEmpty()) {
+ return contactIds;
+ }
+ try (Cursor cursor =
+ queryPhoneTableBasedOnE164(new String[] {Phone.CONTACT_ID}, validE164Numbers)) {
+ if (cursor == null) {
+ LogUtil.w(
+ "Cp2DefaultDirectoryPhoneLookup.queryPhoneTableForContactIdsBasedOnE164",
+ "null cursor");
+ return contactIds;
+ }
+ while (cursor.moveToNext()) {
+ contactIds.add(cursor.getLong(0 /* columnIndex */));
+ }
+ }
+ return contactIds;
+ });
+ }
+
+ private ListenableFuture<Set<Long>> queryPhoneLookupTableForContactIdsBasedOnRawNumber(
+ String rawNumber) {
+ if (TextUtils.isEmpty(rawNumber)) {
+ return Futures.immediateFuture(new ArraySet<>());
+ }
+ return backgroundExecutorService.submit(
+ () -> {
+ Set<Long> contactIds = new ArraySet<>();
+ try (Cursor cursor =
+ queryPhoneLookup(new String[] {ContactsContract.PhoneLookup.CONTACT_ID}, rawNumber)) {
+ if (cursor == null) {
+ LogUtil.w(
+ "Cp2DefaultDirectoryPhoneLookup.queryPhoneLookupTableForContactIdsBasedOnRawNumber",
+ "null cursor");
+ return contactIds;
+ }
+ while (cursor.moveToNext()) {
+ contactIds.add(cursor.getLong(0 /* columnIndex */));
+ }
+ }
+ return contactIds;
+ });
+ }
+
+ /** Returns true if any contacts were modified after {@code lastModified}. */
+ private ListenableFuture<Boolean> contactsUpdated(Set<Long> contactIds, long lastModified) {
+ return backgroundExecutorService.submit(
+ () -> {
+ try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) {
+ return cursor.getCount() > 0;
+ }
+ });
+ }
+
+ private Cursor queryContactsTableForContacts(Set<Long> contactIds, long lastModified) {
+ // Filter to after last modified time based only on contacts we care about
+ String where =
+ Contacts.CONTACT_LAST_UPDATED_TIMESTAMP
+ + " > ?"
+ + " AND "
+ + Contacts._ID
+ + " IN ("
+ + questionMarks(contactIds.size())
+ + ")";
+
+ String[] args = new String[contactIds.size() + 1];
+ args[0] = Long.toString(lastModified);
+ int i = 1;
+ for (Long contactId : contactIds) {
+ args[i++] = Long.toString(contactId);
+ }
+
+ return appContext
+ .getContentResolver()
+ .query(
+ Contacts.CONTENT_URI,
+ new String[] {Contacts._ID, Contacts.CONTACT_LAST_UPDATED_TIMESTAMP},
+ where,
+ args,
+ null);
+ }
+
+ private ListenableFuture<Boolean> noContactsModifiedSince(long lastModified) {
+ return backgroundExecutorService.submit(
+ () -> {
+ try (Cursor cursor =
+ appContext
+ .getContentResolver()
+ .query(
+ Contacts.CONTENT_URI,
+ new String[] {Contacts._ID},
+ Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + " > ?",
+ new String[] {Long.toString(lastModified)},
+ Contacts._ID + " limit 1")) {
+ if (cursor == null) {
+ LogUtil.w("Cp2DefaultDirectoryPhoneLookup.noContactsModifiedSince", "null cursor");
+ return false;
+ }
+ return cursor.getCount() == 0;
+ }
+ });
+ }
+
+ /** Returns true if any contacts were deleted after {@code lastModified}. */
+ private ListenableFuture<Boolean> anyContactsDeletedSince(long lastModified) {
+ return backgroundExecutorService.submit(
+ () -> {
+ try (Cursor cursor =
+ appContext
+ .getContentResolver()
+ .query(
+ DeletedContacts.CONTENT_URI,
+ new String[] {DeletedContacts.CONTACT_DELETED_TIMESTAMP},
+ DeletedContacts.CONTACT_DELETED_TIMESTAMP + " > ?",
+ new String[] {Long.toString(lastModified)},
+ DeletedContacts.CONTACT_DELETED_TIMESTAMP + " limit 1")) {
+ if (cursor == null) {
+ LogUtil.w("Cp2DefaultDirectoryPhoneLookup.anyContactsDeletedSince", "null cursor");
+ return false;
+ }
+ return cursor.getCount() > 0;
+ }
+ });
+ }
+
+ @Override
+ public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) {
+ destination.setDefaultCp2Info(subMessage);
+ }
+
+ @Override
+ public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) {
+ return phoneLookupInfo.getDefaultCp2Info();
+ }
+
+ @Override
+ public ListenableFuture<ImmutableMap<DialerPhoneNumber, Cp2Info>> getMostRecentInfo(
+ ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) {
+ currentLastTimestampProcessed = null;
+
+ ListenableFuture<Long> lastModifiedFuture =
+ backgroundExecutorService.submit(
+ () -> sharedPreferences.getLong(PREF_LAST_TIMESTAMP_PROCESSED, 0L));
+ return Futures.transformAsync(
+ lastModifiedFuture,
+ lastModified -> {
+ // Build a set of each DialerPhoneNumber that was associated with a contact, and is no
+ // longer associated with that same contact.
+ ListenableFuture<Set<DialerPhoneNumber>> deletedPhoneNumbersFuture =
+ getDeletedPhoneNumbers(existingInfoMap, lastModified);
+
+ return Futures.transformAsync(
+ deletedPhoneNumbersFuture,
+ deletedPhoneNumbers -> {
+
+ // If there are too many invalid numbers, just defer the work to render time.
+ ArraySet<DialerPhoneNumber> unprocessableNumbers =
+ findUnprocessableNumbers(existingInfoMap);
+ Map<DialerPhoneNumber, Cp2Info> existingInfoMapToProcess = existingInfoMap;
+ if (!unprocessableNumbers.isEmpty()) {
+ existingInfoMapToProcess =
+ Maps.filterKeys(
+ existingInfoMap, number -> !unprocessableNumbers.contains(number));
+ }
+
+ // For each DialerPhoneNumber that was associated with a contact or added to a
+ // contact, build a map of those DialerPhoneNumbers to a set Cp2ContactInfos, where
+ // each Cp2ContactInfo represents a contact.
+ ListenableFuture<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>>
+ updatedContactsFuture =
+ buildMapForUpdatedOrAddedContacts(
+ existingInfoMapToProcess, lastModified, deletedPhoneNumbers);
+
+ return Futures.transform(
+ updatedContactsFuture,
+ updatedContacts -> {
+
+ // Start build a new map of updated info. This will replace existing info.
+ ImmutableMap.Builder<DialerPhoneNumber, Cp2Info> newInfoMapBuilder =
+ ImmutableMap.builder();
+
+ // For each DialerPhoneNumber in existing info...
+ for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) {
+ DialerPhoneNumber dialerPhoneNumber = entry.getKey();
+ Cp2Info existingInfo = entry.getValue();
+
+ // Build off the existing info
+ Cp2Info.Builder infoBuilder = Cp2Info.newBuilder(existingInfo);
+
+ // If the contact was updated, replace the Cp2ContactInfo list
+ if (updatedContacts.containsKey(dialerPhoneNumber)) {
+ infoBuilder
+ .clear()
+ .addAllCp2ContactInfo(updatedContacts.get(dialerPhoneNumber));
+ // If it was deleted and not added to a new contact, clear all the CP2
+ // information.
+ } else if (deletedPhoneNumbers.contains(dialerPhoneNumber)) {
+ infoBuilder.clear();
+ } else if (unprocessableNumbers.contains(dialerPhoneNumber)) {
+ // Don't ever set the "incomplete" bit for numbers which are empty; this
+ // causes unnecessary render time work because there will never be contact
+ // information for an empty number. It is also required to pass the
+ // assertion check in the new voicemail fragment, which verifies that no
+ // voicemails rows are considered "incomplete" (the voicemail fragment
+ // does not have the ability to fetch information at render time).
+ if (!dialerPhoneNumber.getNormalizedNumber().isEmpty()) {
+ // Don't clear the existing info when the number is unprocessable. It's
+ // likely that the existing info is up-to-date so keep it in place so
+ // that the UI doesn't pop when the query is completed at display time.
+ infoBuilder.setIsIncomplete(true);
+ }
+ }
+
+ // If the DialerPhoneNumber didn't change, add the unchanged existing info.
+ newInfoMapBuilder.put(dialerPhoneNumber, infoBuilder.build());
+ }
+ return newInfoMapBuilder.build();
+ },
+ lightweightExecutorService);
+ },
+ lightweightExecutorService);
+ },
+ lightweightExecutorService);
+ }
+
+ private ArraySet<DialerPhoneNumber> findUnprocessableNumbers(
+ ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) {
+ ArraySet<DialerPhoneNumber> unprocessableNumbers = new ArraySet<>();
+ PartitionedNumbers partitionedNumbers = new PartitionedNumbers(existingInfoMap.keySet());
+ if (partitionedNumbers.invalidNumbers().size() > MAX_SUPPORTED_INVALID_NUMBERS) {
+ for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
+ unprocessableNumbers.addAll(partitionedNumbers.dialerPhoneNumbersForInvalid(invalidNumber));
+ }
+ }
+ return unprocessableNumbers;
+ }
+
+ @Override
+ public ListenableFuture<Void> onSuccessfulBulkUpdate() {
+ return backgroundExecutorService.submit(
+ () -> {
+ if (currentLastTimestampProcessed != null) {
+ sharedPreferences
+ .edit()
+ .putLong(PREF_LAST_TIMESTAMP_PROCESSED, currentLastTimestampProcessed)
+ .apply();
+ }
+ return null;
+ });
+ }
+
+ private ListenableFuture<Set<DialerPhoneNumber>> findNumbersToUpdate(
+ Map<DialerPhoneNumber, Cp2Info> existingInfoMap,
+ long lastModified,
+ Set<DialerPhoneNumber> deletedPhoneNumbers) {
+ return backgroundExecutorService.submit(
+ () -> {
+ Set<DialerPhoneNumber> updatedNumbers = new ArraySet<>();
+ Set<Long> contactIds = new ArraySet<>();
+ for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) {
+ DialerPhoneNumber dialerPhoneNumber = entry.getKey();
+ Cp2Info existingInfo = entry.getValue();
+
+ // If the number was deleted, we need to check if it was added to a new contact.
+ if (deletedPhoneNumbers.contains(dialerPhoneNumber)) {
+ updatedNumbers.add(dialerPhoneNumber);
+ continue;
+ }
+
+ // When the PhoneLookupHistory contains no information for a number, because for
+ // example the user just upgraded to the new UI, or cleared data, we need to check for
+ // updated info.
+ if (existingInfo.getCp2ContactInfoCount() == 0) {
+ updatedNumbers.add(dialerPhoneNumber);
+ } else {
+ // For each Cp2ContactInfo for each existing DialerPhoneNumber...
+ // Store the contact id if it exist, else automatically add the DialerPhoneNumber to
+ // our set of DialerPhoneNumbers we want to update.
+ for (Cp2ContactInfo cp2ContactInfo : existingInfo.getCp2ContactInfoList()) {
+ long existingContactId = cp2ContactInfo.getContactId();
+ if (existingContactId == 0) {
+ // If the number doesn't have a contact id, for various reasons, we need to look
+ // up the number to check if any exists. The various reasons this might happen
+ // are:
+ // - An existing contact that wasn't in the call log is now in the call log.
+ // - A number was in the call log before but has now been added to a contact.
+ // - A number is in the call log, but isn't associated with any contact.
+ updatedNumbers.add(dialerPhoneNumber);
+ } else {
+ contactIds.add(cp2ContactInfo.getContactId());
+ }
+ }
+ }
+ }
+
+ // Query the contacts table and get those that whose
+ // Contacts.CONTACT_LAST_UPDATED_TIMESTAMP is after lastModified, such that Contacts._ID
+ // is in our set of contact IDs we build above.
+ if (!contactIds.isEmpty()) {
+ try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) {
+ int contactIdIndex = cursor.getColumnIndex(Contacts._ID);
+ int lastUpdatedIndex = cursor.getColumnIndex(Contacts.CONTACT_LAST_UPDATED_TIMESTAMP);
+ cursor.moveToPosition(-1);
+ while (cursor.moveToNext()) {
+ // Find the DialerPhoneNumber for each contact id and add it to our updated numbers
+ // set. These, along with our number not associated with any Cp2ContactInfo need to
+ // be updated.
+ long contactId = cursor.getLong(contactIdIndex);
+ updatedNumbers.addAll(
+ findDialerPhoneNumbersContainingContactId(existingInfoMap, contactId));
+ long lastUpdatedTimestamp = cursor.getLong(lastUpdatedIndex);
+ if (currentLastTimestampProcessed == null
+ || currentLastTimestampProcessed < lastUpdatedTimestamp) {
+ currentLastTimestampProcessed = lastUpdatedTimestamp;
+ }
+ }
+ }
+ }
+ return updatedNumbers;
+ });
+ }
+
+ @Override
+ public void registerContentObservers(Context appContext) {
+ // Do nothing since CP2 changes are too noisy.
+ }
+
+ /**
+ * 1. get all contact ids. if the id is unset, add the number to the list of contacts to look up.
+ * 2. reduce our list of contact ids to those that were updated after lastModified. 3. Now we have
+ * the smallest set of dialer phone numbers to query cp2 against. 4. build and return the map of
+ * dialerphonenumbers to their new Cp2ContactInfo
+ *
+ * @return Map of {@link DialerPhoneNumber} to {@link Cp2Info} with updated {@link
+ * Cp2ContactInfo}.
+ */
+ private ListenableFuture<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>>
+ buildMapForUpdatedOrAddedContacts(
+ Map<DialerPhoneNumber, Cp2Info> existingInfoMap,
+ long lastModified,
+ Set<DialerPhoneNumber> deletedPhoneNumbers) {
+ // Start by building a set of DialerPhoneNumbers that we want to update.
+ ListenableFuture<Set<DialerPhoneNumber>> updatedNumbersFuture =
+ findNumbersToUpdate(existingInfoMap, lastModified, deletedPhoneNumbers);
+
+ return Futures.transformAsync(
+ updatedNumbersFuture,
+ updatedNumbers -> {
+ if (updatedNumbers.isEmpty()) {
+ return Futures.immediateFuture(new ArrayMap<>());
+ }
+
+ // Divide the numbers into those that are valid and those that are not. Issue a single
+ // batch query for the valid numbers against the PHONE table, and in parallel issue
+ // individual queries against PHONE_LOOKUP for each invalid number.
+ // TODO(zachh): These queries are inefficient without a lastModified column to filter on.
+ PartitionedNumbers partitionedNumbers =
+ new PartitionedNumbers(ImmutableSet.copyOf(updatedNumbers));
+
+ ListenableFuture<Map<String, Set<Cp2ContactInfo>>> validNumbersFuture =
+ batchQueryForValidNumbers(partitionedNumbers.validE164Numbers());
+
+ List<ListenableFuture<Set<Cp2ContactInfo>>> invalidNumbersFuturesList = new ArrayList<>();
+ for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
+ invalidNumbersFuturesList.add(individualQueryForInvalidNumber(invalidNumber));
+ }
+
+ ListenableFuture<List<Set<Cp2ContactInfo>>> invalidNumbersFuture =
+ Futures.allAsList(invalidNumbersFuturesList);
+
+ Callable<Map<DialerPhoneNumber, Set<Cp2ContactInfo>>> computeMap =
+ () -> {
+ // These get() calls are safe because we are using whenAllSucceed below.
+ Map<String, Set<Cp2ContactInfo>> validNumbersResult = validNumbersFuture.get();
+ List<Set<Cp2ContactInfo>> invalidNumbersResult = invalidNumbersFuture.get();
+
+ Map<DialerPhoneNumber, Set<Cp2ContactInfo>> map = new ArrayMap<>();
+
+ // First update the map with the valid number results.
+ for (Entry<String, Set<Cp2ContactInfo>> entry : validNumbersResult.entrySet()) {
+ String validNumber = entry.getKey();
+ Set<Cp2ContactInfo> cp2ContactInfos = entry.getValue();
+
+ Set<DialerPhoneNumber> dialerPhoneNumbers =
+ partitionedNumbers.dialerPhoneNumbersForValidE164(validNumber);
+
+ addInfo(map, dialerPhoneNumbers, cp2ContactInfos);
+
+ // We are going to remove the numbers that we've handled so that we later can
+ // detect numbers that weren't handled and therefore need to have their contact
+ // information removed.
+ updatedNumbers.removeAll(dialerPhoneNumbers);
+ }
+
+ // Next update the map with the invalid results.
+ int i = 0;
+ for (String invalidNumber : partitionedNumbers.invalidNumbers()) {
+ Set<Cp2ContactInfo> cp2Infos = invalidNumbersResult.get(i++);
+ Set<DialerPhoneNumber> dialerPhoneNumbers =
+ partitionedNumbers.dialerPhoneNumbersForInvalid(invalidNumber);
+
+ addInfo(map, dialerPhoneNumbers, cp2Infos);
+
+ // We are going to remove the numbers that we've handled so that we later can
+ // detect numbers that weren't handled and therefore need to have their contact
+ // information removed.
+ updatedNumbers.removeAll(dialerPhoneNumbers);
+ }
+
+ // The leftovers in updatedNumbers that weren't removed are numbers that were
+ // previously associated with contacts, but are no longer. Remove the contact
+ // information for them.
+ for (DialerPhoneNumber dialerPhoneNumber : updatedNumbers) {
+ map.put(dialerPhoneNumber, ImmutableSet.of());
+ }
+ LogUtil.v(
+ "Cp2DefaultDirectoryPhoneLookup.buildMapForUpdatedOrAddedContacts",
+ "found %d numbers that may need updating",
+ updatedNumbers.size());
+ return map;
+ };
+ return Futures.whenAllSucceed(validNumbersFuture, invalidNumbersFuture)
+ .call(computeMap, lightweightExecutorService);
+ },
+ lightweightExecutorService);
+ }
+
+ private ListenableFuture<Map<String, Set<Cp2ContactInfo>>> batchQueryForValidNumbers(
+ Set<String> validE164Numbers) {
+ return backgroundExecutorService.submit(
+ () -> {
+ Map<String, Set<Cp2ContactInfo>> cp2ContactInfosByNumber = new ArrayMap<>();
+ if (validE164Numbers.isEmpty()) {
+ return cp2ContactInfosByNumber;
+ }
+ try (Cursor cursor =
+ queryPhoneTableBasedOnE164(
+ Cp2Projections.getProjectionForPhoneTable(), validE164Numbers)) {
+ if (cursor == null) {
+ LogUtil.w("Cp2DefaultDirectoryPhoneLookup.batchQueryForValidNumbers", "null cursor");
+ } else {
+ while (cursor.moveToNext()) {
+ String validE164Number = Cp2Projections.getNormalizedNumberFromCursor(cursor);
+ Set<Cp2ContactInfo> cp2ContactInfos = cp2ContactInfosByNumber.get(validE164Number);
+ if (cp2ContactInfos == null) {
+ cp2ContactInfos = new ArraySet<>();
+ cp2ContactInfosByNumber.put(validE164Number, cp2ContactInfos);
+ }
+ cp2ContactInfos.add(
+ Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor));
+ }
+ }
+ }
+ return cp2ContactInfosByNumber;
+ });
+ }
+
+ private ListenableFuture<Set<Cp2ContactInfo>> individualQueryForInvalidNumber(
+ String invalidNumber) {
+ return backgroundExecutorService.submit(
+ () -> {
+ Set<Cp2ContactInfo> cp2ContactInfos = new ArraySet<>();
+ if (invalidNumber.isEmpty()) {
+ return cp2ContactInfos;
+ }
+ try (Cursor cursor =
+ queryPhoneLookup(Cp2Projections.getProjectionForPhoneLookupTable(), invalidNumber)) {
+ if (cursor == null) {
+ LogUtil.w(
+ "Cp2DefaultDirectoryPhoneLookup.individualQueryForInvalidNumber", "null cursor");
+ } else {
+ while (cursor.moveToNext()) {
+ cp2ContactInfos.add(
+ Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor));
+ }
+ }
+ }
+ return cp2ContactInfos;
+ });
+ }
+
+ /**
+ * Adds the {@code cp2ContactInfo} to the entries for all specified {@code dialerPhoneNumbers} in
+ * the {@code map}.
+ */
+ private static void addInfo(
+ Map<DialerPhoneNumber, Set<Cp2ContactInfo>> map,
+ Set<DialerPhoneNumber> dialerPhoneNumbers,
+ Set<Cp2ContactInfo> cp2ContactInfos) {
+ for (DialerPhoneNumber dialerPhoneNumber : dialerPhoneNumbers) {
+ Set<Cp2ContactInfo> existingInfos = map.get(dialerPhoneNumber);
+ if (existingInfos == null) {
+ existingInfos = new ArraySet<>();
+ map.put(dialerPhoneNumber, existingInfos);
+ }
+ existingInfos.addAll(cp2ContactInfos);
+ }
+ }
+
+ private Cursor queryPhoneTableBasedOnE164(String[] projection, Set<String> validE164Numbers) {
+ return appContext
+ .getContentResolver()
+ .query(
+ Phone.CONTENT_URI,
+ projection,
+ Phone.NORMALIZED_NUMBER + " IN (" + questionMarks(validE164Numbers.size()) + ")",
+ validE164Numbers.toArray(new String[validE164Numbers.size()]),
+ null);
+ }
+
+ private Cursor queryPhoneLookup(String[] projection, String rawNumber) {
+ Uri uri =
+ Uri.withAppendedPath(
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(rawNumber));
+ return appContext.getContentResolver().query(uri, projection, null, null, null);
+ }
+
+ /** Returns set of DialerPhoneNumbers that were associated with now deleted contacts. */
+ private ListenableFuture<Set<DialerPhoneNumber>> getDeletedPhoneNumbers(
+ ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap, long lastModified) {
+ return backgroundExecutorService.submit(
+ () -> {
+ // Build set of all contact IDs from our existing data. We're going to use this set to
+ // query against the DeletedContacts table and see if any of them were deleted.
+ Set<Long> contactIds = findContactIdsIn(existingInfoMap);
+
+ // Start building a set of DialerPhoneNumbers that were associated with now deleted
+ // contacts.
+ try (Cursor cursor = queryDeletedContacts(contactIds, lastModified)) {
+ // We now have a cursor/list of contact IDs that were associated with deleted contacts.
+ return findDeletedPhoneNumbersIn(existingInfoMap, cursor);
+ }
+ });
+ }
+
+ private Set<Long> findContactIdsIn(ImmutableMap<DialerPhoneNumber, Cp2Info> map) {
+ Set<Long> contactIds = new ArraySet<>();
+ for (Cp2Info info : map.values()) {
+ for (Cp2ContactInfo cp2ContactInfo : info.getCp2ContactInfoList()) {
+ contactIds.add(cp2ContactInfo.getContactId());
+ }
+ }
+ return contactIds;
+ }
+
+ private Cursor queryDeletedContacts(Set<Long> contactIds, long lastModified) {
+ String where =
+ DeletedContacts.CONTACT_DELETED_TIMESTAMP
+ + " > ?"
+ + " AND "
+ + DeletedContacts.CONTACT_ID
+ + " IN ("
+ + questionMarks(contactIds.size())
+ + ")";
+ String[] args = new String[contactIds.size() + 1];
+ args[0] = Long.toString(lastModified);
+ int i = 1;
+ for (Long contactId : contactIds) {
+ args[i++] = Long.toString(contactId);
+ }
+
+ return appContext
+ .getContentResolver()
+ .query(
+ DeletedContacts.CONTENT_URI,
+ new String[] {DeletedContacts.CONTACT_ID, DeletedContacts.CONTACT_DELETED_TIMESTAMP},
+ where,
+ args,
+ null);
+ }
+
+ /** Returns set of DialerPhoneNumbers that are associated with deleted contact IDs. */
+ private Set<DialerPhoneNumber> findDeletedPhoneNumbersIn(
+ ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap, Cursor cursor) {
+ int contactIdIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_ID);
+ int deletedTimeIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_DELETED_TIMESTAMP);
+ Set<DialerPhoneNumber> deletedPhoneNumbers = new ArraySet<>();
+ cursor.moveToPosition(-1);
+ while (cursor.moveToNext()) {
+ long contactId = cursor.getLong(contactIdIndex);
+ deletedPhoneNumbers.addAll(
+ findDialerPhoneNumbersContainingContactId(existingInfoMap, contactId));
+ long deletedTime = cursor.getLong(deletedTimeIndex);
+ if (currentLastTimestampProcessed == null || currentLastTimestampProcessed < deletedTime) {
+ // TODO(zachh): There's a problem here if a contact for a new row is deleted?
+ currentLastTimestampProcessed = deletedTime;
+ }
+ }
+ return deletedPhoneNumbers;
+ }
+
+ private static Set<DialerPhoneNumber> findDialerPhoneNumbersContainingContactId(
+ Map<DialerPhoneNumber, Cp2Info> existingInfoMap, long contactId) {
+ Set<DialerPhoneNumber> matches = new ArraySet<>();
+ for (Entry<DialerPhoneNumber, Cp2Info> entry : existingInfoMap.entrySet()) {
+ for (Cp2ContactInfo cp2ContactInfo : entry.getValue().getCp2ContactInfoList()) {
+ if (cp2ContactInfo.getContactId() == contactId) {
+ matches.add(entry.getKey());
+ }
+ }
+ }
+ Assert.checkArgument(
+ matches.size() > 0, "Couldn't find DialerPhoneNumber for contact ID: " + contactId);
+ return matches;
+ }
+
+ private static String questionMarks(int count) {
+ StringBuilder where = new StringBuilder();
+ for (int i = 0; i < count; i++) {
+ if (i != 0) {
+ where.append(", ");
+ }
+ where.append("?");
+ }
+ return where.toString();
+ }
+}