1 /* 2 * Copyright (C) 2022 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.server.appsearch.contactsindexer; 18 19 import android.annotation.NonNull; 20 import android.annotation.Nullable; 21 import android.annotation.WorkerThread; 22 import android.app.appsearch.AppSearchBatchResult; 23 import android.app.appsearch.AppSearchManager; 24 import android.app.appsearch.AppSearchResult; 25 import android.app.appsearch.AppSearchSession; 26 import android.app.appsearch.BatchResultCallback; 27 import android.app.appsearch.GenericDocument; 28 import android.app.appsearch.GetByDocumentIdRequest; 29 import android.app.appsearch.PutDocumentsRequest; 30 import android.app.appsearch.RemoveByDocumentIdRequest; 31 import android.app.appsearch.SearchResult; 32 import android.app.appsearch.SearchResults; 33 import android.app.appsearch.SearchSpec; 34 import android.app.appsearch.SetSchemaRequest; 35 import android.app.appsearch.exceptions.AppSearchException; 36 import android.app.appsearch.util.LogUtil; 37 import android.content.Context; 38 import android.util.AndroidRuntimeException; 39 import android.util.Log; 40 41 import com.android.internal.annotations.VisibleForTesting; 42 import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint; 43 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; 44 45 import java.util.ArrayList; 46 import java.util.Collection; 47 import java.util.Collections; 48 import java.util.List; 49 import java.util.Map; 50 import java.util.Objects; 51 import java.util.concurrent.CompletableFuture; 52 import java.util.concurrent.ExecutionException; 53 import java.util.concurrent.Executor; 54 55 /** 56 * Helper class to manage the Person corpus in AppSearch. 57 * 58 * <p>It wraps AppSearch API calls using {@link CompletableFuture}, which is easier to use. 59 * 60 * <p>Note that, most of those methods are async. And some of them, like {@link 61 * #indexContactsAsync(Collection, ContactsUpdateStats)}, accepts a collection of contacts. The 62 * caller can modify the collection after the async method returns. There is no need for the 63 * CompletableFuture that's returned to be completed. 64 * 65 * <p>This class is thread-safe. 66 * 67 * @hide 68 */ 69 public class AppSearchHelper { 70 static final String TAG = "ContactsIndexerAppSearc"; 71 72 public static final String DATABASE_NAME = "contacts"; 73 // Namespace needed to be used for ContactsIndexer to index the contacts 74 public static final String NAMESPACE_NAME = ""; 75 76 private static final int GET_CONTACT_IDS_PAGE_SIZE = 500; 77 78 private final Context mContext; 79 private final Executor mExecutor; 80 private final ContactsIndexerConfig mContactsIndexerConfig; 81 // Holds the result of an asynchronous operation to create an AppSearchSession 82 // and set the builtin:Person schema in it. 83 private volatile CompletableFuture<AppSearchSession> mAppSearchSessionFuture; 84 private final CompletableFuture<Boolean> mDataLikelyWipedDuringInitFuture = 85 new CompletableFuture<>(); 86 87 /** 88 * Creates an initialized {@link AppSearchHelper}. 89 * 90 * @param executor Executor used to handle result callbacks from AppSearch. 91 */ 92 @NonNull createAppSearchHelper( @onNull Context context, @NonNull Executor executor, @NonNull ContactsIndexerConfig contactsIndexerConfig)93 public static AppSearchHelper createAppSearchHelper( 94 @NonNull Context context, 95 @NonNull Executor executor, 96 @NonNull ContactsIndexerConfig contactsIndexerConfig) { 97 AppSearchHelper appSearchHelper = new AppSearchHelper(context, executor, 98 contactsIndexerConfig); 99 appSearchHelper.initializeAsync(); 100 return appSearchHelper; 101 } 102 103 @VisibleForTesting AppSearchHelper(@onNull Context context, @NonNull Executor executor, @NonNull ContactsIndexerConfig contactsIndexerConfig)104 AppSearchHelper(@NonNull Context context, @NonNull Executor executor, 105 @NonNull ContactsIndexerConfig contactsIndexerConfig) { 106 mContext = Objects.requireNonNull(context); 107 mExecutor = Objects.requireNonNull(executor); 108 mContactsIndexerConfig = Objects.requireNonNull(contactsIndexerConfig); 109 } 110 111 /** 112 * Initializes {@link AppSearchHelper} asynchronously. 113 * 114 * <p>Chains {@link CompletableFuture}s to create an {@link AppSearchSession} and 115 * set builtin:Person schema. 116 */ initializeAsync()117 private void initializeAsync() { 118 AppSearchManager appSearchManager = mContext.getSystemService(AppSearchManager.class); 119 if (appSearchManager == null) { 120 throw new AndroidRuntimeException( 121 "Can't get AppSearchManager to initialize AppSearchHelper."); 122 } 123 124 CompletableFuture<AppSearchSession> createSessionFuture = 125 createAppSearchSessionAsync(appSearchManager); 126 mAppSearchSessionFuture = createSessionFuture.thenCompose(appSearchSession -> { 127 // set the schema with forceOverride false first. And if it fails, we will set the 128 // schema with forceOverride true. This way, we know when the data is wiped due to an 129 // incompatible schema change, which is the main cause for the 1st setSchema to fail. 130 return setPersonSchemaAsync(appSearchSession, /*forceOverride=*/ false) 131 .handle((x, e) -> { 132 boolean firstSetSchemaFailed = false; 133 if (e != null) { 134 Log.w(TAG, "Error while setting schema with forceOverride false.", e); 135 firstSetSchemaFailed = true; 136 } 137 return firstSetSchemaFailed; 138 }).thenCompose(firstSetSchemaFailed -> { 139 mDataLikelyWipedDuringInitFuture.complete(firstSetSchemaFailed); 140 if (firstSetSchemaFailed) { 141 // Try setSchema with forceOverride true. 142 // If it succeeds, we know the data is likely to be wiped due to an 143 // incompatible schema change. 144 // If if fails, we don't know the state of that corpus in AppSearch. 145 return setPersonSchemaAsync(appSearchSession, /*forceOverride=*/ true); 146 } 147 return CompletableFuture.completedFuture(appSearchSession); 148 }); 149 }); 150 } 151 152 /** 153 * Creates the {@link AppSearchSession}. 154 * 155 * <p>It returns {@link CompletableFuture} so caller can wait for a valid AppSearchSession 156 * created, which must be done before ContactsIndexer starts handling CP2 changes. 157 */ createAppSearchSessionAsync( @onNull AppSearchManager appSearchManager)158 private CompletableFuture<AppSearchSession> createAppSearchSessionAsync( 159 @NonNull AppSearchManager appSearchManager) { 160 Objects.requireNonNull(appSearchManager); 161 162 CompletableFuture<AppSearchSession> future = new CompletableFuture<>(); 163 final AppSearchManager.SearchContext searchContext = 164 new AppSearchManager.SearchContext.Builder(DATABASE_NAME).build(); 165 appSearchManager.createSearchSession(searchContext, mExecutor, result -> { 166 if (result.isSuccess()) { 167 future.complete(result.getResultValue()); 168 } else { 169 Log.e(TAG, "Failed to create an AppSearchSession - code: " + result.getResultCode() 170 + " errorMessage: " + result.getErrorMessage()); 171 future.completeExceptionally( 172 new AppSearchException(result.getResultCode(), result.getErrorMessage())); 173 } 174 }); 175 176 return future; 177 } 178 179 /** 180 * Sets the Person schemas for the {@link AppSearchSession}. 181 * 182 * <p>It returns {@link CompletableFuture} so caller can wait for valid schemas set, which must 183 * be done before ContactsIndexer starts handling CP2 changes. 184 * 185 * @param session {@link AppSearchSession} created before. 186 * @param forceOverride whether the incompatible schemas should be overridden. 187 */ 188 @NonNull setPersonSchemaAsync( @onNull AppSearchSession session, boolean forceOverride)189 private CompletableFuture<AppSearchSession> setPersonSchemaAsync( 190 @NonNull AppSearchSession session, boolean forceOverride) { 191 Objects.requireNonNull(session); 192 193 CompletableFuture<AppSearchSession> future = new CompletableFuture<>(); 194 SetSchemaRequest.Builder schemaBuilder = new SetSchemaRequest.Builder() 195 .addSchemas(ContactPoint.SCHEMA, Person.getSchema(mContactsIndexerConfig)) 196 .addRequiredPermissionsForSchemaTypeVisibility(Person.SCHEMA_TYPE, 197 Collections.singleton(SetSchemaRequest.READ_CONTACTS)) 198 .setForceOverride(forceOverride); 199 session.setSchema(schemaBuilder.build(), mExecutor, mExecutor, 200 result -> { 201 if (result.isSuccess()) { 202 future.complete(session); 203 } else { 204 Log.e(TAG, "SetSchema failed: code " + result.getResultCode() + " message:" 205 + result.getErrorMessage()); 206 future.completeExceptionally(new AppSearchException(result.getResultCode(), 207 result.getErrorMessage())); 208 } 209 }); 210 return future; 211 } 212 213 @WorkerThread 214 @VisibleForTesting 215 @Nullable getSession()216 AppSearchSession getSession() throws ExecutionException, InterruptedException { 217 return mAppSearchSessionFuture.get(); 218 } 219 220 /** 221 * Returns if the data is likely being wiped during initialization of this {@link 222 * AppSearchHelper}. 223 * 224 * <p>The Person corpus in AppSearch can be wiped during setSchema, and this indicates if it 225 * happens: 226 * <li>If the value is {@code false}, we are sure there is NO data loss. 227 * <li>If the value is {@code true}, it is very likely the data loss happens, or the whole 228 * initialization fails and the data state is unknown. Callers need to query AppSearch to 229 * confirm. 230 */ 231 @NonNull isDataLikelyWipedDuringInitAsync()232 public CompletableFuture<Boolean> isDataLikelyWipedDuringInitAsync() { 233 // Internally, it indicates whether the first setSchema with forceOverride false fails or 234 // not. 235 return mDataLikelyWipedDuringInitFuture; 236 } 237 238 /** 239 * Indexes contacts into AppSearch 240 * 241 * @param contacts a collection of contacts. AppSearch batch put will be used to send the 242 * documents over in one call. So the size of this collection can't be too 243 * big, otherwise binder {@link android.os.TransactionTooLargeException} will 244 * be thrown. 245 * @param updateStats to hold the counters for the update. 246 */ 247 @NonNull indexContactsAsync(@onNull Collection<Person> contacts, @NonNull ContactsUpdateStats updateStats)248 public CompletableFuture<Void> indexContactsAsync(@NonNull Collection<Person> contacts, 249 @NonNull ContactsUpdateStats updateStats) { 250 Objects.requireNonNull(contacts); 251 Objects.requireNonNull(updateStats); 252 253 if (LogUtil.DEBUG) { 254 Log.v(TAG, "Indexing " + contacts.size() + " contacts into AppSearch"); 255 } 256 PutDocumentsRequest request = new PutDocumentsRequest.Builder() 257 .addGenericDocuments(contacts) 258 .build(); 259 return mAppSearchSessionFuture.thenCompose(appSearchSession -> { 260 CompletableFuture<Void> future = new CompletableFuture<>(); 261 appSearchSession.put(request, mExecutor, new BatchResultCallback<String, Void>() { 262 @Override 263 public void onResult(AppSearchBatchResult<String, Void> result) { 264 int numDocsSucceeded = result.getSuccesses().size(); 265 int numDocsFailed = result.getFailures().size(); 266 updateStats.mContactsUpdateSucceededCount += numDocsSucceeded; 267 updateStats.mContactsUpdateFailedCount += numDocsFailed; 268 if (result.isSuccess()) { 269 if (LogUtil.DEBUG) { 270 Log.v(TAG, 271 numDocsSucceeded 272 + " documents successfully added in AppSearch."); 273 } 274 future.complete(null); 275 } else { 276 Map<String, AppSearchResult<Void>> failures = result.getFailures(); 277 AppSearchResult<Void> firstFailure = null; 278 for (AppSearchResult<Void> failure : failures.values()) { 279 if (firstFailure == null) { 280 firstFailure = failure; 281 } 282 updateStats.mUpdateStatuses.add(failure.getResultCode()); 283 } 284 Log.w(TAG, numDocsFailed + " documents failed to be added in AppSearch."); 285 future.completeExceptionally(new AppSearchException( 286 firstFailure.getResultCode(), firstFailure.getErrorMessage())); 287 } 288 } 289 290 @Override 291 public void onSystemError(Throwable throwable) { 292 updateStats.mUpdateStatuses.add(AppSearchResult.RESULT_UNKNOWN_ERROR); 293 future.completeExceptionally(throwable); 294 } 295 }); 296 return future; 297 }); 298 } 299 300 /** 301 * Remove contacts from AppSearch 302 * 303 * @param ids a collection of contact ids. AppSearch batch remove will be used to send 304 * the ids over in one call. So the size of this collection can't be too 305 * big, otherwise binder {@link android.os.TransactionTooLargeException} 306 * will be thrown. 307 * @param updateStats to hold the counters for the update. 308 */ 309 @NonNull removeContactsByIdAsync(@onNull Collection<String> ids, @NonNull ContactsUpdateStats updateStats)310 public CompletableFuture<Void> removeContactsByIdAsync(@NonNull Collection<String> ids, 311 @NonNull ContactsUpdateStats updateStats) { 312 Objects.requireNonNull(ids); 313 Objects.requireNonNull(updateStats); 314 315 if (LogUtil.DEBUG) { 316 Log.v(TAG, "Removing " + ids.size() + " contacts from AppSearch"); 317 } 318 RemoveByDocumentIdRequest request = new RemoveByDocumentIdRequest.Builder(NAMESPACE_NAME) 319 .addIds(ids) 320 .build(); 321 return mAppSearchSessionFuture.thenCompose(appSearchSession -> { 322 CompletableFuture<Void> future = new CompletableFuture<>(); 323 appSearchSession.remove(request, mExecutor, new BatchResultCallback<String, Void>() { 324 @Override 325 public void onResult(AppSearchBatchResult<String, Void> result) { 326 int numSuccesses = result.getSuccesses().size(); 327 int numFailures = 0; 328 AppSearchResult<Void> firstFailure = null; 329 for (AppSearchResult<Void> failedResult : result.getFailures().values()) { 330 // Ignore document not found errors. 331 int errorCode = failedResult.getResultCode(); 332 if (errorCode != AppSearchResult.RESULT_NOT_FOUND) { 333 numFailures++; 334 updateStats.mDeleteStatuses.add(errorCode); 335 if (firstFailure == null) { 336 firstFailure = failedResult; 337 } 338 } 339 } 340 updateStats.mContactsDeleteSucceededCount += numSuccesses; 341 updateStats.mContactsDeleteFailedCount += numFailures; 342 if (firstFailure != null) { 343 Log.w(TAG, "Failed to delete " 344 + numFailures + " contacts from AppSearch"); 345 future.completeExceptionally(new AppSearchException( 346 firstFailure.getResultCode(), firstFailure.getErrorMessage())); 347 return; 348 } 349 if (LogUtil.DEBUG && numSuccesses > 0) { 350 Log.v(TAG, 351 numSuccesses + " documents successfully deleted from AppSearch."); 352 } 353 future.complete(null); 354 } 355 356 @Override 357 public void onSystemError(Throwable throwable) { 358 updateStats.mDeleteStatuses.add(AppSearchResult.RESULT_UNKNOWN_ERROR); 359 future.completeExceptionally(throwable); 360 } 361 }); 362 return future; 363 }); 364 } 365 366 @NonNull 367 private CompletableFuture<AppSearchBatchResult> getContactsByIdAsync( 368 @NonNull GetByDocumentIdRequest request) { 369 Objects.requireNonNull(request); 370 return mAppSearchSessionFuture.thenCompose(appSearchSession -> { 371 CompletableFuture<AppSearchBatchResult> future = new CompletableFuture<>(); 372 appSearchSession.getByDocumentId(request, mExecutor, 373 new BatchResultCallback<String, GenericDocument>() { 374 @Override 375 public void onResult(AppSearchBatchResult<String, GenericDocument> result) { 376 future.complete(result); 377 } 378 379 @Override 380 public void onSystemError(Throwable throwable) { 381 future.completeExceptionally(throwable); 382 } 383 }); 384 return future; 385 }); 386 } 387 388 /** 389 * Returns IDs of all contacts indexed in AppSearch 390 * 391 * <p>Issues an empty query with an empty projection and pages through all results, collecting 392 * the document IDs to return to the caller. 393 */ 394 @NonNull 395 public CompletableFuture<List<String>> getAllContactIdsAsync() { 396 return mAppSearchSessionFuture.thenCompose(appSearchSession -> { 397 SearchSpec allDocumentIdsSpec = new SearchSpec.Builder() 398 .addFilterNamespaces(NAMESPACE_NAME) 399 .addFilterSchemas(Person.SCHEMA_TYPE) 400 .addProjection(Person.SCHEMA_TYPE, /*propertyPaths=*/ Collections.emptyList()) 401 .setResultCountPerPage(GET_CONTACT_IDS_PAGE_SIZE) 402 .build(); 403 SearchResults results = 404 appSearchSession.search(/*queryExpression=*/ "", allDocumentIdsSpec); 405 List<String> allContactIds = new ArrayList<>(); 406 return collectDocumentIdsFromAllPagesAsync(results, allContactIds) 407 .thenCompose(unused -> { 408 results.close(); 409 return CompletableFuture.supplyAsync(() -> allContactIds); 410 }); 411 }); 412 } 413 414 /** 415 * Gets {@link GenericDocument}s with only fingerprints projected for the requested contact ids. 416 * 417 * @return A list containing the corresponding {@link GenericDocument} for the requested contact 418 * ids in order. The entry is {@code null} if the requested contact id is not found in 419 * AppSearch. 420 */ 421 @NonNull 422 public CompletableFuture<List<GenericDocument>> getContactsWithFingerprintsAsync( 423 @NonNull List<String> ids) { 424 Objects.requireNonNull(ids); 425 GetByDocumentIdRequest request = new GetByDocumentIdRequest.Builder( 426 AppSearchHelper.NAMESPACE_NAME) 427 .addProjection(Person.SCHEMA_TYPE, 428 Collections.singletonList(Person.PERSON_PROPERTY_FINGERPRINT)) 429 .addIds(ids) 430 .build(); 431 return getContactsByIdAsync(request).thenCompose( 432 appSearchBatchResult -> { 433 Map<String, GenericDocument> contactsExistInAppSearch = 434 appSearchBatchResult.getSuccesses(); 435 List<GenericDocument> docsWithFingerprints = new ArrayList<>(ids.size()); 436 for (int i = 0; i < ids.size(); ++i) { 437 docsWithFingerprints.add(contactsExistInAppSearch.get(ids.get(i))); 438 } 439 return CompletableFuture.completedFuture(docsWithFingerprints); 440 }); 441 } 442 443 /** 444 * Recursively pages through all search results and collects document IDs into given list. 445 * 446 * @param results Iterator for paging through the search results. 447 * @param contactIds List for collecting and returning document IDs. 448 * @return A future indicating if more results might be available. 449 */ 450 private CompletableFuture<Boolean> collectDocumentIdsFromAllPagesAsync( 451 @NonNull SearchResults results, 452 @NonNull List<String> contactIds) { 453 Objects.requireNonNull(results); 454 Objects.requireNonNull(contactIds); 455 456 CompletableFuture<Boolean> future = new CompletableFuture<>(); 457 results.getNextPage(mExecutor, callback -> { 458 if (!callback.isSuccess()) { 459 future.completeExceptionally(new AppSearchException(callback.getResultCode(), 460 callback.getErrorMessage())); 461 return; 462 } 463 List<SearchResult> resultList = callback.getResultValue(); 464 for (int i = 0; i < resultList.size(); i++) { 465 SearchResult result = resultList.get(i); 466 contactIds.add(result.getGenericDocument().getId()); 467 } 468 future.complete(!resultList.isEmpty()); 469 }); 470 return future.thenCompose(moreResults -> { 471 // Recurse if there might be more results to page through. 472 if (moreResults) { 473 return collectDocumentIdsFromAllPagesAsync(results, contactIds); 474 } 475 return CompletableFuture.supplyAsync(() -> false); 476 }); 477 } 478 } 479