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