1 /* 2 * Copyright (C) 2021 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.app.appsearch.GenericDocument; 22 import android.content.Context; 23 import android.database.Cursor; 24 import android.net.Uri; 25 import android.provider.ContactsContract; 26 import android.text.TextUtils; 27 import android.util.ArraySet; 28 import android.util.Log; 29 30 import com.android.internal.annotations.VisibleForTesting; 31 import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; 32 33 import java.util.ArrayList; 34 import java.util.Arrays; 35 import java.util.Collection; 36 import java.util.List; 37 import java.util.Objects; 38 import java.util.Set; 39 import java.util.concurrent.CompletableFuture; 40 41 /** 42 * The class to sync the data from CP2 to AppSearch. 43 * 44 * <p>This class is NOT thread-safe. 45 * 46 * @hide 47 */ 48 public final class ContactsIndexerImpl { 49 static final String TAG = "ContactsIndexerImpl"; 50 51 // TODO(b/203605504) have and read those flags in/from AppSearchConfig. 52 static final int NUM_CONTACTS_PER_BATCH_FOR_CP2 = 100; 53 static final int NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 50; 54 static final int NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 500; 55 // Common columns needed for all kinds of mime types 56 static final String[] COMMON_NEEDED_COLUMNS = { 57 ContactsContract.Data.CONTACT_ID, 58 ContactsContract.Data.LOOKUP_KEY, 59 ContactsContract.Data.PHOTO_THUMBNAIL_URI, 60 ContactsContract.Data.DISPLAY_NAME_PRIMARY, 61 ContactsContract.Data.PHONETIC_NAME, 62 ContactsContract.Data.RAW_CONTACT_ID, 63 ContactsContract.Data.STARRED, 64 ContactsContract.Data.CONTACT_LAST_UPDATED_TIMESTAMP 65 }; 66 // The order for the results returned from CP2. 67 static final String ORDER_BY = 68 ContactsContract.Data.CONTACT_ID 69 // MUST sort by CONTACT_ID first for our iteration to work 70 + "," 71 // Whether this is the primary entry of its kind for the aggregate 72 // contact it belongs to. 73 + ContactsContract.Data.IS_SUPER_PRIMARY 74 + " DESC" 75 // Then rank by importance. 76 + "," 77 // Whether this is the primary entry of its kind for the raw contact it 78 // belongs to. 79 + ContactsContract.Data.IS_PRIMARY 80 + " DESC" 81 + "," 82 + ContactsContract.Data.RAW_CONTACT_ID; 83 84 private final Context mContext; 85 private final ContactDataHandler mContactDataHandler; 86 private final String[] mProjection; 87 private final AppSearchHelper mAppSearchHelper; 88 ContactsIndexerImpl(@onNull Context context, @NonNull AppSearchHelper appSearchHelper)89 public ContactsIndexerImpl(@NonNull Context context, @NonNull AppSearchHelper appSearchHelper) { 90 mContext = Objects.requireNonNull(context); 91 mAppSearchHelper = Objects.requireNonNull(appSearchHelper); 92 mContactDataHandler = new ContactDataHandler(mContext.getResources()); 93 94 Set<String> neededColumns = new ArraySet<>(Arrays.asList(COMMON_NEEDED_COLUMNS)); 95 neededColumns.addAll(mContactDataHandler.getNeededColumns()); 96 mProjection = neededColumns.toArray(new String[0]); 97 } 98 99 /** 100 * Syncs contacts in Person corpus in AppSearch, with the ones from CP2. 101 * 102 * <p>It deletes removed contacts, inserts newly-added ones, and updates existing ones in the 103 * Person corpus in AppSearch. 104 * 105 * @param wantedContactIds ids for contacts to be updated. 106 * @param unWantedIds ids for contacts to be deleted. 107 * @param updateStats to hold the counters for the update. 108 * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates 109 * should continue after encountering errors. 110 */ updatePersonCorpusAsync( @onNull List<String> wantedContactIds, @NonNull List<String> unWantedIds, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError)111 public CompletableFuture<Void> updatePersonCorpusAsync( 112 @NonNull List<String> wantedContactIds, 113 @NonNull List<String> unWantedIds, 114 @NonNull ContactsUpdateStats updateStats, 115 boolean shouldKeepUpdatingOnError) { 116 Objects.requireNonNull(wantedContactIds); 117 Objects.requireNonNull(unWantedIds); 118 Objects.requireNonNull(updateStats); 119 120 return batchRemoveContactsAsync(unWantedIds, updateStats, shouldKeepUpdatingOnError) 121 .exceptionally( 122 t -> { 123 // Since we update the timestamps no matter the update succeeds or 124 // fails, we can 125 // always try to do the indexing. Updating lastDeltaUpdateTimestamps 126 // without doing 127 // indexing seems odd. 128 // So catch the exception here for deletion, and we can keep doing the 129 // indexing. 130 Log.w(TAG, "Error occurred during batch delete", t); 131 return null; 132 }) 133 .thenCompose( 134 x -> 135 batchUpdateContactsAsync( 136 wantedContactIds, updateStats, shouldKeepUpdatingOnError)); 137 } 138 139 /** 140 * Removes contacts in batches. 141 * 142 * @param updateStats to hold the counters for the remove. 143 * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates 144 * should continue after encountering errors. 145 */ 146 @VisibleForTesting batchRemoveContactsAsync( @onNull final List<String> unWantedIds, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError)147 CompletableFuture<Void> batchRemoveContactsAsync( 148 @NonNull final List<String> unWantedIds, 149 @NonNull ContactsUpdateStats updateStats, 150 boolean shouldKeepUpdatingOnError) { 151 CompletableFuture<Void> batchRemoveFuture = CompletableFuture.completedFuture(null); 152 int startIndex = 0; 153 int unWantedSize = unWantedIds.size(); 154 updateStats.mTotalContactsToBeDeleted += unWantedSize; 155 while (startIndex < unWantedSize) { 156 int endIndex = 157 Math.min( 158 startIndex + NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH, 159 unWantedSize); 160 Collection<String> currentContactIds = unWantedIds.subList(startIndex, endIndex); 161 // If any removeContactsByIdAsync in the future-chain completes exceptionally, all 162 // futures following it will not run and will instead complete exceptionally. However, 163 // when shouldKeepUpdatingOnError is true, removeContactsByIdAsync avoids completing 164 // exceptionally. 165 batchRemoveFuture = 166 batchRemoveFuture.thenCompose( 167 x -> 168 mAppSearchHelper.removeContactsByIdAsync( 169 currentContactIds, 170 updateStats, 171 shouldKeepUpdatingOnError)); 172 startIndex = endIndex; 173 } 174 return batchRemoveFuture; 175 } 176 177 /** 178 * Batch inserts newly-added contacts, and updates recently-updated contacts. 179 * 180 * @param updateStats to hold the counters for the update. 181 * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates 182 * should continue after encountering errors. When enabled and we fail to query CP@ for a 183 * batch of contacts, we continue onto the next batch instead of stopping. 184 */ batchUpdateContactsAsync( @onNull final List<String> wantedContactIds, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError)185 CompletableFuture<Void> batchUpdateContactsAsync( 186 @NonNull final List<String> wantedContactIds, 187 @NonNull ContactsUpdateStats updateStats, 188 boolean shouldKeepUpdatingOnError) { 189 int startIndex = 0; 190 int wantedIdListSize = wantedContactIds.size(); 191 CompletableFuture<Void> future = CompletableFuture.completedFuture(null); 192 updateStats.mTotalContactsToBeUpdated += wantedIdListSize; 193 194 // 195 // Batch reading the contacts from CP2, and index the created documents to AppSearch 196 // 197 // Moved contactsBatcher from a member variable to a local variable. Previously, two 198 // simultaneous updates would use the same ContactsBatcher, leading to updates sometimes 199 // indexing each other's contacts and messing up the metrics/counts for the number of 200 // succeeded/skipped contacts. 201 ContactsBatcher contactsBatcher = 202 new ContactsBatcher( 203 mAppSearchHelper, 204 NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH, 205 shouldKeepUpdatingOnError); 206 while (startIndex < wantedIdListSize) { 207 int endIndex = Math.min(startIndex + NUM_CONTACTS_PER_BATCH_FOR_CP2, wantedIdListSize); 208 Collection<String> currentContactIds = wantedContactIds.subList(startIndex, endIndex); 209 // Read NUM_CONTACTS_PER_BATCH contacts every time from CP2. 210 String selection = 211 ContactsContract.Data.CONTACT_ID 212 + " IN (" 213 + TextUtils.join(/* delimiter= */ ",", currentContactIds) 214 + ")"; 215 startIndex = endIndex; 216 try { 217 // For our iteration work, we must sort the result by contact_id first. 218 Cursor cursor = 219 mContext.getContentResolver() 220 .query( 221 ContactsContract.Data.CONTENT_URI, 222 mProjection, 223 selection, 224 /* selectionArgs= */ null, 225 ORDER_BY); 226 if (cursor == null) { 227 updateStats.mUpdateStatuses.add(ContactsUpdateStats.ERROR_CODE_CP2_NULL_CURSOR); 228 Log.w(TAG, "Cursor was returned as null while querying CP2."); 229 if (!shouldKeepUpdatingOnError) { 230 return future.thenCompose( 231 x -> 232 CompletableFuture.failedFuture( 233 new IllegalStateException( 234 "Cursor was returned as null while querying" 235 + " CP2."))); 236 } 237 } else { 238 // If any indexContactsFromCursorAsync in the future-chain completes 239 // exceptionally, all futures following it will not run and will instead 240 // complete exceptionally. However, when shouldKeepUpdatingOnError is true, 241 // indexContactsFromCursorAsync avoids completing exceptionally except for 242 // AppSearchResult#RESULT_OUT_OF_SPACE. 243 future = 244 future.thenCompose( 245 x -> 246 indexContactsFromCursorAsync( 247 cursor, 248 updateStats, 249 contactsBatcher, 250 shouldKeepUpdatingOnError)) 251 .whenComplete( 252 (x, t) -> { 253 // ensure the cursor is closed even when the 254 // future-chain fails 255 cursor.close(); 256 }); 257 } 258 } catch (RuntimeException e) { 259 // The ContactsProvider sometimes propagates RuntimeExceptions to us 260 // for when their database fails to open. Behave as if there was no 261 // ContactsProvider, and flag that we were not successful. 262 Log.e(TAG, "ContentResolver.query threw an exception.", e); 263 updateStats.mUpdateStatuses.add( 264 ContactsUpdateStats.ERROR_CODE_CP2_RUNTIME_EXCEPTION); 265 if (!shouldKeepUpdatingOnError) { 266 return future.thenCompose(x -> CompletableFuture.failedFuture(e)); 267 } 268 } 269 } 270 271 return future; 272 } 273 274 /** 275 * Reads through cursor, converts the contacts to AppSearch documents, and indexes the documents 276 * into AppSearch. 277 * 278 * @param cursor pointing to the contacts read from CP2. 279 * @param updateStats to hold the counters for the update. 280 * @param contactsBatcher the batcher that indexes the contacts for this update. 281 * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates 282 * should continue after encountering errors. When enabled and an exception is thrown, stops 283 * further indexing and flushes the current batch of contacts but does not return a failed 284 * future. 285 */ indexContactsFromCursorAsync( @onNull Cursor cursor, @NonNull ContactsUpdateStats updateStats, @NonNull ContactsBatcher contactsBatcher, boolean shouldKeepUpdatingOnError)286 private CompletableFuture<Void> indexContactsFromCursorAsync( 287 @NonNull Cursor cursor, 288 @NonNull ContactsUpdateStats updateStats, 289 @NonNull ContactsBatcher contactsBatcher, 290 boolean shouldKeepUpdatingOnError) { 291 Objects.requireNonNull(cursor); 292 try { 293 int contactIdIndex = cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID); 294 int lookupKeyIndex = cursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); 295 int thumbnailUriIndex = 296 cursor.getColumnIndex(ContactsContract.Data.PHOTO_THUMBNAIL_URI); 297 int displayNameIndex = 298 cursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME_PRIMARY); 299 int starredIndex = cursor.getColumnIndex(ContactsContract.Data.STARRED); 300 int phoneticNameIndex = cursor.getColumnIndex(ContactsContract.Data.PHONETIC_NAME); 301 long currentContactId = -1; 302 Person.Builder personBuilder; 303 PersonBuilderHelper personBuilderHelper = null; 304 while (cursor.moveToNext()) { 305 long contactId = cursor.getLong(contactIdIndex); 306 if (contactId != currentContactId) { 307 // Either it is the very first row (currentContactId = -1), or a row for a new 308 // new contact_id. 309 if (currentContactId != -1) { 310 // It is the first row for a new contact_id. We can wrap up the 311 // ContactData for the previous contact_id. 312 contactsBatcher.add(personBuilderHelper, updateStats); 313 } 314 // New set of builder and builderHelper for the new contact. 315 currentContactId = contactId; 316 String displayName = getStringFromCursor(cursor, displayNameIndex); 317 if (displayName == null) { 318 // For now, we don't abandon the data if displayName is missing. In the 319 // schema the name is required for building a person. It might look bad 320 // if there are contacts in CP2, but not in AppSearch, even though the 321 // name is missing. 322 displayName = ""; 323 } 324 personBuilder = 325 new Person.Builder( 326 AppSearchHelper.NAMESPACE_NAME, 327 String.valueOf(contactId), 328 displayName); 329 String imageUri = getStringFromCursor(cursor, thumbnailUriIndex); 330 String lookupKey = getStringFromCursor(cursor, lookupKeyIndex); 331 boolean starred = starredIndex != -1 && cursor.getInt(starredIndex) != 0; 332 Uri lookupUri = 333 lookupKey != null 334 ? ContactsContract.Contacts.getLookupUri( 335 currentContactId, lookupKey) 336 : null; 337 personBuilder.setIsImportant(starred); 338 if (lookupUri != null) { 339 personBuilder.setExternalUri(lookupUri); 340 } 341 if (imageUri != null) { 342 personBuilder.setImageUri(Uri.parse(imageUri)); 343 } 344 String phoneticName = getStringFromCursor(cursor, phoneticNameIndex); 345 if (phoneticName != null) { 346 personBuilder.addAdditionalName(Person.TYPE_PHONETIC_NAME, phoneticName); 347 } 348 // Always use current system timestamp first. If that contact already exists 349 // in AppSearch, the creationTimestamp for this doc will be reset with the 350 // original value stored in AppSearch during performDiffAsync. 351 personBuilderHelper = 352 new PersonBuilderHelper(String.valueOf(contactId), personBuilder) 353 .setCreationTimestampMillis(System.currentTimeMillis()); 354 } 355 if (personBuilderHelper != null) { 356 mContactDataHandler.convertCursorToPerson(cursor, personBuilderHelper); 357 } 358 } 359 360 if (cursor.isAfterLast() && currentContactId != -1) { 361 // The ContactData for the last contact has not been handled yet. So we need to 362 // build and index it. 363 if (personBuilderHelper != null) { 364 contactsBatcher.add(personBuilderHelper, updateStats); 365 } 366 } 367 } catch (RuntimeException e) { 368 updateStats.mUpdateStatuses.add(ContactsUpdateStats.ERROR_CODE_CP2_RUNTIME_EXCEPTION); 369 // TODO(b/203605504) see if we could catch more specific exceptions/errors. 370 Log.e(TAG, "Error while indexing documents from the cursor", e); 371 if (!shouldKeepUpdatingOnError) { 372 return contactsBatcher 373 .flushAsync(updateStats) 374 .thenCompose(x -> CompletableFuture.failedFuture(e)); 375 } 376 } 377 378 // finally force flush all the remaining batched contacts. 379 return contactsBatcher.flushAsync(updateStats); 380 } 381 382 /** 383 * Helper method to read the value from a {@link Cursor} for {@code index}. 384 * 385 * @return A string value, or {@code null} if the value is missing, or {@code index} is -1. 386 */ 387 @Nullable getStringFromCursor(@onNull Cursor cursor, int index)388 private static String getStringFromCursor(@NonNull Cursor cursor, int index) { 389 Objects.requireNonNull(cursor); 390 if (index != -1) { 391 return cursor.getString(index); 392 } 393 return null; 394 } 395 396 /** 397 * Class for helping batching the {@link Person} to be indexed. 398 * 399 * <p>This class is thread unsafe and all its methods must be called from the same thread. 400 */ 401 static class ContactsBatcher { 402 // 1st layer of batching. Contact builders are pushed into this list first before comparing 403 // fingerprints. 404 private List<PersonBuilderHelper> mPendingDiffContactBuilders; 405 // 2nd layer of batching. We do the filtering based on the fingerprint saved in the 406 // AppSearch documents, and save the filtered contacts into this mPendingIndexContacts. 407 private final List<Person> mPendingIndexContacts; 408 409 /** 410 * Batch size for both {@link #mPendingDiffContactBuilders} and {@link 411 * #mPendingIndexContacts}. It is strictly followed by {@link #mPendingDiffContactBuilders}. 412 * But for {@link #mPendingIndexContacts}, when we merge the former set into {@link 413 * #mPendingIndexContacts}, it could exceed this limit. At maximum it could hold 2 * {@link 414 * #mBatchSize} contacts before cleared. 415 */ 416 private final int mBatchSize; 417 418 private final AppSearchHelper mAppSearchHelper; 419 private final boolean mShouldKeepUpdatingOnError; 420 421 private CompletableFuture<Void> mIndexContactsCompositeFuture = 422 CompletableFuture.completedFuture(null); 423 ContactsBatcher( @onNull AppSearchHelper appSearchHelper, int batchSize, boolean shouldKeepUpdatingOnError)424 ContactsBatcher( 425 @NonNull AppSearchHelper appSearchHelper, 426 int batchSize, 427 boolean shouldKeepUpdatingOnError) { 428 mAppSearchHelper = Objects.requireNonNull(appSearchHelper); 429 mBatchSize = batchSize; 430 mShouldKeepUpdatingOnError = shouldKeepUpdatingOnError; 431 mPendingDiffContactBuilders = new ArrayList<>(mBatchSize); 432 mPendingIndexContacts = new ArrayList<>(mBatchSize); 433 } 434 getCompositeFuture()435 CompletableFuture<Void> getCompositeFuture() { 436 return mIndexContactsCompositeFuture; 437 } 438 439 @VisibleForTesting getPendingDiffContactsCount()440 int getPendingDiffContactsCount() { 441 return mPendingDiffContactBuilders.size(); 442 } 443 444 @VisibleForTesting getPendingIndexContactsCount()445 int getPendingIndexContactsCount() { 446 return mPendingIndexContacts.size(); 447 } 448 add( @onNull PersonBuilderHelper builderHelper, @NonNull ContactsUpdateStats updateStats)449 public void add( 450 @NonNull PersonBuilderHelper builderHelper, 451 @NonNull ContactsUpdateStats updateStats) { 452 Objects.requireNonNull(builderHelper); 453 mPendingDiffContactBuilders.add(builderHelper); 454 if (mPendingDiffContactBuilders.size() >= mBatchSize) { 455 // Pass in current mPendingDiffContactBuilders to performDiffAsync and create a new 456 // list for batching 457 List<PersonBuilderHelper> pendingDiffContactBuilders = mPendingDiffContactBuilders; 458 mPendingDiffContactBuilders = new ArrayList<>(mBatchSize); 459 mIndexContactsCompositeFuture = 460 mIndexContactsCompositeFuture 461 .thenCompose( 462 x -> 463 performDiffAsync( 464 pendingDiffContactBuilders, updateStats)) 465 .thenCompose( 466 y -> { 467 if (mPendingIndexContacts.size() >= mBatchSize) { 468 return flushPendingIndexAsync(updateStats); 469 } 470 return CompletableFuture.completedFuture(null); 471 }); 472 } 473 } 474 flushAsync(@onNull ContactsUpdateStats updateStats)475 public CompletableFuture<Void> flushAsync(@NonNull ContactsUpdateStats updateStats) { 476 if (!mPendingDiffContactBuilders.isEmpty() || !mPendingIndexContacts.isEmpty()) { 477 // Pass in current mPendingDiffContactBuilders to performDiffAsync and create a new 478 // list for batching 479 List<PersonBuilderHelper> pendingDiffContactBuilders = mPendingDiffContactBuilders; 480 mPendingDiffContactBuilders = new ArrayList<>(mBatchSize); 481 mIndexContactsCompositeFuture = 482 mIndexContactsCompositeFuture 483 .thenCompose( 484 x -> 485 performDiffAsync( 486 pendingDiffContactBuilders, updateStats)) 487 .thenCompose(y -> flushPendingIndexAsync(updateStats)); 488 } 489 490 CompletableFuture<Void> flushFuture = mIndexContactsCompositeFuture; 491 mIndexContactsCompositeFuture = CompletableFuture.completedFuture(null); 492 return flushFuture; 493 } 494 495 /** 496 * Flushes batched contacts into {@link #mPendingIndexContacts}. 497 * 498 * @param pendingDiffContactBuilders the batched contacts to index 499 */ performDiffAsync( @onNull List<PersonBuilderHelper> pendingDiffContactBuilders, @NonNull ContactsUpdateStats updateStats)500 private CompletableFuture<Void> performDiffAsync( 501 @NonNull List<PersonBuilderHelper> pendingDiffContactBuilders, 502 @NonNull ContactsUpdateStats updateStats) { 503 List<String> ids = new ArrayList<>(pendingDiffContactBuilders.size()); 504 for (int i = 0; i < pendingDiffContactBuilders.size(); ++i) { 505 ids.add(pendingDiffContactBuilders.get(i).getId()); 506 } 507 // getContactsWithFingerPrintsAsync may return no fingerprints if it would normally 508 // completely exceptionally and mShouldAppSearchHelperCompleteNormallyOnError is true. 509 // In this case, we may unnecessarily update some contacts in the following step, but 510 // some unnecessary updates is better than no updates and should not cause a significant 511 // impact on performance. 512 return mAppSearchHelper 513 .getContactsWithFingerprintsAsync(ids, mShouldKeepUpdatingOnError, updateStats) 514 .thenCompose( 515 contactsWithFingerprints -> { 516 List<Person> contactsToBeIndexed = 517 new ArrayList<>(pendingDiffContactBuilders.size()); 518 // Before indexing a contact into AppSearch, we will check if the 519 // contact with same id exists, and whether the fingerprint has 520 // changed. If fingerprint has not been changed for the same 521 // contact, we won't index it. 522 for (int i = 0; i < pendingDiffContactBuilders.size(); ++i) { 523 PersonBuilderHelper builderHelper = 524 pendingDiffContactBuilders.get(i); 525 GenericDocument doc = contactsWithFingerprints.get(i); 526 byte[] oldFingerprint = 527 doc != null 528 ? doc.getPropertyBytes( 529 Person.PERSON_PROPERTY_FINGERPRINT) 530 : null; 531 long docCreationTimestampMillis = 532 doc != null ? doc.getCreationTimestampMillis() : -1; 533 if (oldFingerprint != null) { 534 // We already have this contact in AppSearch. Reset the 535 // creationTimestamp here with the original one. 536 builderHelper.setCreationTimestampMillis( 537 docCreationTimestampMillis); 538 Person person = builderHelper.buildPerson(); 539 if (!Arrays.equals( 540 person.getFingerprint(), oldFingerprint)) { 541 contactsToBeIndexed.add(person); 542 } else { 543 // Fingerprint is same. So this update is skipped. 544 ++updateStats.mContactsUpdateSkippedCount; 545 } 546 } else { 547 // New contact. 548 ++updateStats.mNewContactsToBeUpdated; 549 contactsToBeIndexed.add(builderHelper.buildPerson()); 550 } 551 } 552 mPendingIndexContacts.addAll(contactsToBeIndexed); 553 return CompletableFuture.completedFuture(null); 554 }); 555 } 556 557 /** Flushes the contacts batched in {@link #mPendingIndexContacts} to AppSearch. */ flushPendingIndexAsync( @onNull ContactsUpdateStats updateStats)558 private CompletableFuture<Void> flushPendingIndexAsync( 559 @NonNull ContactsUpdateStats updateStats) { 560 if (mPendingIndexContacts.size() > 0) { 561 CompletableFuture<Void> future = 562 mAppSearchHelper.indexContactsAsync( 563 mPendingIndexContacts, updateStats, mShouldKeepUpdatingOnError); 564 mPendingIndexContacts.clear(); 565 return future; 566 } 567 return CompletableFuture.completedFuture(null); 568 } 569 } 570 } 571