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