From 3506a5f7fd4e71c2f780b4ed5613c9c609154f06 Mon Sep 17 00:00:00 2001 From: linyuh Date: Mon, 26 Feb 2018 12:45:32 -0800 Subject: Look up contacts in the local enterprise directory in the new call log. Bug: 73547944 Test: Cp2ExtendedDirectoryPhoneLookupTest PiperOrigin-RevId: 187064655 Change-Id: Icb468e0867248f097a77134dd67a53352f7c80b0 --- .../dialer/phonelookup/PhoneLookupModule.java | 12 +- .../consolidator/PhoneLookupInfoConsolidator.java | 135 +-- .../cp2/Cp2DefaultDirectoryPhoneLookup.java | 919 +++++++++++++++++++++ .../cp2/Cp2ExtendedDirectoryPhoneLookup.java | 248 ++++++ .../phonelookup/cp2/Cp2LocalPhoneLookup.java | 914 -------------------- .../phonelookup/cp2/Cp2RemotePhoneLookup.java | 243 ------ .../dialer/phonelookup/phone_lookup_info.proto | 51 +- 7 files changed, 1270 insertions(+), 1252 deletions(-) create mode 100644 java/com/android/dialer/phonelookup/cp2/Cp2DefaultDirectoryPhoneLookup.java create mode 100644 java/com/android/dialer/phonelookup/cp2/Cp2ExtendedDirectoryPhoneLookup.java delete mode 100644 java/com/android/dialer/phonelookup/cp2/Cp2LocalPhoneLookup.java delete mode 100644 java/com/android/dialer/phonelookup/cp2/Cp2RemotePhoneLookup.java (limited to 'java/com/android/dialer/phonelookup') diff --git a/java/com/android/dialer/phonelookup/PhoneLookupModule.java b/java/com/android/dialer/phonelookup/PhoneLookupModule.java index 3e21e7c77..a4cc5c7a4 100644 --- a/java/com/android/dialer/phonelookup/PhoneLookupModule.java +++ b/java/com/android/dialer/phonelookup/PhoneLookupModule.java @@ -18,8 +18,8 @@ package com.android.dialer.phonelookup; import com.android.dialer.phonelookup.blockednumber.DialerBlockedNumberPhoneLookup; import com.android.dialer.phonelookup.blockednumber.SystemBlockedNumberPhoneLookup; -import com.android.dialer.phonelookup.cp2.Cp2LocalPhoneLookup; -import com.android.dialer.phonelookup.cp2.Cp2RemotePhoneLookup; +import com.android.dialer.phonelookup.cp2.Cp2DefaultDirectoryPhoneLookup; +import com.android.dialer.phonelookup.cp2.Cp2ExtendedDirectoryPhoneLookup; import com.android.dialer.phonelookup.spam.SpamPhoneLookup; import com.google.common.collect.ImmutableList; import dagger.Module; @@ -32,14 +32,14 @@ public abstract class PhoneLookupModule { @Provides @SuppressWarnings({"unchecked", "rawtype"}) static ImmutableList providePhoneLookupList( - Cp2LocalPhoneLookup cp2LocalPhoneLookup, - Cp2RemotePhoneLookup cp2RemotePhoneLookup, + Cp2DefaultDirectoryPhoneLookup cp2DefaultDirectoryPhoneLookup, + Cp2ExtendedDirectoryPhoneLookup cp2ExtendedDirectoryPhoneLookup, DialerBlockedNumberPhoneLookup dialerBlockedNumberPhoneLookup, SystemBlockedNumberPhoneLookup systemBlockedNumberPhoneLookup, SpamPhoneLookup spamPhoneLookup) { return ImmutableList.of( - cp2LocalPhoneLookup, - cp2RemotePhoneLookup, + cp2DefaultDirectoryPhoneLookup, + cp2ExtendedDirectoryPhoneLookup, dialerBlockedNumberPhoneLookup, systemBlockedNumberPhoneLookup, spamPhoneLookup); diff --git a/java/com/android/dialer/phonelookup/consolidator/PhoneLookupInfoConsolidator.java b/java/com/android/dialer/phonelookup/consolidator/PhoneLookupInfoConsolidator.java index 9c5411081..3a48fd538 100644 --- a/java/com/android/dialer/phonelookup/consolidator/PhoneLookupInfoConsolidator.java +++ b/java/com/android/dialer/phonelookup/consolidator/PhoneLookupInfoConsolidator.java @@ -40,11 +40,16 @@ public final class PhoneLookupInfoConsolidator { /** Integers representing {@link PhoneLookup} implementations that can provide a contact's name */ @Retention(RetentionPolicy.SOURCE) - @IntDef({NameSource.NONE, NameSource.CP2_LOCAL, NameSource.CP2_REMOTE, NameSource.PEOPLE_API}) + @IntDef({ + NameSource.NONE, + NameSource.CP2_DEFAULT_DIRECTORY, + NameSource.CP2_EXTENDED_DIRECTORY, + NameSource.PEOPLE_API + }) @interface NameSource { int NONE = 0; // used when none of the other sources can provide the name - int CP2_LOCAL = 1; - int CP2_REMOTE = 2; + int CP2_DEFAULT_DIRECTORY = 1; + int CP2_EXTENDED_DIRECTORY = 2; int PEOPLE_API = 3; } @@ -53,31 +58,35 @@ public final class PhoneLookupInfoConsolidator { * *

Each source is one of the values in NameSource, as defined above. * - *

Sources are sorted in the order of priority. For example, if source CP2_LOCAL can provide - * the name, we will use that name in the UI and ignore all the other sources. If source CP2_LOCAL - * can't provide the name, source CP2_REMOTE will be consulted. + *

Sources are sorted in the order of priority. For example, if source CP2_DEFAULT_DIRECTORY + * can provide the name, we will use that name in the UI and ignore all the other sources. If + * source CP2_DEFAULT_DIRECTORY can't provide the name, source CP2_EXTENDED_DIRECTORY will be + * consulted. * *

The reason for defining a name source is to avoid mixing info from different sub-messages in * PhoneLookupInfo proto when we are supposed to stick with only one sub-message. For example, if - * a PhoneLookupInfo proto has both cp2_local_info and cp2_remote_info but only cp2_remote_info - * has a photo URI, PhoneLookupInfoConsolidator should provide an empty photo URI as CP2_LOCAL has - * higher priority and we should not use cp2_remote_info's photo URI to display the contact's - * photo. + * a PhoneLookupInfo proto has both default_cp2_info and extended_cp2_info but only + * extended_cp2_info has a photo URI, PhoneLookupInfoConsolidator should provide an empty photo + * URI as CP2_DEFAULT_DIRECTORY has higher priority and we should not use extended_cp2_info's + * photo URI to display the contact's photo. */ private static final ImmutableList NAME_SOURCES_IN_PRIORITY_ORDER = - ImmutableList.of(NameSource.CP2_LOCAL, NameSource.CP2_REMOTE, NameSource.PEOPLE_API); + ImmutableList.of( + NameSource.CP2_DEFAULT_DIRECTORY, + NameSource.CP2_EXTENDED_DIRECTORY, + NameSource.PEOPLE_API); private final @NameSource int nameSource; private final PhoneLookupInfo phoneLookupInfo; - @Nullable private final Cp2ContactInfo firstCp2LocalContact; - @Nullable private final Cp2ContactInfo firstCp2RemoteContact; + @Nullable private final Cp2ContactInfo firstDefaultCp2Contact; + @Nullable private final Cp2ContactInfo firstExtendedCp2Contact; public PhoneLookupInfoConsolidator(PhoneLookupInfo phoneLookupInfo) { this.phoneLookupInfo = phoneLookupInfo; - this.firstCp2LocalContact = getFirstLocalContact(); - this.firstCp2RemoteContact = getFirstRemoteContact(); + this.firstDefaultCp2Contact = getFirstContactInDefaultDirectory(); + this.firstExtendedCp2Contact = getFirstContactInExtendedDirectories(); this.nameSource = selectNameSource(); } @@ -92,10 +101,10 @@ public final class PhoneLookupInfoConsolidator { */ public String getName() { switch (nameSource) { - case NameSource.CP2_LOCAL: - return Assert.isNotNull(firstCp2LocalContact).getName(); - case NameSource.CP2_REMOTE: - return Assert.isNotNull(firstCp2RemoteContact).getName(); + case NameSource.CP2_DEFAULT_DIRECTORY: + return Assert.isNotNull(firstDefaultCp2Contact).getName(); + case NameSource.CP2_EXTENDED_DIRECTORY: + return Assert.isNotNull(firstExtendedCp2Contact).getName(); case NameSource.PEOPLE_API: return phoneLookupInfo.getPeopleApiInfo().getDisplayName(); case NameSource.NONE: @@ -115,10 +124,10 @@ public final class PhoneLookupInfoConsolidator { */ public String getPhotoThumbnailUri() { switch (nameSource) { - case NameSource.CP2_LOCAL: - return Assert.isNotNull(firstCp2LocalContact).getPhotoThumbnailUri(); - case NameSource.CP2_REMOTE: - return Assert.isNotNull(firstCp2RemoteContact).getPhotoThumbnailUri(); + case NameSource.CP2_DEFAULT_DIRECTORY: + return Assert.isNotNull(firstDefaultCp2Contact).getPhotoThumbnailUri(); + case NameSource.CP2_EXTENDED_DIRECTORY: + return Assert.isNotNull(firstExtendedCp2Contact).getPhotoThumbnailUri(); case NameSource.PEOPLE_API: case NameSource.NONE: return ""; @@ -137,10 +146,10 @@ public final class PhoneLookupInfoConsolidator { */ public String getPhotoUri() { switch (nameSource) { - case NameSource.CP2_LOCAL: - return Assert.isNotNull(firstCp2LocalContact).getPhotoUri(); - case NameSource.CP2_REMOTE: - return Assert.isNotNull(firstCp2RemoteContact).getPhotoUri(); + case NameSource.CP2_DEFAULT_DIRECTORY: + return Assert.isNotNull(firstDefaultCp2Contact).getPhotoUri(); + case NameSource.CP2_EXTENDED_DIRECTORY: + return Assert.isNotNull(firstExtendedCp2Contact).getPhotoUri(); case NameSource.PEOPLE_API: case NameSource.NONE: return ""; @@ -156,10 +165,10 @@ public final class PhoneLookupInfoConsolidator { */ public long getPhotoId() { switch (nameSource) { - case NameSource.CP2_LOCAL: - return Math.max(Assert.isNotNull(firstCp2LocalContact).getPhotoId(), 0); - case NameSource.CP2_REMOTE: - return Math.max(Assert.isNotNull(firstCp2RemoteContact).getPhotoId(), 0); + case NameSource.CP2_DEFAULT_DIRECTORY: + return Math.max(Assert.isNotNull(firstDefaultCp2Contact).getPhotoId(), 0); + case NameSource.CP2_EXTENDED_DIRECTORY: + return Math.max(Assert.isNotNull(firstExtendedCp2Contact).getPhotoId(), 0); case NameSource.PEOPLE_API: case NameSource.NONE: return 0; @@ -176,10 +185,10 @@ public final class PhoneLookupInfoConsolidator { */ public String getLookupUri() { switch (nameSource) { - case NameSource.CP2_LOCAL: - return Assert.isNotNull(firstCp2LocalContact).getLookupUri(); - case NameSource.CP2_REMOTE: - return Assert.isNotNull(firstCp2RemoteContact).getLookupUri(); + case NameSource.CP2_DEFAULT_DIRECTORY: + return Assert.isNotNull(firstDefaultCp2Contact).getLookupUri(); + case NameSource.CP2_EXTENDED_DIRECTORY: + return Assert.isNotNull(firstExtendedCp2Contact).getLookupUri(); case NameSource.PEOPLE_API: return Assert.isNotNull(phoneLookupInfo.getPeopleApiInfo().getLookupUri()); case NameSource.NONE: @@ -200,10 +209,10 @@ public final class PhoneLookupInfoConsolidator { */ public String getNumberLabel() { switch (nameSource) { - case NameSource.CP2_LOCAL: - return Assert.isNotNull(firstCp2LocalContact).getLabel(); - case NameSource.CP2_REMOTE: - return Assert.isNotNull(firstCp2RemoteContact).getLabel(); + case NameSource.CP2_DEFAULT_DIRECTORY: + return Assert.isNotNull(firstDefaultCp2Contact).getLabel(); + case NameSource.CP2_EXTENDED_DIRECTORY: + return Assert.isNotNull(firstExtendedCp2Contact).getLabel(); case NameSource.PEOPLE_API: case NameSource.NONE: return ""; @@ -259,11 +268,11 @@ public final class PhoneLookupInfoConsolidator { } /** - * Returns true if the {@link PhoneLookupInfo} passed to the constructor has incomplete CP2 local - * info. + * Returns true if the {@link PhoneLookupInfo} passed to the constructor has incomplete default + * CP2 info (info from the default directory). */ - public boolean isCp2LocalInfoIncomplete() { - return phoneLookupInfo.getCp2LocalInfo().getIsIncomplete(); + public boolean isDefaultCp2InfoIncomplete() { + return phoneLookupInfo.getDefaultCp2Info().getIsIncomplete(); } /** @@ -275,8 +284,8 @@ public final class PhoneLookupInfoConsolidator { */ public boolean canReportAsInvalidNumber() { switch (nameSource) { - case NameSource.CP2_LOCAL: - case NameSource.CP2_REMOTE: + case NameSource.CP2_DEFAULT_DIRECTORY: + case NameSource.CP2_EXTENDED_DIRECTORY: return false; case NameSource.PEOPLE_API: PeopleApiInfo peopleApiInfo = phoneLookupInfo.getPeopleApiInfo(); @@ -291,26 +300,26 @@ public final class PhoneLookupInfoConsolidator { } /** - * Arbitrarily select the first local CP2 contact. In the future, it may make sense to display - * contact information from all contacts with the same number (for example show the name as "Mom, - * Dad" or show a synthesized photo containing photos of both "Mom" and "Dad"). + * Arbitrarily select the first CP2 contact in the default directory. In the future, it may make + * sense to display contact information from all contacts with the same number (for example show + * the name as "Mom, Dad" or show a synthesized photo containing photos of both "Mom" and "Dad"). */ @Nullable - private Cp2ContactInfo getFirstLocalContact() { - return phoneLookupInfo.getCp2LocalInfo().getCp2ContactInfoCount() > 0 - ? phoneLookupInfo.getCp2LocalInfo().getCp2ContactInfo(0) + private Cp2ContactInfo getFirstContactInDefaultDirectory() { + return phoneLookupInfo.getDefaultCp2Info().getCp2ContactInfoCount() > 0 + ? phoneLookupInfo.getDefaultCp2Info().getCp2ContactInfo(0) : null; } /** - * Arbitrarily select the first remote CP2 contact. In the future, it may make sense to display - * contact information from all contacts with the same number (for example show the name as "Mom, - * Dad" or show a synthesized photo containing photos of both "Mom" and "Dad"). + * Arbitrarily select the first CP2 contact in extended directories. In the future, it may make + * sense to display contact information from all contacts with the same number (for example show + * the name as "Mom, Dad" or show a synthesized photo containing photos of both "Mom" and "Dad"). */ @Nullable - private Cp2ContactInfo getFirstRemoteContact() { - return phoneLookupInfo.getCp2RemoteInfo().getCp2ContactInfoCount() > 0 - ? phoneLookupInfo.getCp2RemoteInfo().getCp2ContactInfo(0) + private Cp2ContactInfo getFirstContactInExtendedDirectories() { + return phoneLookupInfo.getExtendedCp2Info().getCp2ContactInfoCount() > 0 + ? phoneLookupInfo.getExtendedCp2Info().getCp2ContactInfo(0) : null; } @@ -318,14 +327,14 @@ public final class PhoneLookupInfoConsolidator { private @NameSource int selectNameSource() { for (int nameSource : NAME_SOURCES_IN_PRIORITY_ORDER) { switch (nameSource) { - case NameSource.CP2_LOCAL: - if (firstCp2LocalContact != null && !firstCp2LocalContact.getName().isEmpty()) { - return NameSource.CP2_LOCAL; + case NameSource.CP2_DEFAULT_DIRECTORY: + if (firstDefaultCp2Contact != null && !firstDefaultCp2Contact.getName().isEmpty()) { + return NameSource.CP2_DEFAULT_DIRECTORY; } break; - case NameSource.CP2_REMOTE: - if (firstCp2RemoteContact != null && !firstCp2RemoteContact.getName().isEmpty()) { - return NameSource.CP2_REMOTE; + case NameSource.CP2_EXTENDED_DIRECTORY: + if (firstExtendedCp2Contact != null && !firstExtendedCp2Contact.getName().isEmpty()) { + return NameSource.CP2_EXTENDED_DIRECTORY; } break; case NameSource.PEOPLE_API: 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 { + + 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 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 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 isDirty(ImmutableSet 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 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 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 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> contactIdsFuture = + queryPhoneTableForContactIds(phoneNumbers); + ListenableFuture 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> 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> queryPhoneTableForContactIds( + ImmutableSet dialerPhoneNumbers) { + PartitionedNumbers partitionedNumbers = new PartitionedNumbers(dialerPhoneNumbers); + + List>> 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 contactIds = new ArraySet<>(); + for (Set ids : listOfSets) { + contactIds.addAll(ids); + } + return contactIds; + }, + lightweightExecutorService); + } + + /** Gets all of the contact ids from PhoneLookupHistory. */ + private ListenableFuture> queryPhoneLookupHistoryForContactIds() { + return backgroundExecutorService.submit( + () -> { + Set 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> queryPhoneTableForContactIdsBasedOnE164( + Set validE164Numbers) { + return backgroundExecutorService.submit( + () -> { + Set 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> queryPhoneLookupTableForContactIdsBasedOnRawNumber( + String rawNumber) { + if (TextUtils.isEmpty(rawNumber)) { + return Futures.immediateFuture(new ArraySet<>()); + } + return backgroundExecutorService.submit( + () -> { + Set 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 contactsUpdated(Set contactIds, long lastModified) { + return backgroundExecutorService.submit( + () -> { + try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) { + return cursor.getCount() > 0; + } + }); + } + + private Cursor queryContactsTableForContacts(Set 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 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 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> getMostRecentInfo( + ImmutableMap existingInfoMap) { + currentLastTimestampProcessed = null; + + ListenableFuture 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> deletedPhoneNumbersFuture = + getDeletedPhoneNumbers(existingInfoMap, lastModified); + + return Futures.transformAsync( + deletedPhoneNumbersFuture, + deletedPhoneNumbers -> { + + // If there are too many invalid numbers, just defer the work to render time. + ArraySet unprocessableNumbers = + findUnprocessableNumbers(existingInfoMap); + Map 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>> + 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 newInfoMapBuilder = + ImmutableMap.builder(); + + // For each DialerPhoneNumber in existing info... + for (Entry 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 findUnprocessableNumbers( + ImmutableMap existingInfoMap) { + ArraySet 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 onSuccessfulBulkUpdate() { + return backgroundExecutorService.submit( + () -> { + if (currentLastTimestampProcessed != null) { + sharedPreferences + .edit() + .putLong(PREF_LAST_TIMESTAMP_PROCESSED, currentLastTimestampProcessed) + .apply(); + } + return null; + }); + } + + private ListenableFuture> findNumbersToUpdate( + Map existingInfoMap, + long lastModified, + Set deletedPhoneNumbers) { + return backgroundExecutorService.submit( + () -> { + Set updatedNumbers = new ArraySet<>(); + Set contactIds = new ArraySet<>(); + for (Entry 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>> + buildMapForUpdatedOrAddedContacts( + Map existingInfoMap, + long lastModified, + Set deletedPhoneNumbers) { + // Start by building a set of DialerPhoneNumbers that we want to update. + ListenableFuture> 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>> validNumbersFuture = + batchQueryForValidNumbers(partitionedNumbers.validE164Numbers()); + + List>> invalidNumbersFuturesList = new ArrayList<>(); + for (String invalidNumber : partitionedNumbers.invalidNumbers()) { + invalidNumbersFuturesList.add(individualQueryForInvalidNumber(invalidNumber)); + } + + ListenableFuture>> invalidNumbersFuture = + Futures.allAsList(invalidNumbersFuturesList); + + Callable>> computeMap = + () -> { + // These get() calls are safe because we are using whenAllSucceed below. + Map> validNumbersResult = validNumbersFuture.get(); + List> invalidNumbersResult = invalidNumbersFuture.get(); + + Map> map = new ArrayMap<>(); + + // First update the map with the valid number results. + for (Entry> entry : validNumbersResult.entrySet()) { + String validNumber = entry.getKey(); + Set cp2ContactInfos = entry.getValue(); + + Set 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 cp2Infos = invalidNumbersResult.get(i++); + Set 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>> batchQueryForValidNumbers( + Set validE164Numbers) { + return backgroundExecutorService.submit( + () -> { + Map> 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 cp2ContactInfos = cp2ContactInfosByNumber.get(validE164Number); + if (cp2ContactInfos == null) { + cp2ContactInfos = new ArraySet<>(); + cp2ContactInfosByNumber.put(validE164Number, cp2ContactInfos); + } + cp2ContactInfos.add( + Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor)); + } + } + } + return cp2ContactInfosByNumber; + }); + } + + private ListenableFuture> individualQueryForInvalidNumber( + String invalidNumber) { + return backgroundExecutorService.submit( + () -> { + Set 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> map, + Set dialerPhoneNumbers, + Set cp2ContactInfos) { + for (DialerPhoneNumber dialerPhoneNumber : dialerPhoneNumbers) { + Set existingInfos = map.get(dialerPhoneNumber); + if (existingInfos == null) { + existingInfos = new ArraySet<>(); + map.put(dialerPhoneNumber, existingInfos); + } + existingInfos.addAll(cp2ContactInfos); + } + } + + private Cursor queryPhoneTableBasedOnE164(String[] projection, Set 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> getDeletedPhoneNumbers( + ImmutableMap 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 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 findContactIdsIn(ImmutableMap map) { + Set contactIds = new ArraySet<>(); + for (Cp2Info info : map.values()) { + for (Cp2ContactInfo cp2ContactInfo : info.getCp2ContactInfoList()) { + contactIds.add(cp2ContactInfo.getContactId()); + } + } + return contactIds; + } + + private Cursor queryDeletedContacts(Set 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 findDeletedPhoneNumbersIn( + ImmutableMap existingInfoMap, Cursor cursor) { + int contactIdIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_ID); + int deletedTimeIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_DELETED_TIMESTAMP); + Set 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 findDialerPhoneNumbersContainingContactId( + Map existingInfoMap, long contactId) { + Set matches = new ArraySet<>(); + for (Entry 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(); + } +} diff --git a/java/com/android/dialer/phonelookup/cp2/Cp2ExtendedDirectoryPhoneLookup.java b/java/com/android/dialer/phonelookup/cp2/Cp2ExtendedDirectoryPhoneLookup.java new file mode 100644 index 000000000..df164bd1b --- /dev/null +++ b/java/com/android/dialer/phonelookup/cp2/Cp2ExtendedDirectoryPhoneLookup.java @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2018 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.database.Cursor; +import android.net.Uri; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Directory; +import android.support.annotation.VisibleForTesting; +import com.android.dialer.DialerPhoneNumber; +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.phonenumberutil.PhoneNumberHelper; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +/** + * PhoneLookup implementation for contacts in both local and remote directories other than the + * default directory. + * + *

Contacts in these directories are accessible only by specifying a directory ID. + */ +public final class Cp2ExtendedDirectoryPhoneLookup implements PhoneLookup { + + private final Context appContext; + private final ListeningExecutorService backgroundExecutorService; + private final ListeningExecutorService lightweightExecutorService; + + @Inject + Cp2ExtendedDirectoryPhoneLookup( + @ApplicationContext Context appContext, + @BackgroundExecutor ListeningExecutorService backgroundExecutorService, + @LightweightExecutor ListeningExecutorService lightweightExecutorService) { + this.appContext = appContext; + this.backgroundExecutorService = backgroundExecutorService; + this.lightweightExecutorService = lightweightExecutorService; + } + + @Override + public ListenableFuture lookup(DialerPhoneNumber dialerPhoneNumber) { + return Futures.transformAsync( + queryCp2ForExtendedDirectoryIds(), + directoryIds -> queryCp2ForDirectoryContact(dialerPhoneNumber, directoryIds), + lightweightExecutorService); + } + + private ListenableFuture> queryCp2ForExtendedDirectoryIds() { + return backgroundExecutorService.submit( + () -> { + List directoryIds = new ArrayList<>(); + try (Cursor cursor = + appContext + .getContentResolver() + .query( + getContentUriForDirectoryIds(), + /* projection = */ new String[] {ContactsContract.Directory._ID}, + /* selection = */ null, + /* selectionArgs = */ null, + /* sortOrder = */ ContactsContract.Directory._ID)) { + if (cursor == null) { + LogUtil.e( + "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForExtendedDirectoryIds", "null cursor"); + return directoryIds; + } + + if (!cursor.moveToFirst()) { + LogUtil.i( + "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForExtendedDirectoryIds", + "empty cursor"); + return directoryIds; + } + + int idColumnIndex = cursor.getColumnIndexOrThrow(ContactsContract.Directory._ID); + do { + long directoryId = cursor.getLong(idColumnIndex); + + if (isExtendedDirectory(directoryId)) { + directoryIds.add(cursor.getLong(idColumnIndex)); + } + } while (cursor.moveToNext()); + return directoryIds; + } + }); + } + + private ListenableFuture queryCp2ForDirectoryContact( + DialerPhoneNumber dialerPhoneNumber, List directoryIds) { + if (directoryIds.isEmpty()) { + return Futures.immediateFuture(Cp2Info.getDefaultInstance()); + } + + // Note: This loses country info when number is not valid. + String number = dialerPhoneNumber.getNormalizedNumber(); + + List> cp2InfoFutures = new ArrayList<>(); + for (long directoryId : directoryIds) { + cp2InfoFutures.add(queryCp2ForDirectoryContact(number, directoryId)); + } + + return Futures.transform( + Futures.allAsList(cp2InfoFutures), + cp2InfoList -> { + Cp2Info.Builder cp2InfoBuilder = Cp2Info.newBuilder(); + for (Cp2Info cp2Info : cp2InfoList) { + cp2InfoBuilder.addAllCp2ContactInfo(cp2Info.getCp2ContactInfoList()); + } + return cp2InfoBuilder.build(); + }, + lightweightExecutorService); + } + + private ListenableFuture queryCp2ForDirectoryContact(String number, long directoryId) { + return backgroundExecutorService.submit( + () -> { + Cp2Info.Builder cp2InfoBuilder = Cp2Info.newBuilder(); + try (Cursor cursor = + appContext + .getContentResolver() + .query( + getContentUriForContacts(number, directoryId), + Cp2Projections.getProjectionForPhoneLookupTable(), + /* selection = */ null, + /* selectionArgs = */ null, + /* sortOrder = */ null)) { + if (cursor == null) { + LogUtil.e( + "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForDirectoryContact", + "null cursor returned when querying directory %d", + directoryId); + return cp2InfoBuilder.build(); + } + + if (!cursor.moveToFirst()) { + LogUtil.i( + "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForDirectoryContact", + "empty cursor returned when querying directory %d", + directoryId); + return cp2InfoBuilder.build(); + } + + do { + cp2InfoBuilder.addCp2ContactInfo( + Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor)); + } while (cursor.moveToNext()); + } + + return cp2InfoBuilder.build(); + }); + } + + @VisibleForTesting + static Uri getContentUriForDirectoryIds() { + return VERSION.SDK_INT >= VERSION_CODES.N + ? ContactsContract.Directory.ENTERPRISE_CONTENT_URI + : ContactsContract.Directory.CONTENT_URI; + } + + @VisibleForTesting + static Uri getContentUriForContacts(String number, long directoryId) { + Uri baseUri = + VERSION.SDK_INT >= VERSION_CODES.N + ? ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI + : ContactsContract.PhoneLookup.CONTENT_FILTER_URI; + + Uri.Builder builder = + baseUri + .buildUpon() + .appendPath(number) + .appendQueryParameter( + ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, + String.valueOf(PhoneNumberHelper.isUriNumber(number))) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); + + return builder.build(); + } + + private static boolean isExtendedDirectory(long directoryId) { + // TODO(a bug): Moving the logic to utility shared with the search fragment. + return VERSION.SDK_INT >= VERSION_CODES.N + ? Directory.isRemoteDirectoryId(directoryId) + || Directory.isEnterpriseDirectoryId(directoryId) + : (directoryId != Directory.DEFAULT + && directoryId != Directory.LOCAL_INVISIBLE + && directoryId != Directory.ENTERPRISE_LOCAL_INVISIBLE); + } + + @Override + public ListenableFuture isDirty(ImmutableSet phoneNumbers) { + return Futures.immediateFuture(false); + } + + @Override + public ListenableFuture> getMostRecentInfo( + ImmutableMap existingInfoMap) { + return Futures.immediateFuture(existingInfoMap); + } + + @Override + public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) { + destination.setExtendedCp2Info(subMessage); + } + + @Override + public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) { + return phoneLookupInfo.getExtendedCp2Info(); + } + + @Override + public ListenableFuture onSuccessfulBulkUpdate() { + return Futures.immediateFuture(null); + } + + @Override + public void registerContentObservers(Context appContext) { + // For contacts in remote directories, no content observer can be registered. + // For contacts in local (but not default) directories (e.g., the local work directory), we + // don't register a content observer for now. + } +} diff --git a/java/com/android/dialer/phonelookup/cp2/Cp2LocalPhoneLookup.java b/java/com/android/dialer/phonelookup/cp2/Cp2LocalPhoneLookup.java deleted file mode 100644 index 8db308892..000000000 --- a/java/com/android/dialer/phonelookup/cp2/Cp2LocalPhoneLookup.java +++ /dev/null @@ -1,914 +0,0 @@ -/* - * 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 local contacts. */ -public final class Cp2LocalPhoneLookup implements PhoneLookup { - - private static final String PREF_LAST_TIMESTAMP_PROCESSED = - "cp2LocalPhoneLookupLastTimestampProcessed"; - - // 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 - Cp2LocalPhoneLookup( - @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 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 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("Cp2LocalPhoneLookup.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 isDirty(ImmutableSet 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( - "Cp2LocalPhoneLookup.isDirty", - "returning true because too many invalid numbers (%d)", - partitionedNumbers.invalidNumbers().size()); - return Futures.immediateFuture(true); - } - - ListenableFuture 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 anyContactsDeletedFuture = - anyContactsDeletedSince(lastModified); - return Futures.transformAsync( - anyContactsDeletedFuture, - anyContactsDeleted -> { - if (anyContactsDeleted) { - LogUtil.v( - "Cp2LocalPhoneLookup.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 noContactsModifiedSinceFuture = - noContactsModifiedSince(lastModified); - return Futures.transformAsync( - noContactsModifiedSinceFuture, - noContactsModifiedSince -> { - if (noContactsModifiedSince) { - LogUtil.v( - "Cp2LocalPhoneLookup.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> contactIdsFuture = - queryPhoneTableForContactIds(phoneNumbers); - ListenableFuture contactsUpdatedFuture = - Futures.transformAsync( - contactIdsFuture, - contactIds -> contactsUpdated(contactIds, lastModified), - MoreExecutors.directExecutor()); - return Futures.transformAsync( - contactsUpdatedFuture, - contactsUpdated -> { - if (contactsUpdated) { - LogUtil.v( - "Cp2LocalPhoneLookup.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> 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> queryPhoneTableForContactIds( - ImmutableSet dialerPhoneNumbers) { - PartitionedNumbers partitionedNumbers = new PartitionedNumbers(dialerPhoneNumbers); - - List>> 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 contactIds = new ArraySet<>(); - for (Set ids : listOfSets) { - contactIds.addAll(ids); - } - return contactIds; - }, - lightweightExecutorService); - } - - /** Gets all of the contact ids from PhoneLookupHistory. */ - private ListenableFuture> queryPhoneLookupHistoryForContactIds() { - return backgroundExecutorService.submit( - () -> { - Set 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("Cp2LocalPhoneLookup.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.getCp2LocalInfo().getCp2ContactInfoList()) { - contactIds.add(info.getContactId()); - } - } while (cursor.moveToNext()); - } - } - return contactIds; - }); - } - - private ListenableFuture> queryPhoneTableForContactIdsBasedOnE164( - Set validE164Numbers) { - return backgroundExecutorService.submit( - () -> { - Set contactIds = new ArraySet<>(); - if (validE164Numbers.isEmpty()) { - return contactIds; - } - try (Cursor cursor = - queryPhoneTableBasedOnE164(new String[] {Phone.CONTACT_ID}, validE164Numbers)) { - if (cursor == null) { - LogUtil.w( - "Cp2LocalPhoneLookup.queryPhoneTableForContactIdsBasedOnE164", "null cursor"); - return contactIds; - } - while (cursor.moveToNext()) { - contactIds.add(cursor.getLong(0 /* columnIndex */)); - } - } - return contactIds; - }); - } - - private ListenableFuture> queryPhoneLookupTableForContactIdsBasedOnRawNumber( - String rawNumber) { - if (TextUtils.isEmpty(rawNumber)) { - return Futures.immediateFuture(new ArraySet<>()); - } - return backgroundExecutorService.submit( - () -> { - Set contactIds = new ArraySet<>(); - try (Cursor cursor = - queryPhoneLookup(new String[] {ContactsContract.PhoneLookup.CONTACT_ID}, rawNumber)) { - if (cursor == null) { - LogUtil.w( - "Cp2LocalPhoneLookup.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 contactsUpdated(Set contactIds, long lastModified) { - return backgroundExecutorService.submit( - () -> { - try (Cursor cursor = queryContactsTableForContacts(contactIds, lastModified)) { - return cursor.getCount() > 0; - } - }); - } - - private Cursor queryContactsTableForContacts(Set 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 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("Cp2LocalPhoneLookup.noContactsModifiedSince", "null cursor"); - return false; - } - return cursor.getCount() == 0; - } - }); - } - - /** Returns true if any contacts were deleted after {@code lastModified}. */ - private ListenableFuture 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("Cp2LocalPhoneLookup.anyContactsDeletedSince", "null cursor"); - return false; - } - return cursor.getCount() > 0; - } - }); - } - - @Override - public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) { - destination.setCp2LocalInfo(subMessage); - } - - @Override - public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) { - return phoneLookupInfo.getCp2LocalInfo(); - } - - @Override - public ListenableFuture> getMostRecentInfo( - ImmutableMap existingInfoMap) { - currentLastTimestampProcessed = null; - - ListenableFuture 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> deletedPhoneNumbersFuture = - getDeletedPhoneNumbers(existingInfoMap, lastModified); - - return Futures.transformAsync( - deletedPhoneNumbersFuture, - deletedPhoneNumbers -> { - - // If there are too many invalid numbers, just defer the work to render time. - ArraySet unprocessableNumbers = - findUnprocessableNumbers(existingInfoMap); - Map 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>> - 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 newInfoMapBuilder = - ImmutableMap.builder(); - - // For each DialerPhoneNumber in existing info... - for (Entry 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 findUnprocessableNumbers( - ImmutableMap existingInfoMap) { - ArraySet 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 onSuccessfulBulkUpdate() { - return backgroundExecutorService.submit( - () -> { - if (currentLastTimestampProcessed != null) { - sharedPreferences - .edit() - .putLong(PREF_LAST_TIMESTAMP_PROCESSED, currentLastTimestampProcessed) - .apply(); - } - return null; - }); - } - - private ListenableFuture> findNumbersToUpdate( - Map existingInfoMap, - long lastModified, - Set deletedPhoneNumbers) { - return backgroundExecutorService.submit( - () -> { - Set updatedNumbers = new ArraySet<>(); - Set contactIds = new ArraySet<>(); - for (Entry 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>> - buildMapForUpdatedOrAddedContacts( - Map existingInfoMap, - long lastModified, - Set deletedPhoneNumbers) { - // Start by building a set of DialerPhoneNumbers that we want to update. - ListenableFuture> 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>> validNumbersFuture = - batchQueryForValidNumbers(partitionedNumbers.validE164Numbers()); - - List>> invalidNumbersFuturesList = new ArrayList<>(); - for (String invalidNumber : partitionedNumbers.invalidNumbers()) { - invalidNumbersFuturesList.add(individualQueryForInvalidNumber(invalidNumber)); - } - - ListenableFuture>> invalidNumbersFuture = - Futures.allAsList(invalidNumbersFuturesList); - - Callable>> computeMap = - () -> { - // These get() calls are safe because we are using whenAllSucceed below. - Map> validNumbersResult = validNumbersFuture.get(); - List> invalidNumbersResult = invalidNumbersFuture.get(); - - Map> map = new ArrayMap<>(); - - // First update the map with the valid number results. - for (Entry> entry : validNumbersResult.entrySet()) { - String validNumber = entry.getKey(); - Set cp2ContactInfos = entry.getValue(); - - Set 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 cp2Infos = invalidNumbersResult.get(i++); - Set 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( - "Cp2LocalPhoneLookup.buildMapForUpdatedOrAddedContacts", - "found %d numbers that may need updating", - updatedNumbers.size()); - return map; - }; - return Futures.whenAllSucceed(validNumbersFuture, invalidNumbersFuture) - .call(computeMap, lightweightExecutorService); - }, - lightweightExecutorService); - } - - private ListenableFuture>> batchQueryForValidNumbers( - Set validE164Numbers) { - return backgroundExecutorService.submit( - () -> { - Map> cp2ContactInfosByNumber = new ArrayMap<>(); - if (validE164Numbers.isEmpty()) { - return cp2ContactInfosByNumber; - } - try (Cursor cursor = - queryPhoneTableBasedOnE164( - Cp2Projections.getProjectionForPhoneTable(), validE164Numbers)) { - if (cursor == null) { - LogUtil.w("Cp2LocalPhoneLookup.batchQueryForValidNumbers", "null cursor"); - } else { - while (cursor.moveToNext()) { - String validE164Number = Cp2Projections.getNormalizedNumberFromCursor(cursor); - Set cp2ContactInfos = cp2ContactInfosByNumber.get(validE164Number); - if (cp2ContactInfos == null) { - cp2ContactInfos = new ArraySet<>(); - cp2ContactInfosByNumber.put(validE164Number, cp2ContactInfos); - } - cp2ContactInfos.add( - Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor)); - } - } - } - return cp2ContactInfosByNumber; - }); - } - - private ListenableFuture> individualQueryForInvalidNumber( - String invalidNumber) { - return backgroundExecutorService.submit( - () -> { - Set cp2ContactInfos = new ArraySet<>(); - if (invalidNumber.isEmpty()) { - return cp2ContactInfos; - } - try (Cursor cursor = - queryPhoneLookup(Cp2Projections.getProjectionForPhoneLookupTable(), invalidNumber)) { - if (cursor == null) { - LogUtil.w("Cp2LocalPhoneLookup.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> map, - Set dialerPhoneNumbers, - Set cp2ContactInfos) { - for (DialerPhoneNumber dialerPhoneNumber : dialerPhoneNumbers) { - Set existingInfos = map.get(dialerPhoneNumber); - if (existingInfos == null) { - existingInfos = new ArraySet<>(); - map.put(dialerPhoneNumber, existingInfos); - } - existingInfos.addAll(cp2ContactInfos); - } - } - - private Cursor queryPhoneTableBasedOnE164(String[] projection, Set 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> getDeletedPhoneNumbers( - ImmutableMap 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 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 findContactIdsIn(ImmutableMap map) { - Set contactIds = new ArraySet<>(); - for (Cp2Info info : map.values()) { - for (Cp2ContactInfo cp2ContactInfo : info.getCp2ContactInfoList()) { - contactIds.add(cp2ContactInfo.getContactId()); - } - } - return contactIds; - } - - private Cursor queryDeletedContacts(Set 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 findDeletedPhoneNumbersIn( - ImmutableMap existingInfoMap, Cursor cursor) { - int contactIdIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_ID); - int deletedTimeIndex = cursor.getColumnIndexOrThrow(DeletedContacts.CONTACT_DELETED_TIMESTAMP); - Set 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 findDialerPhoneNumbersContainingContactId( - Map existingInfoMap, long contactId) { - Set matches = new ArraySet<>(); - for (Entry 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(); - } -} diff --git a/java/com/android/dialer/phonelookup/cp2/Cp2RemotePhoneLookup.java b/java/com/android/dialer/phonelookup/cp2/Cp2RemotePhoneLookup.java deleted file mode 100644 index 7efe039eb..000000000 --- a/java/com/android/dialer/phonelookup/cp2/Cp2RemotePhoneLookup.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright (C) 2018 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.database.Cursor; -import android.net.Uri; -import android.os.Build.VERSION; -import android.os.Build.VERSION_CODES; -import android.provider.ContactsContract; -import android.provider.ContactsContract.Directory; -import android.support.annotation.VisibleForTesting; -import com.android.dialer.DialerPhoneNumber; -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.phonenumberutil.PhoneNumberHelper; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import java.util.ArrayList; -import java.util.List; -import javax.inject.Inject; - -/** PhoneLookup implementation for remote contacts. */ -public final class Cp2RemotePhoneLookup implements PhoneLookup { - - private final Context appContext; - private final ListeningExecutorService backgroundExecutorService; - private final ListeningExecutorService lightweightExecutorService; - - @Inject - Cp2RemotePhoneLookup( - @ApplicationContext Context appContext, - @BackgroundExecutor ListeningExecutorService backgroundExecutorService, - @LightweightExecutor ListeningExecutorService lightweightExecutorService) { - this.appContext = appContext; - this.backgroundExecutorService = backgroundExecutorService; - this.lightweightExecutorService = lightweightExecutorService; - } - - @Override - public ListenableFuture lookup(DialerPhoneNumber dialerPhoneNumber) { - return Futures.transformAsync( - queryCp2ForRemoteDirectoryIds(), - remoteDirectoryIds -> queryCp2ForRemoteContact(dialerPhoneNumber, remoteDirectoryIds), - lightweightExecutorService); - } - - private ListenableFuture> queryCp2ForRemoteDirectoryIds() { - return backgroundExecutorService.submit( - () -> { - List remoteDirectoryIds = new ArrayList<>(); - try (Cursor cursor = - appContext - .getContentResolver() - .query( - getContentUriForDirectoryIds(), - /* projection = */ new String[] {ContactsContract.Directory._ID}, - /* selection = */ null, - /* selectionArgs = */ null, - /* sortOrder = */ ContactsContract.Directory._ID)) { - if (cursor == null) { - LogUtil.e("Cp2RemotePhoneLookup.queryCp2ForDirectoryIds", "null cursor"); - return remoteDirectoryIds; - } - - if (!cursor.moveToFirst()) { - LogUtil.i("Cp2RemotePhoneLookup.queryCp2ForDirectoryIds", "empty cursor"); - return remoteDirectoryIds; - } - - int idColumnIndex = cursor.getColumnIndexOrThrow(ContactsContract.Directory._ID); - do { - long directoryId = cursor.getLong(idColumnIndex); - - // Note that IDs of non-remote directories will be included in the result, such as - // android.provider.ContactsContract.Directory.DEFAULT (the default directory that - // represents locally stored contacts). - if (isRemoteDirectory(directoryId)) { - remoteDirectoryIds.add(cursor.getLong(idColumnIndex)); - } - } while (cursor.moveToNext()); - return remoteDirectoryIds; - } - }); - } - - private ListenableFuture queryCp2ForRemoteContact( - DialerPhoneNumber dialerPhoneNumber, List remoteDirectoryIds) { - if (remoteDirectoryIds.isEmpty()) { - return Futures.immediateFuture(Cp2Info.getDefaultInstance()); - } - - // Note: This loses country info when number is not valid. - String number = dialerPhoneNumber.getNormalizedNumber(); - - List> cp2InfoFutures = new ArrayList<>(); - for (long remoteDirectoryId : remoteDirectoryIds) { - cp2InfoFutures.add(queryCp2ForRemoteContact(number, remoteDirectoryId)); - } - - return Futures.transform( - Futures.allAsList(cp2InfoFutures), - cp2InfoList -> { - Cp2Info.Builder cp2InfoBuilder = Cp2Info.newBuilder(); - for (Cp2Info cp2Info : cp2InfoList) { - cp2InfoBuilder.addAllCp2ContactInfo(cp2Info.getCp2ContactInfoList()); - } - return cp2InfoBuilder.build(); - }, - lightweightExecutorService); - } - - private ListenableFuture queryCp2ForRemoteContact( - String number, long remoteDirectoryId) { - return backgroundExecutorService.submit( - () -> { - Cp2Info.Builder cp2InfoBuilder = Cp2Info.newBuilder(); - try (Cursor cursor = - appContext - .getContentResolver() - .query( - getContentUriForContacts(number, remoteDirectoryId), - Cp2Projections.getProjectionForPhoneLookupTable(), - /* selection = */ null, - /* selectionArgs = */ null, - /* sortOrder = */ null)) { - if (cursor == null) { - LogUtil.e( - "Cp2RemotePhoneLookup.queryCp2ForRemoteContact", - "null cursor returned when querying directory %d", - remoteDirectoryId); - return cp2InfoBuilder.build(); - } - - if (!cursor.moveToFirst()) { - LogUtil.i( - "Cp2RemotePhoneLookup.queryCp2ForRemoteContact", - "empty cursor returned when querying directory %d", - remoteDirectoryId); - return cp2InfoBuilder.build(); - } - - do { - cp2InfoBuilder.addCp2ContactInfo( - Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor)); - } while (cursor.moveToNext()); - } - - return cp2InfoBuilder.build(); - }); - } - - @VisibleForTesting - static Uri getContentUriForDirectoryIds() { - return VERSION.SDK_INT >= VERSION_CODES.N - ? ContactsContract.Directory.ENTERPRISE_CONTENT_URI - : ContactsContract.Directory.CONTENT_URI; - } - - @VisibleForTesting - static Uri getContentUriForContacts(String number, long directoryId) { - Uri baseUri = - VERSION.SDK_INT >= VERSION_CODES.N - ? ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI - : ContactsContract.PhoneLookup.CONTENT_FILTER_URI; - - Uri.Builder builder = - baseUri - .buildUpon() - .appendPath(number) - .appendQueryParameter( - ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, - String.valueOf(PhoneNumberHelper.isUriNumber(number))) - .appendQueryParameter( - ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); - - return builder.build(); - } - - private static boolean isRemoteDirectory(long directoryId) { - return VERSION.SDK_INT >= VERSION_CODES.N - ? Directory.isRemoteDirectoryId(directoryId) - : (directoryId != Directory.DEFAULT - && directoryId != Directory.LOCAL_INVISIBLE - // Directory.ENTERPRISE_DEFAULT is the default work profile directory for locally stored - // contacts - && directoryId != Directory.ENTERPRISE_DEFAULT - && directoryId != Directory.ENTERPRISE_LOCAL_INVISIBLE); - } - - @Override - public ListenableFuture isDirty(ImmutableSet phoneNumbers) { - return Futures.immediateFuture(false); - } - - @Override - public ListenableFuture> getMostRecentInfo( - ImmutableMap existingInfoMap) { - return Futures.immediateFuture(existingInfoMap); - } - - @Override - public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) { - destination.setCp2RemoteInfo(subMessage); - } - - @Override - public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) { - return phoneLookupInfo.getCp2RemoteInfo(); - } - - @Override - public ListenableFuture onSuccessfulBulkUpdate() { - return Futures.immediateFuture(null); - } - - @Override - public void registerContentObservers(Context appContext) { - // No content observer needed for remote contacts - } -} diff --git a/java/com/android/dialer/phonelookup/phone_lookup_info.proto b/java/com/android/dialer/phonelookup/phone_lookup_info.proto index dd6bf664c..44c237bd1 100644 --- a/java/com/android/dialer/phonelookup/phone_lookup_info.proto +++ b/java/com/android/dialer/phonelookup/phone_lookup_info.proto @@ -10,59 +10,58 @@ package com.android.dialer.phonelookup; // Contains information about a phone number, possibly from many sources. // // This message is organized into sub-message fields where each one corresponds -// to an implementation of PhoneLookup. For example, field "cp2_local_info" -// corresponds to class Cp2LocalPhoneLookup, and class Cp2LocalPhoneLookup -// alone is responsible for populating it. -// Next ID: 7 +// to an implementation of PhoneLookup. For example, field +// "cp2_info_in_default_directory" corresponds to class +// Cp2DefaultDirectoryPhoneLookup, and class Cp2DefaultDirectoryPhoneLookup +// alone is responsible for populating it. Next ID: 7 message PhoneLookupInfo { // Information about a PhoneNumber retrieved from CP2. message Cp2Info { - // Information about a single contact, which can be a local contact or a - // remote one. + // Information about a single contact. // Next ID: 8 message Cp2ContactInfo { - // For a local contact: + // For a contact in the default directory: // android.provider.ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY - // For a remote contact: + // For a contact in other directories: // android.provider.ContactsContract.PhoneLookup.DISPLAY_NAME_PRIMARY optional string name = 1; - // For a local contact: + // For a contact in the default directory: // android.provider.ContactsContract.CommonDataKinds.Phone.PHOTO_THUMBNAIL_URI - // For a remote contact: + // For a contact in other directories: // android.provider.ContactsContract.PhoneLookup.PHOTO_THUMBNAIL_URI optional string photo_thumbnail_uri = 2; - // For a local contact: + // For a contact in the default directory: // android.provider.ContactsContract.CommonDataKinds.Phone.PHOTO_URI - // For a remote contact: + // For a contact in other directories: // android.provider.ContactsContract.PhoneLookup.PHOTO_URI optional string photo_uri = 3; - // For a local contact: + // For a contact in the default directory: // android.provider.ContactsContract.CommonDataKinds.Phone.PHOTO_ID - // For a remote contact: + // For a contact in other directories: // android.provider.ContactsContract.PhoneLookup.PHOTO_ID optional fixed64 photo_id = 4; - // For a local contact: + // For a contact in the default directory: // android.provider.ContactsContract.CommonDataKinds.Phone.LABEL - // For a remote contact: + // For a contact in other directories: // android.provider.ContactsContract.PhoneLookup.LABEL // // The value can be "Home", "Mobile", ect. optional string label = 5; - // For a local contact: + // For a contact in the default directory: // android.provider.ContactsContract.CommonDataKinds.Phone.CONTACT_ID - // For a remote contact: + // For a contact in other directories: // android.provider.ContactsContract.PhoneLookup.CONTACT_ID optional fixed64 contact_id = 6; - // For a local contact: + // For a contact in the default directory: // constructed based on // android.provider.ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY - // For a remote contact: + // For a contact in other directories: // constructed based on // android.provider.ContactsContract.PhoneLookup.LOOKUP_KEY optional string lookup_uri = 7; @@ -80,13 +79,13 @@ message PhoneLookupInfo { optional bool is_incomplete = 2; } - // Information about a local contact retrieved via CP2. - // Cp2LocalPhoneLookup is responsible for populating this field. - optional Cp2Info cp2_local_info = 1; + // Information about a contact in the default directory, retrieved via CP2. + // Cp2DefaultDirectoryPhoneLookup is responsible for populating this field. + optional Cp2Info default_cp2_info = 1; - // Information about a remote contact retrieved via CP2. - // Cp2RemotePhoneLookup is responsible for populating this field. - optional Cp2Info cp2_remote_info = 6; + // Information about a contact in other directories, retrieved via CP2. + // Cp2ExtendedDirectoryPhoneLookup is responsible for populating this field. + optional Cp2Info extended_cp2_info = 6; // Message for spam info. // SpamPhoneLookup is responsible for populating this message. -- cgit v1.2.3