1 /* 2 * Copyright (C) 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.dialer.phonelookup.cp2; 18 19 import android.content.Context; 20 import android.database.Cursor; 21 import android.net.Uri; 22 import android.provider.ContactsContract; 23 import android.provider.ContactsContract.Directory; 24 import android.support.annotation.VisibleForTesting; 25 import com.android.dialer.DialerPhoneNumber; 26 import com.android.dialer.common.LogUtil; 27 import com.android.dialer.common.concurrent.Annotations.BackgroundExecutor; 28 import com.android.dialer.common.concurrent.Annotations.LightweightExecutor; 29 import com.android.dialer.common.concurrent.Annotations.NonUiSerial; 30 import com.android.dialer.configprovider.ConfigProvider; 31 import com.android.dialer.inject.ApplicationContext; 32 import com.android.dialer.logging.DialerImpression; 33 import com.android.dialer.logging.Logger; 34 import com.android.dialer.phonelookup.PhoneLookup; 35 import com.android.dialer.phonelookup.PhoneLookupInfo; 36 import com.android.dialer.phonelookup.PhoneLookupInfo.Cp2Info; 37 import com.android.dialer.phonenumberutil.PhoneNumberHelper; 38 import com.android.dialer.util.PermissionsUtil; 39 import com.google.common.collect.ImmutableMap; 40 import com.google.common.collect.ImmutableSet; 41 import com.google.common.util.concurrent.Futures; 42 import com.google.common.util.concurrent.ListenableFuture; 43 import com.google.common.util.concurrent.ListeningExecutorService; 44 import java.util.ArrayList; 45 import java.util.List; 46 import java.util.concurrent.ScheduledExecutorService; 47 import java.util.concurrent.TimeUnit; 48 import java.util.concurrent.TimeoutException; 49 import java.util.function.Predicate; 50 import javax.inject.Inject; 51 52 /** 53 * PhoneLookup implementation for contacts in both local and remote directories other than the 54 * default directory. 55 * 56 * <p>Contacts in these directories are accessible only by specifying a directory ID. 57 */ 58 public final class Cp2ExtendedDirectoryPhoneLookup implements PhoneLookup<Cp2Info> { 59 60 /** Config flag for timeout (in ms). */ 61 @VisibleForTesting 62 static final String CP2_EXTENDED_DIRECTORY_PHONE_LOOKUP_TIMEOUT_MILLIS = 63 "cp2_extended_directory_phone_lookup_timout_millis"; 64 65 private final Context appContext; 66 private final ConfigProvider configProvider; 67 private final ListeningExecutorService backgroundExecutorService; 68 private final ListeningExecutorService lightweightExecutorService; 69 private final MissingPermissionsOperations missingPermissionsOperations; 70 private final ScheduledExecutorService scheduledExecutorService; 71 72 @Inject Cp2ExtendedDirectoryPhoneLookup( @pplicationContext Context appContext, @BackgroundExecutor ListeningExecutorService backgroundExecutorService, @LightweightExecutor ListeningExecutorService lightweightExecutorService, @NonUiSerial ScheduledExecutorService scheduledExecutorService, ConfigProvider configProvider, MissingPermissionsOperations missingPermissionsOperations)73 Cp2ExtendedDirectoryPhoneLookup( 74 @ApplicationContext Context appContext, 75 @BackgroundExecutor ListeningExecutorService backgroundExecutorService, 76 @LightweightExecutor ListeningExecutorService lightweightExecutorService, 77 @NonUiSerial ScheduledExecutorService scheduledExecutorService, 78 ConfigProvider configProvider, 79 MissingPermissionsOperations missingPermissionsOperations) { 80 this.appContext = appContext; 81 this.backgroundExecutorService = backgroundExecutorService; 82 this.lightweightExecutorService = lightweightExecutorService; 83 this.scheduledExecutorService = scheduledExecutorService; 84 this.configProvider = configProvider; 85 this.missingPermissionsOperations = missingPermissionsOperations; 86 } 87 88 @Override lookup(DialerPhoneNumber dialerPhoneNumber)89 public ListenableFuture<Cp2Info> lookup(DialerPhoneNumber dialerPhoneNumber) { 90 if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { 91 return Futures.immediateFuture(Cp2Info.getDefaultInstance()); 92 } 93 94 ListenableFuture<Cp2Info> cp2InfoFuture = 95 Futures.transformAsync( 96 queryCp2ForExtendedDirectoryIds(), 97 directoryIds -> queryCp2ForDirectoryContact(dialerPhoneNumber, directoryIds), 98 lightweightExecutorService); 99 100 long timeoutMillis = 101 configProvider.getLong(CP2_EXTENDED_DIRECTORY_PHONE_LOOKUP_TIMEOUT_MILLIS, Long.MAX_VALUE); 102 103 // Do not pass Long.MAX_VALUE to Futures.withTimeout as it will cause the internal 104 // ScheduledExecutorService for timing to keep waiting even after "cp2InfoFuture" is done. 105 // Do not pass 0 or a negative value to Futures.withTimeout either as it will cause the timeout 106 // event to be triggered immediately. 107 return timeoutMillis == Long.MAX_VALUE 108 ? cp2InfoFuture 109 : Futures.catching( 110 Futures.withTimeout( 111 cp2InfoFuture, timeoutMillis, TimeUnit.MILLISECONDS, scheduledExecutorService), 112 TimeoutException.class, 113 unused -> { 114 LogUtil.w("Cp2ExtendedDirectoryPhoneLookup.lookup", "Time out!"); 115 Logger.get(appContext) 116 .logImpression(DialerImpression.Type.CP2_EXTENDED_DIRECTORY_PHONE_LOOKUP_TIMEOUT); 117 return Cp2Info.getDefaultInstance(); 118 }, 119 lightweightExecutorService); 120 } 121 queryCp2ForExtendedDirectoryIds()122 private ListenableFuture<List<Long>> queryCp2ForExtendedDirectoryIds() { 123 return backgroundExecutorService.submit( 124 () -> { 125 List<Long> directoryIds = new ArrayList<>(); 126 try (Cursor cursor = 127 appContext 128 .getContentResolver() 129 .query( 130 Directory.ENTERPRISE_CONTENT_URI, 131 /* projection = */ new String[] {ContactsContract.Directory._ID}, 132 /* selection = */ null, 133 /* selectionArgs = */ null, 134 /* sortOrder = */ ContactsContract.Directory._ID)) { 135 if (cursor == null) { 136 LogUtil.e( 137 "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForExtendedDirectoryIds", "null cursor"); 138 return directoryIds; 139 } 140 141 if (!cursor.moveToFirst()) { 142 LogUtil.i( 143 "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForExtendedDirectoryIds", 144 "empty cursor"); 145 return directoryIds; 146 } 147 148 int idColumnIndex = cursor.getColumnIndexOrThrow(ContactsContract.Directory._ID); 149 do { 150 long directoryId = cursor.getLong(idColumnIndex); 151 152 if (isExtendedDirectory(directoryId)) { 153 directoryIds.add(cursor.getLong(idColumnIndex)); 154 } 155 } while (cursor.moveToNext()); 156 return directoryIds; 157 } 158 }); 159 } 160 161 private ListenableFuture<Cp2Info> queryCp2ForDirectoryContact( 162 DialerPhoneNumber dialerPhoneNumber, List<Long> directoryIds) { 163 if (directoryIds.isEmpty()) { 164 return Futures.immediateFuture(Cp2Info.getDefaultInstance()); 165 } 166 167 // Note: This loses country info when number is not valid. 168 String number = dialerPhoneNumber.getNormalizedNumber(); 169 170 List<ListenableFuture<Cp2Info>> cp2InfoFutures = new ArrayList<>(); 171 for (long directoryId : directoryIds) { 172 cp2InfoFutures.add(queryCp2ForDirectoryContact(number, directoryId)); 173 } 174 175 return Futures.transform( 176 Futures.allAsList(cp2InfoFutures), 177 cp2InfoList -> { 178 Cp2Info.Builder cp2InfoBuilder = Cp2Info.newBuilder(); 179 for (Cp2Info cp2Info : cp2InfoList) { 180 cp2InfoBuilder.addAllCp2ContactInfo(cp2Info.getCp2ContactInfoList()); 181 } 182 return cp2InfoBuilder.build(); 183 }, 184 lightweightExecutorService); 185 } 186 187 private ListenableFuture<Cp2Info> queryCp2ForDirectoryContact(String number, long directoryId) { 188 return backgroundExecutorService.submit( 189 () -> { 190 Cp2Info.Builder cp2InfoBuilder = Cp2Info.newBuilder(); 191 try (Cursor cursor = 192 appContext 193 .getContentResolver() 194 .query( 195 getContentUriForContacts(number, directoryId), 196 Cp2Projections.getProjectionForPhoneLookupTable(), 197 /* selection = */ null, 198 /* selectionArgs = */ null, 199 /* sortOrder = */ null)) { 200 if (cursor == null) { 201 LogUtil.e( 202 "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForDirectoryContact", 203 "null cursor returned when querying directory %d", 204 directoryId); 205 return cp2InfoBuilder.build(); 206 } 207 208 if (!cursor.moveToFirst()) { 209 LogUtil.i( 210 "Cp2ExtendedDirectoryPhoneLookup.queryCp2ForDirectoryContact", 211 "empty cursor returned when querying directory %d", 212 directoryId); 213 return cp2InfoBuilder.build(); 214 } 215 216 do { 217 cp2InfoBuilder.addCp2ContactInfo( 218 Cp2Projections.buildCp2ContactInfoFromCursor(appContext, cursor, directoryId)); 219 } while (cursor.moveToNext()); 220 } 221 222 return cp2InfoBuilder.build(); 223 }); 224 } 225 226 @VisibleForTesting 227 static Uri getContentUriForContacts(String number, long directoryId) { 228 Uri.Builder builder = 229 ContactsContract.PhoneLookup.ENTERPRISE_CONTENT_FILTER_URI 230 .buildUpon() 231 .appendPath(number) 232 .appendQueryParameter( 233 ContactsContract.PhoneLookup.QUERY_PARAMETER_SIP_ADDRESS, 234 String.valueOf(PhoneNumberHelper.isUriNumber(number))) 235 .appendQueryParameter( 236 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); 237 238 return builder.build(); 239 } 240 241 private static boolean isExtendedDirectory(long directoryId) { 242 return Directory.isRemoteDirectoryId(directoryId) 243 || Directory.isEnterpriseDirectoryId(directoryId); 244 } 245 246 @Override 247 public ListenableFuture<Boolean> isDirty(ImmutableSet<DialerPhoneNumber> phoneNumbers) { 248 if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { 249 Predicate<PhoneLookupInfo> phoneLookupInfoIsDirtyFn = 250 phoneLookupInfo -> 251 !phoneLookupInfo.getExtendedCp2Info().equals(Cp2Info.getDefaultInstance()); 252 return missingPermissionsOperations.isDirtyForMissingPermissions( 253 phoneNumbers, phoneLookupInfoIsDirtyFn); 254 } 255 return Futures.immediateFuture(false); 256 } 257 258 @Override 259 public ListenableFuture<ImmutableMap<DialerPhoneNumber, Cp2Info>> getMostRecentInfo( 260 ImmutableMap<DialerPhoneNumber, Cp2Info> existingInfoMap) { 261 if (!PermissionsUtil.hasContactsReadPermissions(appContext)) { 262 LogUtil.w("Cp2ExtendedDirectoryPhoneLookup.getMostRecentInfo", "missing permissions"); 263 return missingPermissionsOperations.getMostRecentInfoForMissingPermissions(existingInfoMap); 264 } 265 return Futures.immediateFuture(existingInfoMap); 266 } 267 268 @Override 269 public void setSubMessage(PhoneLookupInfo.Builder destination, Cp2Info subMessage) { 270 destination.setExtendedCp2Info(subMessage); 271 } 272 273 @Override 274 public Cp2Info getSubMessage(PhoneLookupInfo phoneLookupInfo) { 275 return phoneLookupInfo.getExtendedCp2Info(); 276 } 277 278 @Override 279 public ListenableFuture<Void> onSuccessfulBulkUpdate() { 280 return Futures.immediateFuture(null); 281 } 282 283 @Override 284 public void registerContentObservers() { 285 // For contacts in remote directories, no content observer can be registered. 286 // For contacts in local (but not default) directories (e.g., the local work directory), we 287 // don't register a content observer for now. 288 } 289 290 @Override 291 public void unregisterContentObservers() {} 292 293 @Override 294 public ListenableFuture<Void> clearData() { 295 return Futures.immediateFuture(null); 296 } 297 298 @Override 299 public String getLoggingName() { 300 return "Cp2ExtendedDirectoryPhoneLookup"; 301 } 302 } 303