• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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