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