/* * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.server.appsearch.contactsindexer; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.appsearch.GenericDocument; import android.content.Context; import android.database.Cursor; import android.net.Uri; import android.provider.ContactsContract; import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import com.android.server.appsearch.contactsindexer.appsearchtypes.Person; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; /** * The class to sync the data from CP2 to AppSearch. * *

This class is NOT thread-safe. * * @hide */ public final class ContactsIndexerImpl { static final String TAG = "ContactsIndexerImpl"; // TODO(b/203605504) have and read those flags in/from AppSearchConfig. static final int NUM_CONTACTS_PER_BATCH_FOR_CP2 = 100; static final int NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 50; static final int NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH = 500; // Common columns needed for all kinds of mime types static final String[] COMMON_NEEDED_COLUMNS = { ContactsContract.Data.CONTACT_ID, ContactsContract.Data.LOOKUP_KEY, ContactsContract.Data.PHOTO_THUMBNAIL_URI, ContactsContract.Data.DISPLAY_NAME_PRIMARY, ContactsContract.Data.PHONETIC_NAME, ContactsContract.Data.RAW_CONTACT_ID, ContactsContract.Data.STARRED, ContactsContract.Data.CONTACT_LAST_UPDATED_TIMESTAMP }; // The order for the results returned from CP2. static final String ORDER_BY = ContactsContract.Data.CONTACT_ID // MUST sort by CONTACT_ID first for our iteration to work + "," // Whether this is the primary entry of its kind for the aggregate // contact it belongs to. + ContactsContract.Data.IS_SUPER_PRIMARY + " DESC" // Then rank by importance. + "," // Whether this is the primary entry of its kind for the raw contact it // belongs to. + ContactsContract.Data.IS_PRIMARY + " DESC" + "," + ContactsContract.Data.RAW_CONTACT_ID; private final Context mContext; private final ContactDataHandler mContactDataHandler; private final String[] mProjection; private final AppSearchHelper mAppSearchHelper; public ContactsIndexerImpl(@NonNull Context context, @NonNull AppSearchHelper appSearchHelper) { mContext = Objects.requireNonNull(context); mAppSearchHelper = Objects.requireNonNull(appSearchHelper); mContactDataHandler = new ContactDataHandler(mContext.getResources()); Set neededColumns = new ArraySet<>(Arrays.asList(COMMON_NEEDED_COLUMNS)); neededColumns.addAll(mContactDataHandler.getNeededColumns()); mProjection = neededColumns.toArray(new String[0]); } /** * Syncs contacts in Person corpus in AppSearch, with the ones from CP2. * *

It deletes removed contacts, inserts newly-added ones, and updates existing ones in the * Person corpus in AppSearch. * * @param wantedContactIds ids for contacts to be updated. * @param unWantedIds ids for contacts to be deleted. * @param updateStats to hold the counters for the update. * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. */ public CompletableFuture updatePersonCorpusAsync( @NonNull List wantedContactIds, @NonNull List unWantedIds, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError) { Objects.requireNonNull(wantedContactIds); Objects.requireNonNull(unWantedIds); Objects.requireNonNull(updateStats); return batchRemoveContactsAsync(unWantedIds, updateStats, shouldKeepUpdatingOnError) .exceptionally( t -> { // Since we update the timestamps no matter the update succeeds or // fails, we can // always try to do the indexing. Updating lastDeltaUpdateTimestamps // without doing // indexing seems odd. // So catch the exception here for deletion, and we can keep doing the // indexing. Log.w(TAG, "Error occurred during batch delete", t); return null; }) .thenCompose( x -> batchUpdateContactsAsync( wantedContactIds, updateStats, shouldKeepUpdatingOnError)); } /** * Removes contacts in batches. * * @param updateStats to hold the counters for the remove. * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. */ @VisibleForTesting CompletableFuture batchRemoveContactsAsync( @NonNull final List unWantedIds, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError) { CompletableFuture batchRemoveFuture = CompletableFuture.completedFuture(null); int startIndex = 0; int unWantedSize = unWantedIds.size(); updateStats.mTotalContactsToBeDeleted += unWantedSize; while (startIndex < unWantedSize) { int endIndex = Math.min( startIndex + NUM_DELETED_CONTACTS_PER_BATCH_FOR_APPSEARCH, unWantedSize); Collection currentContactIds = unWantedIds.subList(startIndex, endIndex); // If any removeContactsByIdAsync in the future-chain completes exceptionally, all // futures following it will not run and will instead complete exceptionally. However, // when shouldKeepUpdatingOnError is true, removeContactsByIdAsync avoids completing // exceptionally. batchRemoveFuture = batchRemoveFuture.thenCompose( x -> mAppSearchHelper.removeContactsByIdAsync( currentContactIds, updateStats, shouldKeepUpdatingOnError)); startIndex = endIndex; } return batchRemoveFuture; } /** * Batch inserts newly-added contacts, and updates recently-updated contacts. * * @param updateStats to hold the counters for the update. * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. When enabled and we fail to query CP@ for a * batch of contacts, we continue onto the next batch instead of stopping. */ CompletableFuture batchUpdateContactsAsync( @NonNull final List wantedContactIds, @NonNull ContactsUpdateStats updateStats, boolean shouldKeepUpdatingOnError) { int startIndex = 0; int wantedIdListSize = wantedContactIds.size(); CompletableFuture future = CompletableFuture.completedFuture(null); updateStats.mTotalContactsToBeUpdated += wantedIdListSize; // // Batch reading the contacts from CP2, and index the created documents to AppSearch // // Moved contactsBatcher from a member variable to a local variable. Previously, two // simultaneous updates would use the same ContactsBatcher, leading to updates sometimes // indexing each other's contacts and messing up the metrics/counts for the number of // succeeded/skipped contacts. ContactsBatcher contactsBatcher = new ContactsBatcher( mAppSearchHelper, NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH, shouldKeepUpdatingOnError); while (startIndex < wantedIdListSize) { int endIndex = Math.min(startIndex + NUM_CONTACTS_PER_BATCH_FOR_CP2, wantedIdListSize); Collection currentContactIds = wantedContactIds.subList(startIndex, endIndex); // Read NUM_CONTACTS_PER_BATCH contacts every time from CP2. String selection = ContactsContract.Data.CONTACT_ID + " IN (" + TextUtils.join(/* delimiter= */ ",", currentContactIds) + ")"; startIndex = endIndex; try { // For our iteration work, we must sort the result by contact_id first. Cursor cursor = mContext.getContentResolver() .query( ContactsContract.Data.CONTENT_URI, mProjection, selection, /* selectionArgs= */ null, ORDER_BY); if (cursor == null) { updateStats.mUpdateStatuses.add(ContactsUpdateStats.ERROR_CODE_CP2_NULL_CURSOR); Log.w(TAG, "Cursor was returned as null while querying CP2."); if (!shouldKeepUpdatingOnError) { return future.thenCompose( x -> CompletableFuture.failedFuture( new IllegalStateException( "Cursor was returned as null while querying" + " CP2."))); } } else { // If any indexContactsFromCursorAsync in the future-chain completes // exceptionally, all futures following it will not run and will instead // complete exceptionally. However, when shouldKeepUpdatingOnError is true, // indexContactsFromCursorAsync avoids completing exceptionally except for // AppSearchResult#RESULT_OUT_OF_SPACE. future = future.thenCompose( x -> indexContactsFromCursorAsync( cursor, updateStats, contactsBatcher, shouldKeepUpdatingOnError)) .whenComplete( (x, t) -> { // ensure the cursor is closed even when the // future-chain fails cursor.close(); }); } } catch (RuntimeException e) { // The ContactsProvider sometimes propagates RuntimeExceptions to us // for when their database fails to open. Behave as if there was no // ContactsProvider, and flag that we were not successful. Log.e(TAG, "ContentResolver.query threw an exception.", e); updateStats.mUpdateStatuses.add( ContactsUpdateStats.ERROR_CODE_CP2_RUNTIME_EXCEPTION); if (!shouldKeepUpdatingOnError) { return future.thenCompose(x -> CompletableFuture.failedFuture(e)); } } } return future; } /** * Reads through cursor, converts the contacts to AppSearch documents, and indexes the documents * into AppSearch. * * @param cursor pointing to the contacts read from CP2. * @param updateStats to hold the counters for the update. * @param contactsBatcher the batcher that indexes the contacts for this update. * @param shouldKeepUpdatingOnError ContactsIndexer flag controlling whether or not updates * should continue after encountering errors. When enabled and an exception is thrown, stops * further indexing and flushes the current batch of contacts but does not return a failed * future. */ private CompletableFuture indexContactsFromCursorAsync( @NonNull Cursor cursor, @NonNull ContactsUpdateStats updateStats, @NonNull ContactsBatcher contactsBatcher, boolean shouldKeepUpdatingOnError) { Objects.requireNonNull(cursor); try { int contactIdIndex = cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID); int lookupKeyIndex = cursor.getColumnIndex(ContactsContract.Data.LOOKUP_KEY); int thumbnailUriIndex = cursor.getColumnIndex(ContactsContract.Data.PHOTO_THUMBNAIL_URI); int displayNameIndex = cursor.getColumnIndex(ContactsContract.Data.DISPLAY_NAME_PRIMARY); int starredIndex = cursor.getColumnIndex(ContactsContract.Data.STARRED); int phoneticNameIndex = cursor.getColumnIndex(ContactsContract.Data.PHONETIC_NAME); long currentContactId = -1; Person.Builder personBuilder; PersonBuilderHelper personBuilderHelper = null; while (cursor.moveToNext()) { long contactId = cursor.getLong(contactIdIndex); if (contactId != currentContactId) { // Either it is the very first row (currentContactId = -1), or a row for a new // new contact_id. if (currentContactId != -1) { // It is the first row for a new contact_id. We can wrap up the // ContactData for the previous contact_id. contactsBatcher.add(personBuilderHelper, updateStats); } // New set of builder and builderHelper for the new contact. currentContactId = contactId; String displayName = getStringFromCursor(cursor, displayNameIndex); if (displayName == null) { // For now, we don't abandon the data if displayName is missing. In the // schema the name is required for building a person. It might look bad // if there are contacts in CP2, but not in AppSearch, even though the // name is missing. displayName = ""; } personBuilder = new Person.Builder( AppSearchHelper.NAMESPACE_NAME, String.valueOf(contactId), displayName); String imageUri = getStringFromCursor(cursor, thumbnailUriIndex); String lookupKey = getStringFromCursor(cursor, lookupKeyIndex); boolean starred = starredIndex != -1 && cursor.getInt(starredIndex) != 0; Uri lookupUri = lookupKey != null ? ContactsContract.Contacts.getLookupUri( currentContactId, lookupKey) : null; personBuilder.setIsImportant(starred); if (lookupUri != null) { personBuilder.setExternalUri(lookupUri); } if (imageUri != null) { personBuilder.setImageUri(Uri.parse(imageUri)); } String phoneticName = getStringFromCursor(cursor, phoneticNameIndex); if (phoneticName != null) { personBuilder.addAdditionalName(Person.TYPE_PHONETIC_NAME, phoneticName); } // Always use current system timestamp first. If that contact already exists // in AppSearch, the creationTimestamp for this doc will be reset with the // original value stored in AppSearch during performDiffAsync. personBuilderHelper = new PersonBuilderHelper(String.valueOf(contactId), personBuilder) .setCreationTimestampMillis(System.currentTimeMillis()); } if (personBuilderHelper != null) { mContactDataHandler.convertCursorToPerson(cursor, personBuilderHelper); } } if (cursor.isAfterLast() && currentContactId != -1) { // The ContactData for the last contact has not been handled yet. So we need to // build and index it. if (personBuilderHelper != null) { contactsBatcher.add(personBuilderHelper, updateStats); } } } catch (RuntimeException e) { updateStats.mUpdateStatuses.add(ContactsUpdateStats.ERROR_CODE_CP2_RUNTIME_EXCEPTION); // TODO(b/203605504) see if we could catch more specific exceptions/errors. Log.e(TAG, "Error while indexing documents from the cursor", e); if (!shouldKeepUpdatingOnError) { return contactsBatcher .flushAsync(updateStats) .thenCompose(x -> CompletableFuture.failedFuture(e)); } } // finally force flush all the remaining batched contacts. return contactsBatcher.flushAsync(updateStats); } /** * Helper method to read the value from a {@link Cursor} for {@code index}. * * @return A string value, or {@code null} if the value is missing, or {@code index} is -1. */ @Nullable private static String getStringFromCursor(@NonNull Cursor cursor, int index) { Objects.requireNonNull(cursor); if (index != -1) { return cursor.getString(index); } return null; } /** * Class for helping batching the {@link Person} to be indexed. * *

This class is thread unsafe and all its methods must be called from the same thread. */ static class ContactsBatcher { // 1st layer of batching. Contact builders are pushed into this list first before comparing // fingerprints. private List mPendingDiffContactBuilders; // 2nd layer of batching. We do the filtering based on the fingerprint saved in the // AppSearch documents, and save the filtered contacts into this mPendingIndexContacts. private final List mPendingIndexContacts; /** * Batch size for both {@link #mPendingDiffContactBuilders} and {@link * #mPendingIndexContacts}. It is strictly followed by {@link #mPendingDiffContactBuilders}. * But for {@link #mPendingIndexContacts}, when we merge the former set into {@link * #mPendingIndexContacts}, it could exceed this limit. At maximum it could hold 2 * {@link * #mBatchSize} contacts before cleared. */ private final int mBatchSize; private final AppSearchHelper mAppSearchHelper; private final boolean mShouldKeepUpdatingOnError; private CompletableFuture mIndexContactsCompositeFuture = CompletableFuture.completedFuture(null); ContactsBatcher( @NonNull AppSearchHelper appSearchHelper, int batchSize, boolean shouldKeepUpdatingOnError) { mAppSearchHelper = Objects.requireNonNull(appSearchHelper); mBatchSize = batchSize; mShouldKeepUpdatingOnError = shouldKeepUpdatingOnError; mPendingDiffContactBuilders = new ArrayList<>(mBatchSize); mPendingIndexContacts = new ArrayList<>(mBatchSize); } CompletableFuture getCompositeFuture() { return mIndexContactsCompositeFuture; } @VisibleForTesting int getPendingDiffContactsCount() { return mPendingDiffContactBuilders.size(); } @VisibleForTesting int getPendingIndexContactsCount() { return mPendingIndexContacts.size(); } public void add( @NonNull PersonBuilderHelper builderHelper, @NonNull ContactsUpdateStats updateStats) { Objects.requireNonNull(builderHelper); mPendingDiffContactBuilders.add(builderHelper); if (mPendingDiffContactBuilders.size() >= mBatchSize) { // Pass in current mPendingDiffContactBuilders to performDiffAsync and create a new // list for batching List pendingDiffContactBuilders = mPendingDiffContactBuilders; mPendingDiffContactBuilders = new ArrayList<>(mBatchSize); mIndexContactsCompositeFuture = mIndexContactsCompositeFuture .thenCompose( x -> performDiffAsync( pendingDiffContactBuilders, updateStats)) .thenCompose( y -> { if (mPendingIndexContacts.size() >= mBatchSize) { return flushPendingIndexAsync(updateStats); } return CompletableFuture.completedFuture(null); }); } } public CompletableFuture flushAsync(@NonNull ContactsUpdateStats updateStats) { if (!mPendingDiffContactBuilders.isEmpty() || !mPendingIndexContacts.isEmpty()) { // Pass in current mPendingDiffContactBuilders to performDiffAsync and create a new // list for batching List pendingDiffContactBuilders = mPendingDiffContactBuilders; mPendingDiffContactBuilders = new ArrayList<>(mBatchSize); mIndexContactsCompositeFuture = mIndexContactsCompositeFuture .thenCompose( x -> performDiffAsync( pendingDiffContactBuilders, updateStats)) .thenCompose(y -> flushPendingIndexAsync(updateStats)); } CompletableFuture flushFuture = mIndexContactsCompositeFuture; mIndexContactsCompositeFuture = CompletableFuture.completedFuture(null); return flushFuture; } /** * Flushes batched contacts into {@link #mPendingIndexContacts}. * * @param pendingDiffContactBuilders the batched contacts to index */ private CompletableFuture performDiffAsync( @NonNull List pendingDiffContactBuilders, @NonNull ContactsUpdateStats updateStats) { List ids = new ArrayList<>(pendingDiffContactBuilders.size()); for (int i = 0; i < pendingDiffContactBuilders.size(); ++i) { ids.add(pendingDiffContactBuilders.get(i).getId()); } // getContactsWithFingerPrintsAsync may return no fingerprints if it would normally // completely exceptionally and mShouldAppSearchHelperCompleteNormallyOnError is true. // In this case, we may unnecessarily update some contacts in the following step, but // some unnecessary updates is better than no updates and should not cause a significant // impact on performance. return mAppSearchHelper .getContactsWithFingerprintsAsync(ids, mShouldKeepUpdatingOnError, updateStats) .thenCompose( contactsWithFingerprints -> { List contactsToBeIndexed = new ArrayList<>(pendingDiffContactBuilders.size()); // Before indexing a contact into AppSearch, we will check if the // contact with same id exists, and whether the fingerprint has // changed. If fingerprint has not been changed for the same // contact, we won't index it. for (int i = 0; i < pendingDiffContactBuilders.size(); ++i) { PersonBuilderHelper builderHelper = pendingDiffContactBuilders.get(i); GenericDocument doc = contactsWithFingerprints.get(i); byte[] oldFingerprint = doc != null ? doc.getPropertyBytes( Person.PERSON_PROPERTY_FINGERPRINT) : null; long docCreationTimestampMillis = doc != null ? doc.getCreationTimestampMillis() : -1; if (oldFingerprint != null) { // We already have this contact in AppSearch. Reset the // creationTimestamp here with the original one. builderHelper.setCreationTimestampMillis( docCreationTimestampMillis); Person person = builderHelper.buildPerson(); if (!Arrays.equals( person.getFingerprint(), oldFingerprint)) { contactsToBeIndexed.add(person); } else { // Fingerprint is same. So this update is skipped. ++updateStats.mContactsUpdateSkippedCount; } } else { // New contact. ++updateStats.mNewContactsToBeUpdated; contactsToBeIndexed.add(builderHelper.buildPerson()); } } mPendingIndexContacts.addAll(contactsToBeIndexed); return CompletableFuture.completedFuture(null); }); } /** Flushes the contacts batched in {@link #mPendingIndexContacts} to AppSearch. */ private CompletableFuture flushPendingIndexAsync( @NonNull ContactsUpdateStats updateStats) { if (mPendingIndexContacts.size() > 0) { CompletableFuture future = mAppSearchHelper.indexContactsAsync( mPendingIndexContacts, updateStats, mShouldKeepUpdatingOnError); mPendingIndexContacts.clear(); return future; } return CompletableFuture.completedFuture(null); } } }