/* * 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.ArraySet; 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.Arrays; 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; // 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) { AppSearchHelper appSearchHelper = new AppSearchHelper(context, executor); appSearchHelper.initializeAsync(); return appSearchHelper; } @VisibleForTesting AppSearchHelper(@NonNull Context context, @NonNull Executor executor) { mContext = Objects.requireNonNull(context); mExecutor = Objects.requireNonNull(executor); } /** * 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()) .addRequiredPermissionsForSchemaTypeVisibility( Person.SCHEMA_TYPE, Collections.singleton(SetSchemaRequest.READ_CONTACTS)) // Adds a permission set that allows the Person schema to be read by an // enterprise session. The set contains ENTERPRISE_ACCESS which makes it // visible to enterprise sessions and unsatisfiable for regular sessions. // The set also requires the caller to have regular read contacts access and // managed profile contacts access. .addRequiredPermissionsForSchemaTypeVisibility( Person.SCHEMA_TYPE, new ArraySet<>( Arrays.asList( SetSchemaRequest.ENTERPRISE_ACCESS, SetSchemaRequest.READ_CONTACTS, SetSchemaRequest.MANAGED_PROFILE_CONTACTS_ACCESS))) .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(); } @VisibleForTesting void setAppSearchSessionFutureForTesting( CompletableFuture appSearchSessionFuture) { mAppSearchSessionFuture = appSearchSessionFuture; } /** * 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. * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. When true, the returned future completes * normally even when contacts have failed to be added. AppSearchResult#RESULT_OUT_OF_SPACE * failures are an exception to this however and will still complete exceptionally. */ @NonNull public CompletableFuture indexContactsAsync( @NonNull Collection contacts, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError) { 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; 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()) { int errorCode = failure.getResultCode(); if (firstFailure == null) { if (shouldKeepUpdatingOnError) { // Still complete exceptionally (and abort // further indexing) if // AppSearchResult#RESULT_OUT_OF_SPACE if (errorCode == AppSearchResult .RESULT_OUT_OF_SPACE) { firstFailure = failure; } } else { firstFailure = failure; } } updateStats.mUpdateStatuses.add(errorCode); } if (firstFailure == null) { future.complete(null); } else { 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) { Log.e(TAG, "Failed to add contacts", throwable); // Log a combined status code; ranges of the codes do not // overlap 10100 + 0-99 updateStats.mUpdateStatuses.add( ContactsUpdateStats.ERROR_CODE_APP_SEARCH_SYSTEM_ERROR + AppSearchResult.throwableToFailedResult( throwable) .getResultCode()); if (shouldKeepUpdatingOnError) { future.complete(null); } else { 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. * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. When enabled, the returned future completes * normally even when contacts have failed to be removed. */ @NonNull public CompletableFuture removeContactsByIdAsync( @NonNull Collection ids, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError) { 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 = result.getFailures().size(); int numNotFound = 0; updateStats.mContactsDeleteSucceededCount += numSuccesses; if (result.isSuccess()) { if (LogUtil.DEBUG) { Log.v( TAG, numSuccesses + " documents successfully deleted from" + " AppSearch."); } future.complete(null); } else { AppSearchResult firstFailure = null; for (AppSearchResult failedResult : result.getFailures().values()) { // Ignore failures if the error code is // AppSearchResult#RESULT_NOT_FOUND // or if shouldKeepUpdatingOnError is true int errorCode = failedResult.getResultCode(); if (errorCode == AppSearchResult.RESULT_NOT_FOUND) { numNotFound++; } else if (firstFailure == null && !shouldKeepUpdatingOnError) { firstFailure = failedResult; } updateStats.mDeleteStatuses.add(errorCode); } updateStats.mContactsDeleteNotFoundCount += numNotFound; if (firstFailure == null) { future.complete(null); } else { Log.w( TAG, "Failed to delete " + numFailures + " contacts from AppSearch"); future.completeExceptionally( new AppSearchException( firstFailure.getResultCode(), firstFailure.getErrorMessage())); } } } @Override public void onSystemError(Throwable throwable) { Log.e(TAG, "Failed to delete contacts", throwable); // Log a combined status code; ranges of the codes do not // overlap 10100 + 0-99 updateStats.mDeleteStatuses.add( ContactsUpdateStats.ERROR_CODE_APP_SEARCH_SYSTEM_ERROR + AppSearchResult.throwableToFailedResult( throwable) .getResultCode()); if (shouldKeepUpdatingOnError) { future.complete(null); } else { future.completeExceptionally(throwable); } } }); return future; }); } /** * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. When enabled, the returned future completes * normally even when contacts could not be retrieved. */ @NonNull private CompletableFuture getContactsByIdAsync( @NonNull GetByDocumentIdRequest request, boolean shouldKeepUpdatingOnError, @NonNull ContactsUpdateStats updateStats) { 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) { Log.e(TAG, "Failed to get contacts", throwable); // Log a combined status code; ranges of the codes do not // overlap // 10100 + 0-99 updateStats.mUpdateStatuses.add( ContactsUpdateStats.ERROR_CODE_APP_SEARCH_SYSTEM_ERROR + AppSearchResult.throwableToFailedResult( throwable) .getResultCode()); if (shouldKeepUpdatingOnError) { future.complete( new AppSearchBatchResult.Builder<>().build()); } else { 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. * * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. * @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, boolean shouldKeepUpdatingOnError, @NonNull ContactsUpdateStats updateStats) { 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, shouldKeepUpdatingOnError, updateStats) .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); }); } }