/* * 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.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.common.concurrent.Annotations.NonUiSerial; import com.android.dialer.configprovider.ConfigProvider; import com.android.dialer.inject.ApplicationContext; import com.android.dialer.logging.DialerImpression; import com.android.dialer.logging.Logger; 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.android.dialer.util.PermissionsUtil; 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 java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Predicate; 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. */ @SuppressWarnings("AndroidApiChecker") // Use of Java 8 APIs. public final class Cp2ExtendedDirectoryPhoneLookup implements PhoneLookup { /** Config flag for timeout (in ms). */ @VisibleForTesting static final String CP2_EXTENDED_DIRECTORY_PHONE_LOOKUP_TIMEOUT_MILLIS = "cp2_extended_directory_phone_lookup_timout_millis"; private final Context appContext; private final ConfigProvider configProvider; private final ListeningExecutorService backgroundExecutorService; private final ListeningExecutorService lightweightExecutorService; private final MissingPermissionsOperations missingPermissionsOperations; private final ScheduledExecutorService scheduledExecutorService; @Inject Cp2ExtendedDirectoryPhoneLookup( @ApplicationContext Context appContext, @BackgroundExecutor ListeningExecutorService backgroundExecutorService, @LightweightExecutor ListeningExecutorService lightweightExecutorService, @NonUiSerial ScheduledExecutorService scheduledExecutorService, ConfigProvider configProvider, MissingPermissionsOperations missingPermissionsOperations) { this.appContext = appContext; this.backgroundExecutorService = backgroundExecutorService; this.lightweightExecutorService = lightweightExecutorService; this.scheduledExecutorService = scheduledExecutorService; this.configProvider = configProvider; this.missingPermissionsOperations = missingPermissionsOperations; } @Override public ListenableFuture lookup(DialerPhoneNumber dialerPhoneNumber) { if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { return Futures.immediateFuture(Cp2Info.getDefaultInstance()); } ListenableFuture cp2InfoFuture = Futures.transformAsync( queryCp2ForExtendedDirectoryIds(), directoryIds -> queryCp2ForDirectoryContact(dialerPhoneNumber, directoryIds), lightweightExecutorService); long timeoutMillis = configProvider.getLong(CP2_EXTENDED_DIRECTORY_PHONE_LOOKUP_TIMEOUT_MILLIS, Long.MAX_VALUE); // Do not pass Long.MAX_VALUE to Futures.withTimeout as it will cause the internal // ScheduledExecutorService for timing to keep waiting even after "cp2InfoFuture" is done. // Do not pass 0 or a negative value to Futures.withTimeout either as it will cause the timeout // event to be triggered immediately. return timeoutMillis == Long.MAX_VALUE ? cp2InfoFuture : Futures.catching( Futures.withTimeout( cp2InfoFuture, timeoutMillis, TimeUnit.MILLISECONDS, scheduledExecutorService), TimeoutException.class, unused -> { LogUtil.w("Cp2ExtendedDirectoryPhoneLookup.lookup", "Time out!"); Logger.get(appContext) .logImpression(DialerImpression.Type.CP2_EXTENDED_DIRECTORY_PHONE_LOOKUP_TIMEOUT); return Cp2Info.getDefaultInstance(); }, lightweightExecutorService); } private ListenableFuture> queryCp2ForExtendedDirectoryIds() { return backgroundExecutorService.submit( () -> { List directoryIds = new ArrayList<>(); try (Cursor cursor = appContext .getContentResolver() .query( Directory.ENTERPRISE_CONTENT_URI, /* 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, directoryId)); } while (cursor.moveToNext()); } return cp2InfoBuilder.build(); }); } @VisibleForTesting static Uri getContentUriForContacts(String number, long directoryId) { Uri.Builder builder = ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI .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) { return Directory.isRemoteDirectoryId(directoryId) || Directory.isEnterpriseDirectoryId(directoryId); } @Override public ListenableFuture isDirty(ImmutableSet phoneNumbers) { if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { Predicate phoneLookupInfoIsDirtyFn = phoneLookupInfo -> !phoneLookupInfo.getExtendedCp2Info().equals(Cp2Info.getDefaultInstance()); return missingPermissionsOperations.isDirtyForMissingPermissions( phoneNumbers, phoneLookupInfoIsDirtyFn); } return Futures.immediateFuture(false); } @Override public ListenableFuture> getMostRecentInfo( ImmutableMap existingInfoMap) { if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { LogUtil.w("Cp2ExtendedDirectoryPhoneLookup.getMostRecentInfo", "missing permissions"); return missingPermissionsOperations.getMostRecentInfoForMissingPermissions(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() { // 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. } @Override public void unregisterContentObservers() {} @Override public ListenableFuture clearData() { return Futures.immediateFuture(null); } @Override public String getLoggingName() { return "Cp2ExtendedDirectoryPhoneLookup"; } }