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.ArraySet;
40 import android.util.Log;
41 
42 import com.android.internal.annotations.VisibleForTesting;
43 import com.android.server.appsearch.contactsindexer.appsearchtypes.ContactPoint;
44 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person;
45 
46 import java.util.ArrayList;
47 import java.util.Arrays;
48 import java.util.Collection;
49 import java.util.Collections;
50 import java.util.List;
51 import java.util.Map;
52 import java.util.Objects;
53 import java.util.concurrent.CompletableFuture;
54 import java.util.concurrent.ExecutionException;
55 import java.util.concurrent.Executor;
56 
57 /**
58  * Helper class to manage the Person corpus in AppSearch.
59  *
60  * <p>It wraps AppSearch API calls using {@link CompletableFuture}, which is easier to use.
61  *
62  * <p>Note that, most of those methods are async. And some of them, like {@link
63  * #indexContactsAsync(Collection, ContactsUpdateStats)}, accepts a collection of contacts. The
64  * caller can modify the collection after the async method returns. There is no need for the
65  * CompletableFuture that's returned to be completed.
66  *
67  * <p>This class is thread-safe.
68  *
69  * @hide
70  */
71 public class AppSearchHelper {
72     static final String TAG = "ContactsIndexerAppSearc";
73 
74     public static final String DATABASE_NAME = "contacts";
75     // Namespace needed to be used for ContactsIndexer to index the contacts
76     public static final String NAMESPACE_NAME = "";
77 
78     private static final int GET_CONTACT_IDS_PAGE_SIZE = 500;
79 
80     private final Context mContext;
81     private final Executor mExecutor;
82     private final ContactsIndexerConfig mContactsIndexerConfig;
83     // Holds the result of an asynchronous operation to create an AppSearchSession
84     // and set the builtin:Person schema in it.
85     private volatile CompletableFuture<AppSearchSession> mAppSearchSessionFuture;
86     private final CompletableFuture<Boolean> mDataLikelyWipedDuringInitFuture =
87             new CompletableFuture<>();
88 
89     /**
90      * Creates an initialized {@link AppSearchHelper}.
91      *
92      * @param executor Executor used to handle result callbacks from AppSearch.
93      */
94     @NonNull
createAppSearchHelper( @onNull Context context, @NonNull Executor executor, @NonNull ContactsIndexerConfig contactsIndexerConfig)95     public static AppSearchHelper createAppSearchHelper(
96             @NonNull Context context,
97             @NonNull Executor executor,
98             @NonNull ContactsIndexerConfig contactsIndexerConfig) {
99         AppSearchHelper appSearchHelper =
100                 new AppSearchHelper(context, executor, contactsIndexerConfig);
101         appSearchHelper.initializeAsync();
102         return appSearchHelper;
103     }
104 
105     @VisibleForTesting
AppSearchHelper( @onNull Context context, @NonNull Executor executor, @NonNull ContactsIndexerConfig contactsIndexerConfig)106     AppSearchHelper(
107             @NonNull Context context,
108             @NonNull Executor executor,
109             @NonNull ContactsIndexerConfig contactsIndexerConfig) {
110         mContext = Objects.requireNonNull(context);
111         mExecutor = Objects.requireNonNull(executor);
112         mContactsIndexerConfig = Objects.requireNonNull(contactsIndexerConfig);
113     }
114 
115     /**
116      * Initializes {@link AppSearchHelper} asynchronously.
117      *
118      * <p>Chains {@link CompletableFuture}s to create an {@link AppSearchSession} and set
119      * builtin:Person schema.
120      */
initializeAsync()121     private void initializeAsync() {
122         AppSearchManager appSearchManager = mContext.getSystemService(AppSearchManager.class);
123         if (appSearchManager == null) {
124             throw new AndroidRuntimeException(
125                     "Can't get AppSearchManager to initialize AppSearchHelper.");
126         }
127 
128         CompletableFuture<AppSearchSession> createSessionFuture =
129                 createAppSearchSessionAsync(appSearchManager);
130         mAppSearchSessionFuture =
131                 createSessionFuture.thenCompose(
132                         appSearchSession -> {
133                             // set the schema with forceOverride false first. And if it fails, we
134                             // will set the schema with forceOverride true. This way, we know when
135                             // the data is wiped due to an incompatible schema change, which is the
136                             // main cause for the 1st setSchema to fail.
137                             return setPersonSchemaAsync(
138                                             appSearchSession, /* forceOverride= */ false)
139                                     .handle(
140                                             (x, e) -> {
141                                                 boolean firstSetSchemaFailed = false;
142                                                 if (e != null) {
143                                                     Log.w(
144                                                             TAG,
145                                                             "Error while setting schema with"
146                                                                     + " forceOverride false.",
147                                                             e);
148                                                     firstSetSchemaFailed = true;
149                                                 }
150                                                 return firstSetSchemaFailed;
151                                             })
152                                     .thenCompose(
153                                             firstSetSchemaFailed -> {
154                                                 mDataLikelyWipedDuringInitFuture.complete(
155                                                         firstSetSchemaFailed);
156                                                 if (firstSetSchemaFailed) {
157                                                     // Try setSchema with forceOverride true.
158                                                     // If it succeeds, we know the data is likely to
159                                                     // be wiped due to an
160                                                     // incompatible schema change.
161                                                     // If if fails, we don't know the state of that
162                                                     // corpus in AppSearch.
163                                                     return setPersonSchemaAsync(
164                                                             appSearchSession,
165                                                             /* forceOverride= */ true);
166                                                 }
167                                                 return CompletableFuture.completedFuture(
168                                                         appSearchSession);
169                                             });
170                         });
171     }
172 
173     /**
174      * Creates the {@link AppSearchSession}.
175      *
176      * <p>It returns {@link CompletableFuture} so caller can wait for a valid AppSearchSession
177      * created, which must be done before ContactsIndexer starts handling CP2 changes.
178      */
createAppSearchSessionAsync( @onNull AppSearchManager appSearchManager)179     private CompletableFuture<AppSearchSession> createAppSearchSessionAsync(
180             @NonNull AppSearchManager appSearchManager) {
181         Objects.requireNonNull(appSearchManager);
182 
183         CompletableFuture<AppSearchSession> future = new CompletableFuture<>();
184         final AppSearchManager.SearchContext searchContext =
185                 new AppSearchManager.SearchContext.Builder(DATABASE_NAME).build();
186         appSearchManager.createSearchSession(
187                 searchContext,
188                 mExecutor,
189                 result -> {
190                     if (result.isSuccess()) {
191                         future.complete(result.getResultValue());
192                     } else {
193                         Log.e(
194                                 TAG,
195                                 "Failed to create an AppSearchSession - code: "
196                                         + result.getResultCode()
197                                         + " errorMessage: "
198                                         + result.getErrorMessage());
199                         future.completeExceptionally(
200                                 new AppSearchException(
201                                         result.getResultCode(), result.getErrorMessage()));
202                     }
203                 });
204 
205         return future;
206     }
207 
208     /**
209      * Sets the Person schemas for the {@link AppSearchSession}.
210      *
211      * <p>It returns {@link CompletableFuture} so caller can wait for valid schemas set, which must
212      * be done before ContactsIndexer starts handling CP2 changes.
213      *
214      * @param session {@link AppSearchSession} created before.
215      * @param forceOverride whether the incompatible schemas should be overridden.
216      */
217     @NonNull
setPersonSchemaAsync( @onNull AppSearchSession session, boolean forceOverride)218     private CompletableFuture<AppSearchSession> setPersonSchemaAsync(
219             @NonNull AppSearchSession session, boolean forceOverride) {
220         Objects.requireNonNull(session);
221 
222         CompletableFuture<AppSearchSession> future = new CompletableFuture<>();
223         SetSchemaRequest.Builder schemaBuilder =
224                 new SetSchemaRequest.Builder()
225                         .addSchemas(ContactPoint.SCHEMA, Person.getSchema(mContactsIndexerConfig))
226                         .addRequiredPermissionsForSchemaTypeVisibility(
227                                 Person.SCHEMA_TYPE,
228                                 Collections.singleton(SetSchemaRequest.READ_CONTACTS))
229                         // Adds a permission set that allows the Person schema to be read by an
230                         // enterprise session. The set contains ENTERPRISE_ACCESS which makes it
231                         // visible to enterprise sessions and unsatisfiable for regular sessions.
232                         // The set also requires the caller to have regular read contacts access and
233                         // managed profile contacts access.
234                         .addRequiredPermissionsForSchemaTypeVisibility(
235                                 Person.SCHEMA_TYPE,
236                                 new ArraySet<>(
237                                         Arrays.asList(
238                                                 SetSchemaRequest.ENTERPRISE_ACCESS,
239                                                 SetSchemaRequest.READ_CONTACTS,
240                                                 SetSchemaRequest.MANAGED_PROFILE_CONTACTS_ACCESS)))
241                         .setForceOverride(forceOverride);
242         session.setSchema(
243                 schemaBuilder.build(),
244                 mExecutor,
245                 mExecutor,
246                 result -> {
247                     if (result.isSuccess()) {
248                         future.complete(session);
249                     } else {
250                         Log.e(
251                                 TAG,
252                                 "SetSchema failed: code "
253                                         + result.getResultCode()
254                                         + " message:"
255                                         + result.getErrorMessage());
256                         future.completeExceptionally(
257                                 new AppSearchException(
258                                         result.getResultCode(), result.getErrorMessage()));
259                     }
260                 });
261         return future;
262     }
263 
264     @WorkerThread
265     @VisibleForTesting
266     @Nullable
getSession()267     AppSearchSession getSession() throws ExecutionException, InterruptedException {
268         return mAppSearchSessionFuture.get();
269     }
270 
271     @VisibleForTesting
setAppSearchSessionFutureForTesting( CompletableFuture<AppSearchSession> appSearchSessionFuture)272     void setAppSearchSessionFutureForTesting(
273             CompletableFuture<AppSearchSession> appSearchSessionFuture) {
274         mAppSearchSessionFuture = appSearchSessionFuture;
275     }
276 
277     /**
278      * Returns if the data is likely being wiped during initialization of this {@link
279      * AppSearchHelper}.
280      *
281      * <p>The Person corpus in AppSearch can be wiped during setSchema, and this indicates if it
282      * happens:
283      * <li>If the value is {@code false}, we are sure there is NO data loss.
284      * <li>If the value is {@code true}, it is very likely the data loss happens, or the whole
285      *     initialization fails and the data state is unknown. Callers need to query AppSearch to
286      *     confirm.
287      */
288     @NonNull
isDataLikelyWipedDuringInitAsync()289     public CompletableFuture<Boolean> isDataLikelyWipedDuringInitAsync() {
290         // Internally, it indicates whether the first setSchema with forceOverride false fails or
291         // not.
292         return mDataLikelyWipedDuringInitFuture;
293     }
294 
295     /**
296      * Indexes contacts into AppSearch
297      *
298      * @param contacts a collection of contacts. AppSearch batch put will be used to send the
299      *     documents over in one call. So the size of this collection can't be too big, otherwise
300      *     binder {@link android.os.TransactionTooLargeException} will be thrown.
301      * @param updateStats to hold the counters for the update.
302      * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates
303      *     should continue after encountering errors. When true, the returned future completes
304      *     normally even when contacts have failed to be added. AppSearchResult#RESULT_OUT_OF_SPACE
305      *     failures are an exception to this however and will still complete exceptionally.
306      */
307     @NonNull
indexContactsAsync( @onNull Collection<Person> contacts, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError)308     public CompletableFuture<Void> indexContactsAsync(
309             @NonNull Collection<Person> contacts,
310             @NonNull ContactsUpdateStats updateStats,
311             boolean shouldKeepUpdatingOnError) {
312         Objects.requireNonNull(contacts);
313         Objects.requireNonNull(updateStats);
314 
315         if (LogUtil.DEBUG) {
316             Log.v(TAG, "Indexing " + contacts.size() + " contacts into AppSearch");
317         }
318         PutDocumentsRequest request =
319                 new PutDocumentsRequest.Builder().addGenericDocuments(contacts).build();
320         return mAppSearchSessionFuture.thenCompose(
321                 appSearchSession -> {
322                     CompletableFuture<Void> future = new CompletableFuture<>();
323                     appSearchSession.put(
324                             request,
325                             mExecutor,
326                             new BatchResultCallback<>() {
327                                 @Override
328                                 public void onResult(AppSearchBatchResult<String, Void> result) {
329                                     int numDocsSucceeded = result.getSuccesses().size();
330                                     int numDocsFailed = result.getFailures().size();
331                                     updateStats.mContactsUpdateSucceededCount += numDocsSucceeded;
332                                     if (result.isSuccess()) {
333                                         if (LogUtil.DEBUG) {
334                                             Log.v(
335                                                     TAG,
336                                                     numDocsSucceeded
337                                                             + " documents successfully added in"
338                                                             + " AppSearch.");
339                                         }
340                                         future.complete(null);
341                                     } else {
342                                         Map<String, AppSearchResult<Void>> failures =
343                                                 result.getFailures();
344                                         AppSearchResult<Void> firstFailure = null;
345                                         for (AppSearchResult<Void> failure : failures.values()) {
346                                             int errorCode = failure.getResultCode();
347                                             if (firstFailure == null) {
348                                                 if (shouldKeepUpdatingOnError) {
349                                                     // Still complete exceptionally (and abort
350                                                     // further indexing) if
351                                                     // AppSearchResult#RESULT_OUT_OF_SPACE
352                                                     if (errorCode
353                                                             == AppSearchResult
354                                                                     .RESULT_OUT_OF_SPACE) {
355                                                         firstFailure = failure;
356                                                     }
357                                                 } else {
358                                                     firstFailure = failure;
359                                                 }
360                                             }
361                                             updateStats.mUpdateStatuses.add(errorCode);
362                                         }
363                                         if (firstFailure == null) {
364                                             future.complete(null);
365                                         } else {
366                                             Log.w(
367                                                     TAG,
368                                                     numDocsFailed
369                                                             + " documents failed to be added in"
370                                                             + " AppSearch.");
371                                             future.completeExceptionally(
372                                                     new AppSearchException(
373                                                             firstFailure.getResultCode(),
374                                                             firstFailure.getErrorMessage()));
375                                         }
376                                     }
377                                 }
378 
379                                 @Override
380                                 public void onSystemError(Throwable throwable) {
381                                     Log.e(TAG, "Failed to add contacts", throwable);
382                                     // Log a combined status code; ranges of the codes do not
383                                     // overlap 10100 + 0-99
384                                     updateStats.mUpdateStatuses.add(
385                                             ContactsUpdateStats.ERROR_CODE_APP_SEARCH_SYSTEM_ERROR
386                                                     + AppSearchResult.throwableToFailedResult(
387                                                                     throwable)
388                                                             .getResultCode());
389                                     if (shouldKeepUpdatingOnError) {
390                                         future.complete(null);
391                                     } else {
392                                         future.completeExceptionally(throwable);
393                                     }
394                                 }
395                             });
396                     return future;
397                 });
398     }
399 
400     /**
401      * Remove contacts from AppSearch
402      *
403      * @param ids a collection of contact ids. AppSearch batch remove will be used to send the ids
404      *     over in one call. So the size of this collection can't be too big, otherwise binder
405      *     {@link android.os.TransactionTooLargeException} will be thrown.
406      * @param updateStats to hold the counters for the update.
407      * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates
408      *     should continue after encountering errors. When enabled, the returned future completes
409      *     normally even when contacts have failed to be removed.
410      */
411     @NonNull
removeContactsByIdAsync( @onNull Collection<String> ids, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError)412     public CompletableFuture<Void> removeContactsByIdAsync(
413             @NonNull Collection<String> ids,
414             @NonNull ContactsUpdateStats updateStats,
415             boolean shouldKeepUpdatingOnError) {
416         Objects.requireNonNull(ids);
417         Objects.requireNonNull(updateStats);
418 
419         if (LogUtil.DEBUG) {
420             Log.v(TAG, "Removing " + ids.size() + " contacts from AppSearch");
421         }
422         RemoveByDocumentIdRequest request =
423                 new RemoveByDocumentIdRequest.Builder(NAMESPACE_NAME).addIds(ids).build();
424         return mAppSearchSessionFuture.thenCompose(
425                 appSearchSession -> {
426                     CompletableFuture<Void> future = new CompletableFuture<>();
427                     appSearchSession.remove(
428                             request,
429                             mExecutor,
430                             new BatchResultCallback<>() {
431                                 @Override
432                                 public void onResult(AppSearchBatchResult<String, Void> result) {
433                                     int numSuccesses = result.getSuccesses().size();
434                                     int numFailures = result.getFailures().size();
435                                     int numNotFound = 0;
436                                     updateStats.mContactsDeleteSucceededCount += numSuccesses;
437                                     if (result.isSuccess()) {
438                                         if (LogUtil.DEBUG) {
439                                             Log.v(
440                                                     TAG,
441                                                     numSuccesses
442                                                             + " documents successfully deleted from"
443                                                             + " AppSearch.");
444                                         }
445                                         future.complete(null);
446                                     } else {
447                                         AppSearchResult<Void> firstFailure = null;
448                                         for (AppSearchResult<Void> failedResult :
449                                                 result.getFailures().values()) {
450                                             // Ignore failures if the error code is
451                                             // AppSearchResult#RESULT_NOT_FOUND
452                                             // or if shouldKeepUpdatingOnError is true
453                                             int errorCode = failedResult.getResultCode();
454                                             if (errorCode == AppSearchResult.RESULT_NOT_FOUND) {
455                                                 numNotFound++;
456                                             } else if (firstFailure == null
457                                                     && !shouldKeepUpdatingOnError) {
458                                                 firstFailure = failedResult;
459                                             }
460                                             updateStats.mDeleteStatuses.add(errorCode);
461                                         }
462                                         updateStats.mContactsDeleteNotFoundCount += numNotFound;
463                                         if (firstFailure == null) {
464                                             future.complete(null);
465                                         } else {
466                                             Log.w(
467                                                     TAG,
468                                                     "Failed to delete "
469                                                             + numFailures
470                                                             + " contacts from AppSearch");
471                                             future.completeExceptionally(
472                                                     new AppSearchException(
473                                                             firstFailure.getResultCode(),
474                                                             firstFailure.getErrorMessage()));
475                                         }
476                                     }
477                                 }
478 
479                                 @Override
480                                 public void onSystemError(Throwable throwable) {
481                                     Log.e(TAG, "Failed to delete contacts", throwable);
482                                     // Log a combined status code; ranges of the codes do not
483                                     // overlap 10100 + 0-99
484                                     updateStats.mDeleteStatuses.add(
485                                             ContactsUpdateStats.ERROR_CODE_APP_SEARCH_SYSTEM_ERROR
486                                                     + AppSearchResult.throwableToFailedResult(
487                                                                     throwable)
488                                                             .getResultCode());
489                                     if (shouldKeepUpdatingOnError) {
490                                         future.complete(null);
491                                     } else {
492                                         future.completeExceptionally(throwable);
493                                     }
494                                 }
495                             });
496                     return future;
497                 });
498     }
499 
500     /**
501      * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates
502      *     should continue after encountering errors. When enabled, the returned future completes
503      *     normally even when contacts could not be retrieved.
504      */
505     @NonNull
506     private CompletableFuture<AppSearchBatchResult> getContactsByIdAsync(
507             @NonNull GetByDocumentIdRequest request,
508             boolean shouldKeepUpdatingOnError,
509             @NonNull ContactsUpdateStats updateStats) {
510         Objects.requireNonNull(request);
511         return mAppSearchSessionFuture.thenCompose(
512                 appSearchSession -> {
513                     CompletableFuture<AppSearchBatchResult> future = new CompletableFuture<>();
514                     appSearchSession.getByDocumentId(
515                             request,
516                             mExecutor,
517                             new BatchResultCallback<>() {
518                                 @Override
519                                 public void onResult(
520                                         AppSearchBatchResult<String, GenericDocument> result) {
521                                     future.complete(result);
522                                 }
523 
524                                 @Override
525                                 public void onSystemError(Throwable throwable) {
526                                     Log.e(TAG, "Failed to get contacts", throwable);
527                                     // Log a combined status code; ranges of the codes do not
528                                     // overlap
529                                     // 10100 + 0-99
530                                     updateStats.mUpdateStatuses.add(
531                                             ContactsUpdateStats.ERROR_CODE_APP_SEARCH_SYSTEM_ERROR
532                                                     + AppSearchResult.throwableToFailedResult(
533                                                                     throwable)
534                                                             .getResultCode());
535                                     if (shouldKeepUpdatingOnError) {
536                                         future.complete(
537                                                 new AppSearchBatchResult.Builder<>().build());
538                                     } else {
539                                         future.completeExceptionally(throwable);
540                                     }
541                                 }
542                             });
543                     return future;
544                 });
545     }
546 
547     /**
548      * Returns IDs of all contacts indexed in AppSearch
549      *
550      * <p>Issues an empty query with an empty projection and pages through all results, collecting
551      * the document IDs to return to the caller.
552      */
553     @NonNull
554     public CompletableFuture<List<String>> getAllContactIdsAsync() {
555         return mAppSearchSessionFuture.thenCompose(
556                 appSearchSession -> {
557                     SearchSpec allDocumentIdsSpec =
558                             new SearchSpec.Builder()
559                                     .addFilterNamespaces(NAMESPACE_NAME)
560                                     .addFilterSchemas(Person.SCHEMA_TYPE)
561                                     .addProjection(
562                                             Person.SCHEMA_TYPE,
563                                             /* propertyPaths= */ Collections.emptyList())
564                                     .setResultCountPerPage(GET_CONTACT_IDS_PAGE_SIZE)
565                                     .build();
566                     SearchResults results =
567                             appSearchSession.search(/* queryExpression= */ "", allDocumentIdsSpec);
568                     List<String> allContactIds = new ArrayList<>();
569                     return collectDocumentIdsFromAllPagesAsync(results, allContactIds)
570                             .thenCompose(
571                                     unused -> {
572                                         results.close();
573                                         return CompletableFuture.supplyAsync(() -> allContactIds);
574                                     });
575                 });
576     }
577 
578     /**
579      * Gets {@link GenericDocument}s with only fingerprints projected for the requested contact ids.
580      *
581      * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates
582      *     should continue after encountering errors.
583      * @return A list containing the corresponding {@link GenericDocument} for the requested contact
584      *     ids in order. The entry is {@code null} if the requested contact id is not found in
585      *     AppSearch.
586      */
587     @NonNull
588     public CompletableFuture<List<GenericDocument>> getContactsWithFingerprintsAsync(
589             @NonNull List<String> ids,
590             boolean shouldKeepUpdatingOnError,
591             @NonNull ContactsUpdateStats updateStats) {
592         Objects.requireNonNull(ids);
593         GetByDocumentIdRequest request =
594                 new GetByDocumentIdRequest.Builder(AppSearchHelper.NAMESPACE_NAME)
595                         .addProjection(
596                                 Person.SCHEMA_TYPE,
597                                 Collections.singletonList(Person.PERSON_PROPERTY_FINGERPRINT))
598                         .addIds(ids)
599                         .build();
600         return getContactsByIdAsync(request, shouldKeepUpdatingOnError, updateStats)
601                 .thenCompose(
602                         appSearchBatchResult -> {
603                             Map<String, GenericDocument> contactsExistInAppSearch =
604                                     appSearchBatchResult.getSuccesses();
605                             List<GenericDocument> docsWithFingerprints =
606                                     new ArrayList<>(ids.size());
607                             for (int i = 0; i < ids.size(); ++i) {
608                                 docsWithFingerprints.add(contactsExistInAppSearch.get(ids.get(i)));
609                             }
610                             return CompletableFuture.completedFuture(docsWithFingerprints);
611                         });
612     }
613 
614     /**
615      * Recursively pages through all search results and collects document IDs into given list.
616      *
617      * @param results Iterator for paging through the search results.
618      * @param contactIds List for collecting and returning document IDs.
619      * @return A future indicating if more results might be available.
620      */
621     private CompletableFuture<Boolean> collectDocumentIdsFromAllPagesAsync(
622             @NonNull SearchResults results, @NonNull List<String> contactIds) {
623         Objects.requireNonNull(results);
624         Objects.requireNonNull(contactIds);
625 
626         CompletableFuture<Boolean> future = new CompletableFuture<>();
627         results.getNextPage(
628                 mExecutor,
629                 callback -> {
630                     if (!callback.isSuccess()) {
631                         future.completeExceptionally(
632                                 new AppSearchException(
633                                         callback.getResultCode(), callback.getErrorMessage()));
634                         return;
635                     }
636                     List<SearchResult> resultList = callback.getResultValue();
637                     for (int i = 0; i < resultList.size(); i++) {
638                         SearchResult result = resultList.get(i);
639                         contactIds.add(result.getGenericDocument().getId());
640                     }
641                     future.complete(!resultList.isEmpty());
642                 });
643         return future.thenCompose(
644                 moreResults -> {
645                     // Recurse if there might be more results to page through.
646                     if (moreResults) {
647                         return collectDocumentIdsFromAllPagesAsync(results, contactIds);
648                     }
649                     return CompletableFuture.supplyAsync(() -> false);
650                 });
651     }
652 }
653