/* * Copyright (C) 2022 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.server.appsearch.contactsindexer; import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.WorkerThread; import android.app.appsearch.AppSearchBatchResult; import android.app.appsearch.AppSearchManager; import android.app.appsearch.AppSearchResult; import android.app.appsearch.AppSearchSession; import android.app.appsearch.BatchResultCallback; import android.app.appsearch.GenericDocument; import android.app.appsearch.GetByDocumentIdRequest; import android.app.appsearch.PutDocumentsRequest; import android.app.appsearch.RemoveByDocumentIdRequest; import android.app.appsearch.SearchResult; import android.app.appsearch.SearchResults; import android.app.appsearch.SearchSpec; import android.app.appsearch.SetSchemaRequest; import android.app.appsearch.exceptions.AppSearchException; import android.app.appsearch.util.LogUtil; import android.content.Context; import android.util.AndroidRuntimeException; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint; import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; /** * Helper class to manage the Person corpus in AppSearch. * *

It wraps AppSearch API calls using {@link CompletableFuture}, which is easier to use. * *

Note that, most of those methods are async. And some of them, like {@link * #indexContactsAsync(Collection, ContactsUpdateStats)}, accepts a collection of contacts. The * caller can modify the collection after the async method returns. There is no need for the * CompletableFuture that's returned to be completed. * *

This class is thread-safe. * * @hide */ public class AppSearchHelper { static final String TAG = "ContactsIndexerAppSearc"; public static final String DATABASE_NAME = "contacts"; // Namespace needed to be used for ContactsIndexer to index the contacts public static final String NAMESPACE_NAME = ""; private static final int GET_CONTACT_IDS_PAGE_SIZE = 500; private final Context mContext; private final Executor mExecutor; private final ContactsIndexerConfig mContactsIndexerConfig; // Holds the result of an asynchronous operation to create an AppSearchSession // and set the builtin:Person schema in it. private volatile CompletableFuture mAppSearchSessionFuture; private final CompletableFuture mDataLikelyWipedDuringInitFuture = new CompletableFuture<>(); /** * Creates an initialized {@link AppSearchHelper}. * * @param executor Executor used to handle result callbacks from AppSearch. */ @NonNull public static AppSearchHelper createAppSearchHelper( @NonNull Context context, @NonNull Executor executor, @NonNull ContactsIndexerConfig contactsIndexerConfig) { AppSearchHelper appSearchHelper = new AppSearchHelper(context, executor, contactsIndexerConfig); appSearchHelper.initializeAsync(); return appSearchHelper; } @VisibleForTesting AppSearchHelper(@NonNull Context context, @NonNull Executor executor, @NonNull ContactsIndexerConfig contactsIndexerConfig) { mContext = Objects.requireNonNull(context); mExecutor = Objects.requireNonNull(executor); mContactsIndexerConfig = Objects.requireNonNull(contactsIndexerConfig); } /** * Initializes {@link AppSearchHelper} asynchronously. * *

Chains {@link CompletableFuture}s to create an {@link AppSearchSession} and * set builtin:Person schema. */ private void initializeAsync() { AppSearchManager appSearchManager = mContext.getSystemService(AppSearchManager.class); if (appSearchManager == null) { throw new AndroidRuntimeException( "Can't get AppSearchManager to initialize AppSearchHelper."); } CompletableFuture createSessionFuture = createAppSearchSessionAsync(appSearchManager); mAppSearchSessionFuture = createSessionFuture.thenCompose(appSearchSession -> { // set the schema with forceOverride false first. And if it fails, we will set the // schema with forceOverride true. This way, we know when the data is wiped due to an // incompatible schema change, which is the main cause for the 1st setSchema to fail. return setPersonSchemaAsync(appSearchSession, /*forceOverride=*/ false) .handle((x, e) -> { boolean firstSetSchemaFailed = false; if (e != null) { Log.w(TAG, "Error while setting schema with forceOverride false.", e); firstSetSchemaFailed = true; } return firstSetSchemaFailed; }).thenCompose(firstSetSchemaFailed -> { mDataLikelyWipedDuringInitFuture.complete(firstSetSchemaFailed); if (firstSetSchemaFailed) { // Try setSchema with forceOverride true. // If it succeeds, we know the data is likely to be wiped due to an // incompatible schema change. // If if fails, we don't know the state of that corpus in AppSearch. return setPersonSchemaAsync(appSearchSession, /*forceOverride=*/ true); } return CompletableFuture.completedFuture(appSearchSession); }); }); } /** * Creates the {@link AppSearchSession}. * *

It returns {@link CompletableFuture} so caller can wait for a valid AppSearchSession * created, which must be done before ContactsIndexer starts handling CP2 changes. */ private CompletableFuture createAppSearchSessionAsync( @NonNull AppSearchManager appSearchManager) { Objects.requireNonNull(appSearchManager); CompletableFuture future = new CompletableFuture<>(); final AppSearchManager.SearchContext searchContext = new AppSearchManager.SearchContext.Builder(DATABASE_NAME).build(); appSearchManager.createSearchSession(searchContext, mExecutor, result -> { if (result.isSuccess()) { future.complete(result.getResultValue()); } else { Log.e(TAG, "Failed to create an AppSearchSession - code: " + result.getResultCode() + " errorMessage: " + result.getErrorMessage()); future.completeExceptionally( new AppSearchException(result.getResultCode(), result.getErrorMessage())); } }); return future; } /** * Sets the Person schemas for the {@link AppSearchSession}. * *

It returns {@link CompletableFuture} so caller can wait for valid schemas set, which must * be done before ContactsIndexer starts handling CP2 changes. * * @param session {@link AppSearchSession} created before. * @param forceOverride whether the incompatible schemas should be overridden. */ @NonNull private CompletableFuture setPersonSchemaAsync( @NonNull AppSearchSession session, boolean forceOverride) { Objects.requireNonNull(session); CompletableFuture future = new CompletableFuture<>(); SetSchemaRequest.Builder schemaBuilder = new SetSchemaRequest.Builder() .addSchemas(ContactPoint.SCHEMA, Person.getSchema(mContactsIndexerConfig)) .addRequiredPermissionsForSchemaTypeVisibility(Person.SCHEMA_TYPE, Collections.singleton(SetSchemaRequest.READ_CONTACTS)) .setForceOverride(forceOverride); session.setSchema(schemaBuilder.build(), mExecutor, mExecutor, result -> { if (result.isSuccess()) { future.complete(session); } else { Log.e(TAG, "SetSchema failed: code " + result.getResultCode() + " message:" + result.getErrorMessage()); future.completeExceptionally(new AppSearchException(result.getResultCode(), result.getErrorMessage())); } }); return future; } @WorkerThread @VisibleForTesting @Nullable AppSearchSession getSession() throws ExecutionException, InterruptedException { return mAppSearchSessionFuture.get(); } /** * Returns if the data is likely being wiped during initialization of this {@link * AppSearchHelper}. * *

The Person corpus in AppSearch can be wiped during setSchema, and this indicates if it * happens: *

  • If the value is {@code false}, we are sure there is NO data loss. *
  • If the value is {@code true}, it is very likely the data loss happens, or the whole * initialization fails and the data state is unknown. Callers need to query AppSearch to * confirm. */ @NonNull public CompletableFuture isDataLikelyWipedDuringInitAsync() { // Internally, it indicates whether the first setSchema with forceOverride false fails or // not. return mDataLikelyWipedDuringInitFuture; } /** * Indexes contacts into AppSearch * * @param contacts a collection of contacts. AppSearch batch put will be used to send the * documents over in one call. So the size of this collection can't be too * big, otherwise binder {@link android.os.TransactionTooLargeException} will * be thrown. * @param updateStats to hold the counters for the update. */ @NonNull public CompletableFuture indexContactsAsync(@NonNull Collection contacts, @NonNull ContactsUpdateStats updateStats) { Objects.requireNonNull(contacts); Objects.requireNonNull(updateStats); if (LogUtil.DEBUG) { Log.v(TAG, "Indexing " + contacts.size() + " contacts into AppSearch"); } PutDocumentsRequest request = new PutDocumentsRequest.Builder() .addGenericDocuments(contacts) .build(); return mAppSearchSessionFuture.thenCompose(appSearchSession -> { CompletableFuture future = new CompletableFuture<>(); appSearchSession.put(request, mExecutor, new BatchResultCallback() { @Override public void onResult(AppSearchBatchResult result) { int numDocsSucceeded = result.getSuccesses().size(); int numDocsFailed = result.getFailures().size(); updateStats.mContactsUpdateSucceededCount += numDocsSucceeded; updateStats.mContactsUpdateFailedCount += numDocsFailed; if (result.isSuccess()) { if (LogUtil.DEBUG) { Log.v(TAG, numDocsSucceeded + " documents successfully added in AppSearch."); } future.complete(null); } else { Map> failures = result.getFailures(); AppSearchResult firstFailure = null; for (AppSearchResult failure : failures.values()) { if (firstFailure == null) { firstFailure = failure; } updateStats.mUpdateStatuses.add(failure.getResultCode()); } Log.w(TAG, numDocsFailed + " documents failed to be added in AppSearch."); future.completeExceptionally(new AppSearchException( firstFailure.getResultCode(), firstFailure.getErrorMessage())); } } @Override public void onSystemError(Throwable throwable) { updateStats.mUpdateStatuses.add(AppSearchResult.RESULT_UNKNOWN_ERROR); future.completeExceptionally(throwable); } }); return future; }); } /** * Remove contacts from AppSearch * * @param ids a collection of contact ids. AppSearch batch remove will be used to send * the ids over in one call. So the size of this collection can't be too * big, otherwise binder {@link android.os.TransactionTooLargeException} * will be thrown. * @param updateStats to hold the counters for the update. */ @NonNull public CompletableFuture removeContactsByIdAsync(@NonNull Collection ids, @NonNull ContactsUpdateStats updateStats) { Objects.requireNonNull(ids); Objects.requireNonNull(updateStats); if (LogUtil.DEBUG) { Log.v(TAG, "Removing " + ids.size() + " contacts from AppSearch"); } RemoveByDocumentIdRequest request = new RemoveByDocumentIdRequest.Builder(NAMESPACE_NAME) .addIds(ids) .build(); return mAppSearchSessionFuture.thenCompose(appSearchSession -> { CompletableFuture future = new CompletableFuture<>(); appSearchSession.remove(request, mExecutor, new BatchResultCallback() { @Override public void onResult(AppSearchBatchResult result) { int numSuccesses = result.getSuccesses().size(); int numFailures = 0; AppSearchResult firstFailure = null; for (AppSearchResult failedResult : result.getFailures().values()) { // Ignore document not found errors. int errorCode = failedResult.getResultCode(); if (errorCode != AppSearchResult.RESULT_NOT_FOUND) { numFailures++; updateStats.mDeleteStatuses.add(errorCode); if (firstFailure == null) { firstFailure = failedResult; } } } updateStats.mContactsDeleteSucceededCount += numSuccesses; updateStats.mContactsDeleteFailedCount += numFailures; if (firstFailure != null) { Log.w(TAG, "Failed to delete " + numFailures + " contacts from AppSearch"); future.completeExceptionally(new AppSearchException( firstFailure.getResultCode(), firstFailure.getErrorMessage())); return; } if (LogUtil.DEBUG && numSuccesses > 0) { Log.v(TAG, numSuccesses + " documents successfully deleted from AppSearch."); } future.complete(null); } @Override public void onSystemError(Throwable throwable) { updateStats.mDeleteStatuses.add(AppSearchResult.RESULT_UNKNOWN_ERROR); future.completeExceptionally(throwable); } }); return future; }); } @NonNull private CompletableFuture getContactsByIdAsync( @NonNull GetByDocumentIdRequest request) { Objects.requireNonNull(request); return mAppSearchSessionFuture.thenCompose(appSearchSession -> { CompletableFuture future = new CompletableFuture<>(); appSearchSession.getByDocumentId(request, mExecutor, new BatchResultCallback() { @Override public void onResult(AppSearchBatchResult result) { future.complete(result); } @Override public void onSystemError(Throwable throwable) { future.completeExceptionally(throwable); } }); return future; }); } /** * Returns IDs of all contacts indexed in AppSearch * *

    Issues an empty query with an empty projection and pages through all results, collecting * the document IDs to return to the caller. */ @NonNull public CompletableFuture> getAllContactIdsAsync() { return mAppSearchSessionFuture.thenCompose(appSearchSession -> { SearchSpec allDocumentIdsSpec = new SearchSpec.Builder() .addFilterNamespaces(NAMESPACE_NAME) .addFilterSchemas(Person.SCHEMA_TYPE) .addProjection(Person.SCHEMA_TYPE, /*propertyPaths=*/ Collections.emptyList()) .setResultCountPerPage(GET_CONTACT_IDS_PAGE_SIZE) .build(); SearchResults results = appSearchSession.search(/*queryExpression=*/ "", allDocumentIdsSpec); List allContactIds = new ArrayList<>(); return collectDocumentIdsFromAllPagesAsync(results, allContactIds) .thenCompose(unused -> { results.close(); return CompletableFuture.supplyAsync(() -> allContactIds); }); }); } /** * Gets {@link GenericDocument}s with only fingerprints projected for the requested contact ids. * * @return A list containing the corresponding {@link GenericDocument} for the requested contact * ids in order. The entry is {@code null} if the requested contact id is not found in * AppSearch. */ @NonNull public CompletableFuture> getContactsWithFingerprintsAsync( @NonNull List ids) { Objects.requireNonNull(ids); GetByDocumentIdRequest request = new GetByDocumentIdRequest.Builder( AppSearchHelper.NAMESPACE_NAME) .addProjection(Person.SCHEMA_TYPE, Collections.singletonList(Person.PERSON_PROPERTY_FINGERPRINT)) .addIds(ids) .build(); return getContactsByIdAsync(request).thenCompose( appSearchBatchResult -> { Map contactsExistInAppSearch = appSearchBatchResult.getSuccesses(); List docsWithFingerprints = new ArrayList<>(ids.size()); for (int i = 0; i < ids.size(); ++i) { docsWithFingerprints.add(contactsExistInAppSearch.get(ids.get(i))); } return CompletableFuture.completedFuture(docsWithFingerprints); }); } /** * Recursively pages through all search results and collects document IDs into given list. * * @param results Iterator for paging through the search results. * @param contactIds List for collecting and returning document IDs. * @return A future indicating if more results might be available. */ private CompletableFuture collectDocumentIdsFromAllPagesAsync( @NonNull SearchResults results, @NonNull List contactIds) { Objects.requireNonNull(results); Objects.requireNonNull(contactIds); CompletableFuture future = new CompletableFuture<>(); results.getNextPage(mExecutor, callback -> { if (!callback.isSuccess()) { future.completeExceptionally(new AppSearchException(callback.getResultCode(), callback.getErrorMessage())); return; } List resultList = callback.getResultValue(); for (int i = 0; i < resultList.size(); i++) { SearchResult result = resultList.get(i); contactIds.add(result.getGenericDocument().getId()); } future.complete(!resultList.isEmpty()); }); return future.thenCompose(moreResults -> { // Recurse if there might be more results to page through. if (moreResults) { return collectDocumentIdsFromAllPagesAsync(results, contactIds); } return CompletableFuture.supplyAsync(() -> false); }); } }