/*
* 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.AppSearchResult;
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;
private final ContactsBatcher mBatcher;
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]);
mBatcher = new ContactsBatcher(mAppSearchHelper,
NUM_UPDATED_CONTACTS_PER_BATCH_FOR_APPSEARCH);
}
/**
* 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.
*/
public CompletableFuture updatePersonCorpusAsync(
@NonNull List wantedContactIds,
@NonNull List unWantedIds,
@NonNull ContactsUpdateStats updateStats) {
Objects.requireNonNull(wantedContactIds);
Objects.requireNonNull(unWantedIds);
Objects.requireNonNull(updateStats);
return batchRemoveContactsAsync(unWantedIds, updateStats).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 occurs during batch delete:", t);
return null;
}).thenCompose(x -> batchUpdateContactsAsync(wantedContactIds, updateStats));
}
/**
* Removes contacts in batches.
*
* @param updateStats to hold the counters for the remove.
*/
@VisibleForTesting
CompletableFuture batchRemoveContactsAsync(
@NonNull final List unWantedIds,
@NonNull ContactsUpdateStats updateStats) {
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);
batchRemoveFuture = batchRemoveFuture.thenCompose(
x -> mAppSearchHelper.removeContactsByIdAsync(currentContactIds, updateStats));
startIndex = endIndex;
}
return batchRemoveFuture;
}
/**
* Batch inserts newly-added contacts, and updates recently-updated contacts.
*
* @param updateStats to hold the counters for the update.
*/
CompletableFuture batchUpdateContactsAsync(
@NonNull final List wantedContactIds,
@NonNull ContactsUpdateStats updateStats) {
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
//
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(AppSearchResult.RESULT_INTERNAL_ERROR);
return CompletableFuture.failedFuture(
new IllegalStateException(
"Cursor is returned as null while querying CP2."));
} else {
future = future
.thenCompose(x -> {
CompletableFuture indexContactsFuture =
indexContactsFromCursorAsync(cursor, updateStats);
cursor.close();
return indexContactsFuture;
});
}
} 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(AppSearchResult.RESULT_INTERNAL_ERROR);
return CompletableFuture.failedFuture(e);
}
}
return future;
}
/**
* Cancels the {@link #updatePersonCorpusAsync(List, List, ContactsUpdateStats)} in case of
* error. This will clean up the states in the batcher so it can get ready for the following
* updates.
*/
void cancelUpdatePersonCorpus() {
mBatcher.clearBatchedContacts();
}
/**
* 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.
*/
private CompletableFuture indexContactsFromCursorAsync(@NonNull Cursor cursor,
@NonNull ContactsUpdateStats updateStats) {
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 = null;
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.
mBatcher.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) {
mBatcher.add(personBuilderHelper, updateStats);
}
}
} catch (Throwable t) {
updateStats.mUpdateStatuses.add(AppSearchResult.RESULT_UNKNOWN_ERROR);
// TODO(b/203605504) see if we could catch more specific exceptions/errors.
Log.e(TAG, "Error while indexing documents from the cursor", t);
return CompletableFuture.failedFuture(t);
}
// finally force flush all the remaining batched contacts.
return mBatcher.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 CompletableFuture mIndexContactsCompositeFuture =
CompletableFuture.completedFuture(null);
ContactsBatcher(@NonNull AppSearchHelper appSearchHelper, int batchSize) {
mAppSearchHelper = Objects.requireNonNull(appSearchHelper);
mBatchSize = batchSize;
mPendingDiffContactBuilders = new ArrayList<>(mBatchSize);
mPendingIndexContacts = new ArrayList<>(mBatchSize);
}
CompletableFuture getCompositeFuture() {
return mIndexContactsCompositeFuture;
}
@VisibleForTesting
int getPendingDiffContactsCount() {
return mPendingDiffContactBuilders.size();
}
@VisibleForTesting
int getPendingIndexContactsCount() {
return mPendingIndexContacts.size();
}
void clearBatchedContacts() {
mPendingDiffContactBuilders.clear();
mPendingIndexContacts.clear();
}
public void add(@NonNull PersonBuilderHelper builderHelper,
@NonNull ContactsUpdateStats updateStats) {
Objects.requireNonNull(builderHelper);
mPendingDiffContactBuilders.add(builderHelper);
if (mPendingDiffContactBuilders.size() >= mBatchSize) {
mIndexContactsCompositeFuture = mIndexContactsCompositeFuture
.thenCompose(x -> performDiffAsync(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()) {
mIndexContactsCompositeFuture = mIndexContactsCompositeFuture
.thenCompose(x -> performDiffAsync(updateStats))
.thenCompose(y -> flushPendingIndexAsync(updateStats));
}
CompletableFuture flushFuture = mIndexContactsCompositeFuture;
mIndexContactsCompositeFuture = CompletableFuture.completedFuture(null);
return flushFuture;
}
/**
* Flushes the batched contacts from {@link #mPendingDiffContactBuilders} to {@link
* #mPendingIndexContacts}.
*/
private CompletableFuture performDiffAsync(@NonNull ContactsUpdateStats updateStats) {
// Shallow copy before passing it to chained future stages.
// mPendingDiffContacts is being cleared after passing them to the async completion
// stage, and that leads to a race condition without a copy.
List pendingDiffContactBuilders = mPendingDiffContactBuilders;
mPendingDiffContactBuilders = new ArrayList<>(mBatchSize);
// Get the ids from persons in order.
List ids = new ArrayList<>(pendingDiffContactBuilders.size());
for (int i = 0; i < pendingDiffContactBuilders.size(); ++i) {
ids.add(pendingDiffContactBuilders.get(i).getId());
}
CompletableFuture future = CompletableFuture.completedFuture(null)
.thenCompose(x -> mAppSearchHelper.getContactsWithFingerprintsAsync(ids))
.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);
});
return future;
}
/** 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);
mPendingIndexContacts.clear();
return future;
}
return CompletableFuture.completedFuture(null);
}
}
}