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