summaryrefslogtreecommitdiffstats
path: root/src/com/android/providers/contacts/aggregation
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/providers/contacts/aggregation')
-rw-r--r--src/com/android/providers/contacts/aggregation/ContactAggregator.java2303
-rw-r--r--src/com/android/providers/contacts/aggregation/ProfileAggregator.java95
2 files changed, 2398 insertions, 0 deletions
diff --git a/src/com/android/providers/contacts/aggregation/ContactAggregator.java b/src/com/android/providers/contacts/aggregation/ContactAggregator.java
new file mode 100644
index 0000000..a235ce2
--- /dev/null
+++ b/src/com/android/providers/contacts/aggregation/ContactAggregator.java
@@ -0,0 +1,2303 @@
+/*
+ * Copyright (C) 2009 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.providers.contacts.aggregation;
+
+import com.android.providers.contacts.CommonNicknameCache;
+import com.android.providers.contacts.ContactLookupKey;
+import com.android.providers.contacts.ContactMatcher;
+import com.android.providers.contacts.ContactMatcher.MatchScore;
+import com.android.providers.contacts.ContactsDatabaseHelper;
+import com.android.providers.contacts.ContactsDatabaseHelper.AccountsColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType;
+import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.PresenceColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.StatusUpdatesColumns;
+import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
+import com.android.providers.contacts.ContactsDatabaseHelper.Views;
+import com.android.providers.contacts.ContactsProvider2;
+import com.android.providers.contacts.NameLookupBuilder;
+import com.android.providers.contacts.NameNormalizer;
+import com.android.providers.contacts.NameSplitter;
+import com.android.providers.contacts.PhotoPriorityResolver;
+import com.android.providers.contacts.ReorderingCursorWrapper;
+import com.android.providers.contacts.TransactionContext;
+
+import android.database.Cursor;
+import android.database.DatabaseUtils;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.database.sqlite.SQLiteStatement;
+import android.net.Uri;
+import android.provider.ContactsContract.AggregationExceptions;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.CommonDataKinds.Identity;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.Photo;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Contacts.AggregationSuggestions;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.DisplayNameSources;
+import android.provider.ContactsContract.FullNameStyle;
+import android.provider.ContactsContract.PhotoFiles;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.StatusUpdates;
+import android.text.TextUtils;
+import android.util.EventLog;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * ContactAggregator deals with aggregating contact information coming from different sources.
+ * Two John Doe contacts from two disjoint sources are presumed to be the same
+ * person unless the user declares otherwise.
+ */
+public class ContactAggregator {
+
+ private static final String TAG = "ContactAggregator";
+
+ private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE);
+
+ private static final String STRUCTURED_NAME_BASED_LOOKUP_SQL =
+ NameLookupColumns.NAME_TYPE + " IN ("
+ + NameLookupType.NAME_EXACT + ","
+ + NameLookupType.NAME_VARIANT + ","
+ + NameLookupType.NAME_COLLATION_KEY + ")";
+
+
+ /**
+ * SQL statement that sets the {@link ContactsColumns#LAST_STATUS_UPDATE_ID} column
+ * on the contact to point to the latest social status update.
+ */
+ private static final String UPDATE_LAST_STATUS_UPDATE_ID_SQL =
+ "UPDATE " + Tables.CONTACTS +
+ " SET " + ContactsColumns.LAST_STATUS_UPDATE_ID + "=" +
+ "(SELECT " + DataColumns.CONCRETE_ID +
+ " FROM " + Tables.STATUS_UPDATES +
+ " JOIN " + Tables.DATA +
+ " ON (" + StatusUpdatesColumns.DATA_ID + "="
+ + DataColumns.CONCRETE_ID + ")" +
+ " JOIN " + Tables.RAW_CONTACTS +
+ " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "="
+ + RawContactsColumns.CONCRETE_ID + ")" +
+ " WHERE " + RawContacts.CONTACT_ID + "=?" +
+ " ORDER BY " + StatusUpdates.STATUS_TIMESTAMP + " DESC,"
+ + StatusUpdates.STATUS +
+ " LIMIT 1)" +
+ " WHERE " + ContactsColumns.CONCRETE_ID + "=?";
+
+ // From system/core/logcat/event-log-tags
+ // aggregator [time, count] will be logged for each aggregator cycle.
+ // For the query (as opposed to the merge), count will be negative
+ public static final int LOG_SYNC_CONTACTS_AGGREGATION = 2747;
+
+ // If we encounter more than this many contacts with matching names, aggregate only this many
+ private static final int PRIMARY_HIT_LIMIT = 15;
+ private static final String PRIMARY_HIT_LIMIT_STRING = String.valueOf(PRIMARY_HIT_LIMIT);
+
+ // If we encounter more than this many contacts with matching phone number or email,
+ // don't attempt to aggregate - this is likely an error or a shared corporate data element.
+ private static final int SECONDARY_HIT_LIMIT = 20;
+ private static final String SECONDARY_HIT_LIMIT_STRING = String.valueOf(SECONDARY_HIT_LIMIT);
+
+ // If we encounter more than this many contacts with matching name during aggregation
+ // suggestion lookup, ignore the remaining results.
+ private static final int FIRST_LETTER_SUGGESTION_HIT_LIMIT = 100;
+
+ private final ContactsProvider2 mContactsProvider;
+ private final ContactsDatabaseHelper mDbHelper;
+ private PhotoPriorityResolver mPhotoPriorityResolver;
+ private final NameSplitter mNameSplitter;
+ private final CommonNicknameCache mCommonNicknameCache;
+
+ private boolean mEnabled = true;
+
+ /** Precompiled sql statement for setting an aggregated presence */
+ private SQLiteStatement mAggregatedPresenceReplace;
+ private SQLiteStatement mPresenceContactIdUpdate;
+ private SQLiteStatement mRawContactCountQuery;
+ private SQLiteStatement mContactDelete;
+ private SQLiteStatement mAggregatedPresenceDelete;
+ private SQLiteStatement mMarkForAggregation;
+ private SQLiteStatement mPhotoIdUpdate;
+ private SQLiteStatement mDisplayNameUpdate;
+ private SQLiteStatement mLookupKeyUpdate;
+ private SQLiteStatement mStarredUpdate;
+ private SQLiteStatement mContactIdAndMarkAggregatedUpdate;
+ private SQLiteStatement mContactIdUpdate;
+ private SQLiteStatement mMarkAggregatedUpdate;
+ private SQLiteStatement mContactUpdate;
+ private SQLiteStatement mContactInsert;
+
+ private HashMap<Long, Integer> mRawContactsMarkedForAggregation = new HashMap<Long, Integer>();
+
+ private String[] mSelectionArgs1 = new String[1];
+ private String[] mSelectionArgs2 = new String[2];
+ private String[] mSelectionArgs3 = new String[3];
+ private String[] mSelectionArgs4 = new String[4];
+ private long mMimeTypeIdIdentity;
+ private long mMimeTypeIdEmail;
+ private long mMimeTypeIdPhoto;
+ private long mMimeTypeIdPhone;
+ private String mRawContactsQueryByRawContactId;
+ private String mRawContactsQueryByContactId;
+ private StringBuilder mSb = new StringBuilder();
+ private MatchCandidateList mCandidates = new MatchCandidateList();
+ private ContactMatcher mMatcher = new ContactMatcher();
+ private DisplayNameCandidate mDisplayNameCandidate = new DisplayNameCandidate();
+
+ /**
+ * Parameter for the suggestion lookup query.
+ */
+ public static final class AggregationSuggestionParameter {
+ public final String kind;
+ public final String value;
+
+ public AggregationSuggestionParameter(String kind, String value) {
+ this.kind = kind;
+ this.value = value;
+ }
+ }
+
+ /**
+ * Captures a potential match for a given name. The matching algorithm
+ * constructs a bunch of NameMatchCandidate objects for various potential matches
+ * and then executes the search in bulk.
+ */
+ private static class NameMatchCandidate {
+ String mName;
+ int mLookupType;
+
+ public NameMatchCandidate(String name, int nameLookupType) {
+ mName = name;
+ mLookupType = nameLookupType;
+ }
+ }
+
+ /**
+ * A list of {@link NameMatchCandidate} that keeps its elements even when the list is
+ * truncated. This is done for optimization purposes to avoid excessive object allocation.
+ */
+ private static class MatchCandidateList {
+ private final ArrayList<NameMatchCandidate> mList = new ArrayList<NameMatchCandidate>();
+ private int mCount;
+
+ /**
+ * Adds a {@link NameMatchCandidate} element or updates the next one if it already exists.
+ */
+ public void add(String name, int nameLookupType) {
+ if (mCount >= mList.size()) {
+ mList.add(new NameMatchCandidate(name, nameLookupType));
+ } else {
+ NameMatchCandidate candidate = mList.get(mCount);
+ candidate.mName = name;
+ candidate.mLookupType = nameLookupType;
+ }
+ mCount++;
+ }
+
+ public void clear() {
+ mCount = 0;
+ }
+
+ public boolean isEmpty() {
+ return mCount == 0;
+ }
+ }
+
+ /**
+ * A convenience class used in the algorithm that figures out which of available
+ * display names to use for an aggregate contact.
+ */
+ private static class DisplayNameCandidate {
+ long rawContactId;
+ String displayName;
+ int displayNameSource;
+ boolean verified;
+ boolean writableAccount;
+
+ public DisplayNameCandidate() {
+ clear();
+ }
+
+ public void clear() {
+ rawContactId = -1;
+ displayName = null;
+ displayNameSource = DisplayNameSources.UNDEFINED;
+ verified = false;
+ writableAccount = false;
+ }
+ }
+
+ /**
+ * Constructor.
+ */
+ public ContactAggregator(ContactsProvider2 contactsProvider,
+ ContactsDatabaseHelper contactsDatabaseHelper,
+ PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter,
+ CommonNicknameCache commonNicknameCache) {
+ mContactsProvider = contactsProvider;
+ mDbHelper = contactsDatabaseHelper;
+ mPhotoPriorityResolver = photoPriorityResolver;
+ mNameSplitter = nameSplitter;
+ mCommonNicknameCache = commonNicknameCache;
+
+ SQLiteDatabase db = mDbHelper.getReadableDatabase();
+
+ // Since we have no way of determining which custom status was set last,
+ // we'll just pick one randomly. We are using MAX as an approximation of randomness
+ final String replaceAggregatePresenceSql =
+ "INSERT OR REPLACE INTO " + Tables.AGGREGATED_PRESENCE + "("
+ + AggregatedPresenceColumns.CONTACT_ID + ", "
+ + StatusUpdates.PRESENCE + ", "
+ + StatusUpdates.CHAT_CAPABILITY + ")"
+ + " SELECT " + PresenceColumns.CONTACT_ID + ","
+ + StatusUpdates.PRESENCE + ","
+ + StatusUpdates.CHAT_CAPABILITY
+ + " FROM " + Tables.PRESENCE
+ + " WHERE "
+ + " (" + StatusUpdates.PRESENCE
+ + " * 10 + " + StatusUpdates.CHAT_CAPABILITY + ")"
+ + " = (SELECT "
+ + "MAX (" + StatusUpdates.PRESENCE
+ + " * 10 + " + StatusUpdates.CHAT_CAPABILITY + ")"
+ + " FROM " + Tables.PRESENCE
+ + " WHERE " + PresenceColumns.CONTACT_ID
+ + "=?)"
+ + " AND " + PresenceColumns.CONTACT_ID
+ + "=?;";
+ mAggregatedPresenceReplace = db.compileStatement(replaceAggregatePresenceSql);
+
+ mRawContactCountQuery = db.compileStatement(
+ "SELECT COUNT(" + RawContacts._ID + ")" +
+ " FROM " + Tables.RAW_CONTACTS +
+ " WHERE " + RawContacts.CONTACT_ID + "=?"
+ + " AND " + RawContacts._ID + "<>?");
+
+ mContactDelete = db.compileStatement(
+ "DELETE FROM " + Tables.CONTACTS +
+ " WHERE " + Contacts._ID + "=?");
+
+ mAggregatedPresenceDelete = db.compileStatement(
+ "DELETE FROM " + Tables.AGGREGATED_PRESENCE +
+ " WHERE " + AggregatedPresenceColumns.CONTACT_ID + "=?");
+
+ mMarkForAggregation = db.compileStatement(
+ "UPDATE " + Tables.RAW_CONTACTS +
+ " SET " + RawContactsColumns.AGGREGATION_NEEDED + "=1" +
+ " WHERE " + RawContacts._ID + "=?"
+ + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0");
+
+ mPhotoIdUpdate = db.compileStatement(
+ "UPDATE " + Tables.CONTACTS +
+ " SET " + Contacts.PHOTO_ID + "=?," + Contacts.PHOTO_FILE_ID + "=? " +
+ " WHERE " + Contacts._ID + "=?");
+
+ mDisplayNameUpdate = db.compileStatement(
+ "UPDATE " + Tables.CONTACTS +
+ " SET " + Contacts.NAME_RAW_CONTACT_ID + "=? " +
+ " WHERE " + Contacts._ID + "=?");
+
+ mLookupKeyUpdate = db.compileStatement(
+ "UPDATE " + Tables.CONTACTS +
+ " SET " + Contacts.LOOKUP_KEY + "=? " +
+ " WHERE " + Contacts._ID + "=?");
+
+ mStarredUpdate = db.compileStatement("UPDATE " + Tables.CONTACTS + " SET "
+ + Contacts.STARRED + "=(SELECT (CASE WHEN COUNT(" + RawContacts.STARRED
+ + ")=0 THEN 0 ELSE 1 END) FROM " + Tables.RAW_CONTACTS + " WHERE "
+ + RawContacts.CONTACT_ID + "=" + ContactsColumns.CONCRETE_ID + " AND "
+ + RawContacts.STARRED + "=1)" + " WHERE " + Contacts._ID + "=?");
+
+ mContactIdAndMarkAggregatedUpdate = db.compileStatement(
+ "UPDATE " + Tables.RAW_CONTACTS +
+ " SET " + RawContacts.CONTACT_ID + "=?, "
+ + RawContactsColumns.AGGREGATION_NEEDED + "=0" +
+ " WHERE " + RawContacts._ID + "=?");
+
+ mContactIdUpdate = db.compileStatement(
+ "UPDATE " + Tables.RAW_CONTACTS +
+ " SET " + RawContacts.CONTACT_ID + "=?" +
+ " WHERE " + RawContacts._ID + "=?");
+
+ mMarkAggregatedUpdate = db.compileStatement(
+ "UPDATE " + Tables.RAW_CONTACTS +
+ " SET " + RawContactsColumns.AGGREGATION_NEEDED + "=0" +
+ " WHERE " + RawContacts._ID + "=?");
+
+ mPresenceContactIdUpdate = db.compileStatement(
+ "UPDATE " + Tables.PRESENCE +
+ " SET " + PresenceColumns.CONTACT_ID + "=?" +
+ " WHERE " + PresenceColumns.RAW_CONTACT_ID + "=?");
+
+ mContactUpdate = db.compileStatement(ContactReplaceSqlStatement.UPDATE_SQL);
+ mContactInsert = db.compileStatement(ContactReplaceSqlStatement.INSERT_SQL);
+
+ mMimeTypeIdEmail = mDbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE);
+ mMimeTypeIdIdentity = mDbHelper.getMimeTypeId(Identity.CONTENT_ITEM_TYPE);
+ mMimeTypeIdPhoto = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
+ mMimeTypeIdPhone = mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE);
+
+ // Query used to retrieve data from raw contacts to populate the corresponding aggregate
+ mRawContactsQueryByRawContactId = String.format(Locale.US,
+ RawContactsQuery.SQL_FORMAT_BY_RAW_CONTACT_ID,
+ mMimeTypeIdPhoto, mMimeTypeIdPhone);
+
+ mRawContactsQueryByContactId = String.format(Locale.US,
+ RawContactsQuery.SQL_FORMAT_BY_CONTACT_ID,
+ mMimeTypeIdPhoto, mMimeTypeIdPhone);
+ }
+
+ public void setEnabled(boolean enabled) {
+ mEnabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return mEnabled;
+ }
+
+ private interface AggregationQuery {
+ String SQL =
+ "SELECT " + RawContacts._ID + "," + RawContacts.CONTACT_ID +
+ ", " + RawContactsColumns.ACCOUNT_ID +
+ " FROM " + Tables.RAW_CONTACTS +
+ " WHERE " + RawContacts._ID + " IN(";
+
+ int _ID = 0;
+ int CONTACT_ID = 1;
+ int ACCOUNT_ID = 2;
+ }
+
+ /**
+ * Aggregate all raw contacts that were marked for aggregation in the current transaction.
+ * Call just before committing the transaction.
+ */
+ public void aggregateInTransaction(TransactionContext txContext, SQLiteDatabase db) {
+ int count = mRawContactsMarkedForAggregation.size();
+ if (count == 0) {
+ return;
+ }
+
+ long start = System.currentTimeMillis();
+ if (VERBOSE_LOGGING) {
+ Log.v(TAG, "Contact aggregation: " + count);
+ }
+
+ EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, start, -count);
+
+ String selectionArgs[] = new String[count];
+
+ int index = 0;
+ mSb.setLength(0);
+ mSb.append(AggregationQuery.SQL);
+ for (long rawContactId : mRawContactsMarkedForAggregation.keySet()) {
+ if (index > 0) {
+ mSb.append(',');
+ }
+ mSb.append('?');
+ selectionArgs[index++] = String.valueOf(rawContactId);
+ }
+
+ mSb.append(')');
+
+ long rawContactIds[] = new long[count];
+ long contactIds[] = new long[count];
+ long accountIds[] = new long[count];
+ Cursor c = db.rawQuery(mSb.toString(), selectionArgs);
+ try {
+ count = c.getCount();
+ index = 0;
+ while (c.moveToNext()) {
+ rawContactIds[index] = c.getLong(AggregationQuery._ID);
+ contactIds[index] = c.getLong(AggregationQuery.CONTACT_ID);
+ accountIds[index] = c.getLong(AggregationQuery.ACCOUNT_ID);
+ index++;
+ }
+ } finally {
+ c.close();
+ }
+
+ for (int i = 0; i < count; i++) {
+ aggregateContact(txContext, db, rawContactIds[i], accountIds[i], contactIds[i],
+ mCandidates, mMatcher);
+ }
+
+ long elapsedTime = System.currentTimeMillis() - start;
+ EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, elapsedTime, count);
+
+ if (VERBOSE_LOGGING) {
+ String performance = count == 0 ? "" : ", " + (elapsedTime / count) + " ms per contact";
+ Log.i(TAG, "Contact aggregation complete: " + count + performance);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ public void triggerAggregation(TransactionContext txContext, long rawContactId) {
+ if (!mEnabled) {
+ return;
+ }
+
+ int aggregationMode = mDbHelper.getAggregationMode(rawContactId);
+ switch (aggregationMode) {
+ case RawContacts.AGGREGATION_MODE_DISABLED:
+ break;
+
+ case RawContacts.AGGREGATION_MODE_DEFAULT: {
+ markForAggregation(rawContactId, aggregationMode, false);
+ break;
+ }
+
+ case RawContacts.AGGREGATION_MODE_SUSPENDED: {
+ long contactId = mDbHelper.getContactId(rawContactId);
+
+ if (contactId != 0) {
+ updateAggregateData(txContext, contactId);
+ }
+ break;
+ }
+
+ case RawContacts.AGGREGATION_MODE_IMMEDIATE: {
+ aggregateContact(txContext, mDbHelper.getWritableDatabase(), rawContactId);
+ break;
+ }
+ }
+ }
+
+ public void clearPendingAggregations() {
+ mRawContactsMarkedForAggregation.clear();
+ }
+
+ public void markNewForAggregation(long rawContactId, int aggregationMode) {
+ mRawContactsMarkedForAggregation.put(rawContactId, aggregationMode);
+ }
+
+ public void markForAggregation(long rawContactId, int aggregationMode, boolean force) {
+ final int effectiveAggregationMode;
+ if (!force && mRawContactsMarkedForAggregation.containsKey(rawContactId)) {
+ // As per ContactsContract documentation, default aggregation mode
+ // does not override a previously set mode
+ if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
+ effectiveAggregationMode = mRawContactsMarkedForAggregation.get(rawContactId);
+ } else {
+ effectiveAggregationMode = aggregationMode;
+ }
+ } else {
+ mMarkForAggregation.bindLong(1, rawContactId);
+ mMarkForAggregation.execute();
+ effectiveAggregationMode = aggregationMode;
+ }
+
+ mRawContactsMarkedForAggregation.put(rawContactId, effectiveAggregationMode);
+ }
+
+ private static class RawContactIdAndAggregationModeQuery {
+ public static final String TABLE = Tables.RAW_CONTACTS;
+
+ public static final String[] COLUMNS = { RawContacts._ID, RawContacts.AGGREGATION_MODE };
+
+ public static final String SELECTION = RawContacts.CONTACT_ID + "=?";
+
+ public static final int _ID = 0;
+ public static final int AGGREGATION_MODE = 1;
+ }
+
+ /**
+ * Marks all constituent raw contacts of an aggregated contact for re-aggregation.
+ */
+ private void markContactForAggregation(SQLiteDatabase db, long contactId) {
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ Cursor cursor = db.query(RawContactIdAndAggregationModeQuery.TABLE,
+ RawContactIdAndAggregationModeQuery.COLUMNS,
+ RawContactIdAndAggregationModeQuery.SELECTION, mSelectionArgs1, null, null, null);
+ try {
+ if (cursor.moveToFirst()) {
+ long rawContactId = cursor.getLong(RawContactIdAndAggregationModeQuery._ID);
+ int aggregationMode = cursor.getInt(
+ RawContactIdAndAggregationModeQuery.AGGREGATION_MODE);
+ if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
+ markForAggregation(rawContactId, aggregationMode, true);
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Creates a new contact based on the given raw contact. Does not perform aggregation. Returns
+ * the ID of the contact that was created.
+ */
+ public long onRawContactInsert(
+ TransactionContext txContext, SQLiteDatabase db, long rawContactId) {
+ long contactId = insertContact(db, rawContactId);
+ setContactId(rawContactId, contactId);
+ mDbHelper.updateContactVisible(txContext, contactId);
+ return contactId;
+ }
+
+ protected long insertContact(SQLiteDatabase db, long rawContactId) {
+ mSelectionArgs1[0] = String.valueOf(rawContactId);
+ computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1, mContactInsert);
+ return mContactInsert.executeInsert();
+ }
+
+ private static final class RawContactIdAndAccountQuery {
+ public static final String TABLE = Tables.RAW_CONTACTS;
+
+ public static final String[] COLUMNS = {
+ RawContacts.CONTACT_ID,
+ RawContactsColumns.ACCOUNT_ID
+ };
+
+ public static final String SELECTION = RawContacts._ID + "=?";
+
+ public static final int CONTACT_ID = 0;
+ public static final int ACCOUNT_ID = 1;
+ }
+
+ public void aggregateContact(
+ TransactionContext txContext, SQLiteDatabase db, long rawContactId) {
+ if (!mEnabled) {
+ return;
+ }
+
+ MatchCandidateList candidates = new MatchCandidateList();
+ ContactMatcher matcher = new ContactMatcher();
+
+ long contactId = 0;
+ long accountId = 0;
+ mSelectionArgs1[0] = String.valueOf(rawContactId);
+ Cursor cursor = db.query(RawContactIdAndAccountQuery.TABLE,
+ RawContactIdAndAccountQuery.COLUMNS, RawContactIdAndAccountQuery.SELECTION,
+ mSelectionArgs1, null, null, null);
+ try {
+ if (cursor.moveToFirst()) {
+ contactId = cursor.getLong(RawContactIdAndAccountQuery.CONTACT_ID);
+ accountId = cursor.getLong(RawContactIdAndAccountQuery.ACCOUNT_ID);
+ }
+ } finally {
+ cursor.close();
+ }
+
+ aggregateContact(txContext, db, rawContactId, accountId, contactId,
+ candidates, matcher);
+ }
+
+ public void updateAggregateData(TransactionContext txContext, long contactId) {
+ if (!mEnabled) {
+ return;
+ }
+
+ final SQLiteDatabase db = mDbHelper.getWritableDatabase();
+ computeAggregateData(db, contactId, mContactUpdate);
+ mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId);
+ mContactUpdate.execute();
+
+ mDbHelper.updateContactVisible(txContext, contactId);
+ updateAggregatedStatusUpdate(contactId);
+ }
+
+ private void updateAggregatedStatusUpdate(long contactId) {
+ mAggregatedPresenceReplace.bindLong(1, contactId);
+ mAggregatedPresenceReplace.bindLong(2, contactId);
+ mAggregatedPresenceReplace.execute();
+ updateLastStatusUpdateId(contactId);
+ }
+
+ /**
+ * Adjusts the reference to the latest status update for the specified contact.
+ */
+ public void updateLastStatusUpdateId(long contactId) {
+ String contactIdString = String.valueOf(contactId);
+ mDbHelper.getWritableDatabase().execSQL(UPDATE_LAST_STATUS_UPDATE_ID_SQL,
+ new String[]{contactIdString, contactIdString});
+ }
+
+ /**
+ * Given a specific raw contact, finds all matching aggregate contacts and chooses the one
+ * with the highest match score. If no such contact is found, creates a new contact.
+ */
+ private synchronized void aggregateContact(TransactionContext txContext, SQLiteDatabase db,
+ long rawContactId, long accountId, long currentContactId, MatchCandidateList candidates,
+ ContactMatcher matcher) {
+
+ int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT;
+
+ Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId);
+ if (aggModeObject != null) {
+ aggregationMode = aggModeObject;
+ }
+
+ long contactId = -1;
+ long contactIdToSplit = -1;
+
+ if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
+ candidates.clear();
+ matcher.clear();
+
+ contactId = pickBestMatchBasedOnExceptions(db, rawContactId, matcher);
+ if (contactId == -1) {
+
+ // If this is a newly inserted contact or a visible contact, look for
+ // data matches.
+ if (currentContactId == 0
+ || mDbHelper.isContactInDefaultDirectory(db, currentContactId)) {
+ contactId = pickBestMatchBasedOnData(db, rawContactId, candidates, matcher);
+ }
+
+ // If we found an aggregate to join, but it already contains raw contacts from
+ // the same account, not only will we not join it, but also we will split
+ // that other aggregate
+ if (contactId != -1 && contactId != currentContactId &&
+ containsRawContactsFromAccount(db, contactId, accountId)) {
+ contactIdToSplit = contactId;
+ contactId = -1;
+ }
+ }
+ } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) {
+ return;
+ }
+
+ long currentContactContentsCount = 0;
+
+ if (currentContactId != 0) {
+ mRawContactCountQuery.bindLong(1, currentContactId);
+ mRawContactCountQuery.bindLong(2, rawContactId);
+ currentContactContentsCount = mRawContactCountQuery.simpleQueryForLong();
+ }
+
+ // If there are no other raw contacts in the current aggregate, we might as well reuse it.
+ // Also, if the aggregation mode is SUSPENDED, we must reuse the same aggregate.
+ if (contactId == -1
+ && currentContactId != 0
+ && (currentContactContentsCount == 0
+ || aggregationMode == RawContacts.AGGREGATION_MODE_SUSPENDED)) {
+ contactId = currentContactId;
+ }
+
+ if (contactId == currentContactId) {
+ // Aggregation unchanged
+ markAggregated(rawContactId);
+ } else if (contactId == -1) {
+ // Splitting an aggregate
+ createNewContactForRawContact(txContext, db, rawContactId);
+ if (currentContactContentsCount > 0) {
+ updateAggregateData(txContext, currentContactId);
+ }
+ } else {
+ // Joining with an existing aggregate
+ if (currentContactContentsCount == 0) {
+ // Delete a previous aggregate if it only contained this raw contact
+ mContactDelete.bindLong(1, currentContactId);
+ mContactDelete.execute();
+
+ mAggregatedPresenceDelete.bindLong(1, currentContactId);
+ mAggregatedPresenceDelete.execute();
+ }
+
+ setContactIdAndMarkAggregated(rawContactId, contactId);
+ computeAggregateData(db, contactId, mContactUpdate);
+ mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId);
+ mContactUpdate.execute();
+ mDbHelper.updateContactVisible(txContext, contactId);
+ updateAggregatedStatusUpdate(contactId);
+ }
+
+ if (contactIdToSplit != -1) {
+ splitAutomaticallyAggregatedRawContacts(txContext, db, contactIdToSplit);
+ }
+ }
+
+ /**
+ * Returns true if the aggregate contains has any raw contacts from the specified account.
+ */
+ private boolean containsRawContactsFromAccount(
+ SQLiteDatabase db, long contactId, long accountId) {
+ final String query = "SELECT count(_id) FROM " + Tables.RAW_CONTACTS +
+ " WHERE " + RawContacts.CONTACT_ID + "=?" +
+ " AND " + RawContactsColumns.ACCOUNT_ID + "=?";
+ Cursor cursor = db.rawQuery(query, new String[] {
+ Long.toString(contactId), Long.toString(accountId)
+ });
+ try {
+ cursor.moveToFirst();
+ return cursor.getInt(0) != 0;
+ } finally {
+ cursor.close();
+ }
+ }
+
+ /**
+ * Breaks up an existing aggregate when a new raw contact is inserted that has
+ * come from the same account as one of the raw contacts in this aggregate.
+ */
+ private void splitAutomaticallyAggregatedRawContacts(
+ TransactionContext txContext, SQLiteDatabase db, long contactId) {
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ int count = (int) DatabaseUtils.longForQuery(db,
+ "SELECT COUNT(" + RawContacts._ID + ")" +
+ " FROM " + Tables.RAW_CONTACTS +
+ " WHERE " + RawContacts.CONTACT_ID + "=?", mSelectionArgs1);
+ if (count < 2) {
+ // A single-raw-contact aggregate does not need to be split up
+ return;
+ }
+
+ // Find all constituent raw contacts that are not held together by
+ // an explicit aggregation exception
+ String query =
+ "SELECT " + RawContacts._ID +
+ " FROM " + Tables.RAW_CONTACTS +
+ " WHERE " + RawContacts.CONTACT_ID + "=?" +
+ " AND " + RawContacts._ID + " NOT IN " +
+ "(SELECT " + AggregationExceptions.RAW_CONTACT_ID1 +
+ " FROM " + Tables.AGGREGATION_EXCEPTIONS +
+ " WHERE " + AggregationExceptions.TYPE + "="
+ + AggregationExceptions.TYPE_KEEP_TOGETHER +
+ " UNION SELECT " + AggregationExceptions.RAW_CONTACT_ID2 +
+ " FROM " + Tables.AGGREGATION_EXCEPTIONS +
+ " WHERE " + AggregationExceptions.TYPE + "="
+ + AggregationExceptions.TYPE_KEEP_TOGETHER +
+ ")";
+
+ Cursor cursor = db.rawQuery(query, mSelectionArgs1);
+ try {
+ // Process up to count-1 raw contact, leaving the last one alone.
+ for (int i = 0; i < count - 1; i++) {
+ if (!cursor.moveToNext()) {
+ break;
+ }
+ long rawContactId = cursor.getLong(0);
+ createNewContactForRawContact(txContext, db, rawContactId);
+ }
+ } finally {
+ cursor.close();
+ }
+ if (contactId > 0) {
+ updateAggregateData(txContext, contactId);
+ }
+ }
+
+ /**
+ * Creates a stand-alone Contact for the given raw contact ID.
+ */
+ private void createNewContactForRawContact(
+ TransactionContext txContext, SQLiteDatabase db, long rawContactId) {
+ mSelectionArgs1[0] = String.valueOf(rawContactId);
+ computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1,
+ mContactInsert);
+ long contactId = mContactInsert.executeInsert();
+ setContactIdAndMarkAggregated(rawContactId, contactId);
+ mDbHelper.updateContactVisible(txContext, contactId);
+ setPresenceContactId(rawContactId, contactId);
+ updateAggregatedStatusUpdate(contactId);
+ }
+
+ private static class RawContactIdQuery {
+ public static final String TABLE = Tables.RAW_CONTACTS;
+ public static final String[] COLUMNS = { RawContacts._ID };
+ public static final String SELECTION = RawContacts.CONTACT_ID + "=?";
+ public static final int RAW_CONTACT_ID = 0;
+ }
+
+ /**
+ * Ensures that automatic aggregation rules are followed after a contact
+ * becomes visible or invisible. Specifically, consider this case: there are
+ * three contacts named Foo. Two of them come from account A1 and one comes
+ * from account A2. The aggregation rules say that in this case none of the
+ * three Foo's should be aggregated: two of them are in the same account, so
+ * they don't get aggregated; the third has two affinities, so it does not
+ * join either of them.
+ * <p>
+ * Consider what happens if one of the "Foo"s from account A1 becomes
+ * invisible. Nothing stands in the way of aggregating the other two
+ * anymore, so they should get joined.
+ * <p>
+ * What if the invisible "Foo" becomes visible after that? We should split the
+ * aggregate between the other two.
+ */
+ public void updateAggregationAfterVisibilityChange(long contactId) {
+ SQLiteDatabase db = mDbHelper.getWritableDatabase();
+ boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId);
+ if (visible) {
+ markContactForAggregation(db, contactId);
+ } else {
+ // Find all contacts that _could be_ aggregated with this one and
+ // rerun aggregation for all of them
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
+ RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null);
+ try {
+ while (cursor.moveToNext()) {
+ long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID);
+ mMatcher.clear();
+
+ updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher);
+ updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher);
+ List<MatchScore> bestMatches =
+ mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_PRIMARY);
+ for (MatchScore matchScore : bestMatches) {
+ markContactForAggregation(db, matchScore.getContactId());
+ }
+
+ mMatcher.clear();
+ updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher);
+ updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher);
+ bestMatches =
+ mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SECONDARY);
+ for (MatchScore matchScore : bestMatches) {
+ markContactForAggregation(db, matchScore.getContactId());
+ }
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+
+ /**
+ * Updates the contact ID for the specified contact.
+ */
+ protected void setContactId(long rawContactId, long contactId) {
+ mContactIdUpdate.bindLong(1, contactId);
+ mContactIdUpdate.bindLong(2, rawContactId);
+ mContactIdUpdate.execute();
+ }
+
+ /**
+ * Marks the specified raw contact ID as aggregated
+ */
+ private void markAggregated(long rawContactId) {
+ mMarkAggregatedUpdate.bindLong(1, rawContactId);
+ mMarkAggregatedUpdate.execute();
+ }
+
+ /**
+ * Updates the contact ID for the specified contact and marks the raw contact as aggregated.
+ */
+ private void setContactIdAndMarkAggregated(long rawContactId, long contactId) {
+ mContactIdAndMarkAggregatedUpdate.bindLong(1, contactId);
+ mContactIdAndMarkAggregatedUpdate.bindLong(2, rawContactId);
+ mContactIdAndMarkAggregatedUpdate.execute();
+ }
+
+ private void setPresenceContactId(long rawContactId, long contactId) {
+ mPresenceContactIdUpdate.bindLong(1, contactId);
+ mPresenceContactIdUpdate.bindLong(2, rawContactId);
+ mPresenceContactIdUpdate.execute();
+ }
+
+ interface AggregateExceptionPrefetchQuery {
+ String TABLE = Tables.AGGREGATION_EXCEPTIONS;
+
+ String[] COLUMNS = {
+ AggregationExceptions.RAW_CONTACT_ID1,
+ AggregationExceptions.RAW_CONTACT_ID2,
+ };
+
+ int RAW_CONTACT_ID1 = 0;
+ int RAW_CONTACT_ID2 = 1;
+ }
+
+ // A set of raw contact IDs for which there are aggregation exceptions
+ private final HashSet<Long> mAggregationExceptionIds = new HashSet<Long>();
+ private boolean mAggregationExceptionIdsValid;
+
+ public void invalidateAggregationExceptionCache() {
+ mAggregationExceptionIdsValid = false;
+ }
+
+ /**
+ * Finds all raw contact IDs for which there are aggregation exceptions. The list of
+ * ids is used as an optimization in aggregation: there is no point to run a query against
+ * the agg_exceptions table if it is known that there are no records there for a given
+ * raw contact ID.
+ */
+ private void prefetchAggregationExceptionIds(SQLiteDatabase db) {
+ mAggregationExceptionIds.clear();
+ final Cursor c = db.query(AggregateExceptionPrefetchQuery.TABLE,
+ AggregateExceptionPrefetchQuery.COLUMNS,
+ null, null, null, null, null);
+
+ try {
+ while (c.moveToNext()) {
+ long rawContactId1 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID1);
+ long rawContactId2 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID2);
+ mAggregationExceptionIds.add(rawContactId1);
+ mAggregationExceptionIds.add(rawContactId2);
+ }
+ } finally {
+ c.close();
+ }
+
+ mAggregationExceptionIdsValid = true;
+ }
+
+ interface AggregateExceptionQuery {
+ String TABLE = Tables.AGGREGATION_EXCEPTIONS
+ + " JOIN raw_contacts raw_contacts1 "
+ + " ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) "
+ + " JOIN raw_contacts raw_contacts2 "
+ + " ON (agg_exceptions.raw_contact_id2 = raw_contacts2._id) ";
+
+ String[] COLUMNS = {
+ AggregationExceptions.TYPE,
+ AggregationExceptions.RAW_CONTACT_ID1,
+ "raw_contacts1." + RawContacts.CONTACT_ID,
+ "raw_contacts1." + RawContactsColumns.AGGREGATION_NEEDED,
+ "raw_contacts2." + RawContacts.CONTACT_ID,
+ "raw_contacts2." + RawContactsColumns.AGGREGATION_NEEDED,
+ };
+
+ int TYPE = 0;
+ int RAW_CONTACT_ID1 = 1;
+ int CONTACT_ID1 = 2;
+ int AGGREGATION_NEEDED_1 = 3;
+ int CONTACT_ID2 = 4;
+ int AGGREGATION_NEEDED_2 = 5;
+ }
+
+ /**
+ * Computes match scores based on exceptions entered by the user: always match and never match.
+ * Returns the aggregate contact with the always match exception if any.
+ */
+ private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId,
+ ContactMatcher matcher) {
+ if (!mAggregationExceptionIdsValid) {
+ prefetchAggregationExceptionIds(db);
+ }
+
+ // If there are no aggregation exceptions involving this raw contact, there is no need to
+ // run a query and we can just return -1, which stands for "nothing found"
+ if (!mAggregationExceptionIds.contains(rawContactId)) {
+ return -1;
+ }
+
+ final Cursor c = db.query(AggregateExceptionQuery.TABLE,
+ AggregateExceptionQuery.COLUMNS,
+ AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId
+ + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId,
+ null, null, null, null);
+
+ try {
+ while (c.moveToNext()) {
+ int type = c.getInt(AggregateExceptionQuery.TYPE);
+ long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
+ long contactId = -1;
+ if (rawContactId == rawContactId1) {
+ if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_2) == 0
+ && !c.isNull(AggregateExceptionQuery.CONTACT_ID2)) {
+ contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2);
+ }
+ } else {
+ if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_1) == 0
+ && !c.isNull(AggregateExceptionQuery.CONTACT_ID1)) {
+ contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1);
+ }
+ }
+ if (contactId != -1) {
+ if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) {
+ matcher.keepIn(contactId);
+ } else {
+ matcher.keepOut(contactId);
+ }
+ }
+ }
+ } finally {
+ c.close();
+ }
+
+ return matcher.pickBestMatch(ContactMatcher.MAX_SCORE, true);
+ }
+
+ /**
+ * Picks the best matching contact based on matches between data elements. It considers
+ * name match to be primary and phone, email etc matches to be secondary. A good primary
+ * match triggers aggregation, while a good secondary match only triggers aggregation in
+ * the absence of a strong primary mismatch.
+ * <p>
+ * Consider these examples:
+ * <p>
+ * John Doe with phone number 111-111-1111 and Jon Doe with phone number 111-111-1111 should
+ * be aggregated (same number, similar names).
+ * <p>
+ * John Doe with phone number 111-111-1111 and Deborah Doe with phone number 111-111-1111 should
+ * not be aggregated (same number, different names).
+ */
+ private long pickBestMatchBasedOnData(SQLiteDatabase db, long rawContactId,
+ MatchCandidateList candidates, ContactMatcher matcher) {
+
+ // Find good matches based on name alone
+ long bestMatch = updateMatchScoresBasedOnDataMatches(db, rawContactId, matcher);
+ if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) {
+ // We found multiple matches on the name - do not aggregate because of the ambiguity
+ return -1;
+ } else if (bestMatch == -1) {
+ // We haven't found a good match on name, see if we have any matches on phone, email etc
+ bestMatch = pickBestMatchBasedOnSecondaryData(db, rawContactId, candidates, matcher);
+ if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) {
+ return -1;
+ }
+ }
+
+ return bestMatch;
+ }
+
+
+ /**
+ * Picks the best matching contact based on secondary data matches. The method loads
+ * structured names for all candidate contacts and recomputes match scores using approximate
+ * matching.
+ */
+ private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db,
+ long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
+ List<Long> secondaryContactIds = matcher.prepareSecondaryMatchCandidates(
+ ContactMatcher.SCORE_THRESHOLD_PRIMARY);
+ if (secondaryContactIds == null || secondaryContactIds.size() > SECONDARY_HIT_LIMIT) {
+ return -1;
+ }
+
+ loadNameMatchCandidates(db, rawContactId, candidates, true);
+
+ mSb.setLength(0);
+ mSb.append(RawContacts.CONTACT_ID).append(" IN (");
+ for (int i = 0; i < secondaryContactIds.size(); i++) {
+ if (i != 0) {
+ mSb.append(',');
+ }
+ mSb.append(secondaryContactIds.get(i));
+ }
+
+ // We only want to compare structured names to structured names
+ // at this stage, we need to ignore all other sources of name lookup data.
+ mSb.append(") AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL);
+
+ matchAllCandidates(db, mSb.toString(), candidates, matcher,
+ ContactMatcher.MATCHING_ALGORITHM_CONSERVATIVE, null);
+
+ return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY, false);
+ }
+
+ private interface NameLookupQuery {
+ String TABLE = Tables.NAME_LOOKUP;
+
+ String SELECTION = NameLookupColumns.RAW_CONTACT_ID + "=?";
+ String SELECTION_STRUCTURED_NAME_BASED =
+ SELECTION + " AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL;
+
+ String[] COLUMNS = new String[] {
+ NameLookupColumns.NORMALIZED_NAME,
+ NameLookupColumns.NAME_TYPE
+ };
+
+ int NORMALIZED_NAME = 0;
+ int NAME_TYPE = 1;
+ }
+
+ private void loadNameMatchCandidates(SQLiteDatabase db, long rawContactId,
+ MatchCandidateList candidates, boolean structuredNameBased) {
+ candidates.clear();
+ mSelectionArgs1[0] = String.valueOf(rawContactId);
+ Cursor c = db.query(NameLookupQuery.TABLE, NameLookupQuery.COLUMNS,
+ structuredNameBased
+ ? NameLookupQuery.SELECTION_STRUCTURED_NAME_BASED
+ : NameLookupQuery.SELECTION,
+ mSelectionArgs1, null, null, null);
+ try {
+ while (c.moveToNext()) {
+ String normalizedName = c.getString(NameLookupQuery.NORMALIZED_NAME);
+ int type = c.getInt(NameLookupQuery.NAME_TYPE);
+ candidates.add(normalizedName, type);
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Computes scores for contacts that have matching data rows.
+ */
+ private long updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId,
+ ContactMatcher matcher) {
+
+ updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
+ updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
+ long bestMatch = matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_PRIMARY, false);
+ if (bestMatch != -1) {
+ return bestMatch;
+ }
+
+ updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
+ updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
+
+ return -1;
+ }
+
+ private interface IdentityLookupMatchQuery {
+ final String TABLE = Tables.DATA + " dataA"
+ + " JOIN " + Tables.DATA + " dataB" +
+ " ON (dataA." + Identity.NAMESPACE + "=dataB." + Identity.NAMESPACE +
+ " AND dataA." + Identity.IDENTITY + "=dataB." + Identity.IDENTITY + ")"
+ + " JOIN " + Tables.RAW_CONTACTS +
+ " ON (dataB." + Data.RAW_CONTACT_ID + " = "
+ + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
+
+ final String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?"
+ + " AND dataA." + DataColumns.MIMETYPE_ID + "=?"
+ + " AND dataA." + Identity.NAMESPACE + " NOT NULL"
+ + " AND dataA." + Identity.IDENTITY + " NOT NULL"
+ + " AND dataB." + DataColumns.MIMETYPE_ID + "=?"
+ + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
+ + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
+
+ final String[] COLUMNS = new String[] {
+ RawContacts.CONTACT_ID
+ };
+
+ int CONTACT_ID = 0;
+ }
+
+ /**
+ * Finds contacts with exact identity matches to the the specified raw contact.
+ */
+ private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId,
+ ContactMatcher matcher) {
+ mSelectionArgs3[0] = String.valueOf(rawContactId);
+ mSelectionArgs3[1] = mSelectionArgs3[2] = String.valueOf(mMimeTypeIdIdentity);
+ Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS,
+ IdentityLookupMatchQuery.SELECTION,
+ mSelectionArgs3, RawContacts.CONTACT_ID, null, null);
+ try {
+ while (c.moveToNext()) {
+ final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID);
+ matcher.matchIdentity(contactId);
+ }
+ } finally {
+ c.close();
+ }
+
+ }
+
+ private interface NameLookupMatchQuery {
+ String TABLE = Tables.NAME_LOOKUP + " nameA"
+ + " JOIN " + Tables.NAME_LOOKUP + " nameB" +
+ " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "="
+ + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")"
+ + " JOIN " + Tables.RAW_CONTACTS +
+ " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = "
+ + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
+
+ String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?"
+ + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
+ + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
+
+ String[] COLUMNS = new String[] {
+ RawContacts.CONTACT_ID,
+ "nameA." + NameLookupColumns.NORMALIZED_NAME,
+ "nameA." + NameLookupColumns.NAME_TYPE,
+ "nameB." + NameLookupColumns.NAME_TYPE,
+ };
+
+ int CONTACT_ID = 0;
+ int NAME = 1;
+ int NAME_TYPE_A = 2;
+ int NAME_TYPE_B = 3;
+ }
+
+ /**
+ * Finds contacts with names matching the name of the specified raw contact.
+ */
+ private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId,
+ ContactMatcher matcher) {
+ mSelectionArgs1[0] = String.valueOf(rawContactId);
+ Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS,
+ NameLookupMatchQuery.SELECTION,
+ mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING);
+ try {
+ while (c.moveToNext()) {
+ long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID);
+ String name = c.getString(NameLookupMatchQuery.NAME);
+ int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A);
+ int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B);
+ matcher.matchName(contactId, nameTypeA, name,
+ nameTypeB, name, ContactMatcher.MATCHING_ALGORITHM_EXACT);
+ if (nameTypeA == NameLookupType.NICKNAME &&
+ nameTypeB == NameLookupType.NICKNAME) {
+ matcher.updateScoreWithNicknameMatch(contactId);
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ private interface NameLookupMatchQueryWithParameter {
+ String TABLE = Tables.NAME_LOOKUP
+ + " JOIN " + Tables.RAW_CONTACTS +
+ " ON (" + NameLookupColumns.RAW_CONTACT_ID + " = "
+ + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
+
+ String[] COLUMNS = new String[] {
+ RawContacts.CONTACT_ID,
+ NameLookupColumns.NORMALIZED_NAME,
+ NameLookupColumns.NAME_TYPE,
+ };
+
+ int CONTACT_ID = 0;
+ int NAME = 1;
+ int NAME_TYPE = 2;
+ }
+
+ private final class NameLookupSelectionBuilder extends NameLookupBuilder {
+
+ private final MatchCandidateList mNameLookupCandidates;
+
+ private StringBuilder mSelection = new StringBuilder(
+ NameLookupColumns.NORMALIZED_NAME + " IN(");
+
+
+ public NameLookupSelectionBuilder(NameSplitter splitter, MatchCandidateList candidates) {
+ super(splitter);
+ this.mNameLookupCandidates = candidates;
+ }
+
+ @Override
+ protected String[] getCommonNicknameClusters(String normalizedName) {
+ return mCommonNicknameCache.getCommonNicknameClusters(normalizedName);
+ }
+
+ @Override
+ protected void insertNameLookup(
+ long rawContactId, long dataId, int lookupType, String string) {
+ mNameLookupCandidates.add(string, lookupType);
+ DatabaseUtils.appendEscapedSQLString(mSelection, string);
+ mSelection.append(',');
+ }
+
+ public boolean isEmpty() {
+ return mNameLookupCandidates.isEmpty();
+ }
+
+ public String getSelection() {
+ mSelection.setLength(mSelection.length() - 1); // Strip last comma
+ mSelection.append(')');
+ return mSelection.toString();
+ }
+
+ public int getLookupType(String name) {
+ for (int i = 0; i < mNameLookupCandidates.mCount; i++) {
+ if (mNameLookupCandidates.mList.get(i).mName.equals(name)) {
+ return mNameLookupCandidates.mList.get(i).mLookupType;
+ }
+ }
+ throw new IllegalStateException();
+ }
+ }
+
+ /**
+ * Finds contacts with names matching the specified name.
+ */
+ private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query,
+ MatchCandidateList candidates, ContactMatcher matcher) {
+ candidates.clear();
+ NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder(
+ mNameSplitter, candidates);
+ builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED);
+ if (builder.isEmpty()) {
+ return;
+ }
+
+ Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE,
+ NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null,
+ null, PRIMARY_HIT_LIMIT_STRING);
+ try {
+ while (c.moveToNext()) {
+ long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID);
+ String name = c.getString(NameLookupMatchQueryWithParameter.NAME);
+ int nameTypeA = builder.getLookupType(name);
+ int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE);
+ matcher.matchName(contactId, nameTypeA, name, nameTypeB, name,
+ ContactMatcher.MATCHING_ALGORITHM_EXACT);
+ if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) {
+ matcher.updateScoreWithNicknameMatch(contactId);
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ private interface EmailLookupQuery {
+ String TABLE = Tables.DATA + " dataA"
+ + " JOIN " + Tables.DATA + " dataB" +
+ " ON (" + "dataA." + Email.DATA + "=dataB." + Email.DATA + ")"
+ + " JOIN " + Tables.RAW_CONTACTS +
+ " ON (dataB." + Data.RAW_CONTACT_ID + " = "
+ + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
+
+ String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?"
+ + " AND dataA." + DataColumns.MIMETYPE_ID + "=?"
+ + " AND dataA." + Email.DATA + " NOT NULL"
+ + " AND dataB." + DataColumns.MIMETYPE_ID + "=?"
+ + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
+ + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
+
+ String[] COLUMNS = new String[] {
+ RawContacts.CONTACT_ID
+ };
+
+ int CONTACT_ID = 0;
+ }
+
+ private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId,
+ ContactMatcher matcher) {
+ mSelectionArgs3[0] = String.valueOf(rawContactId);
+ mSelectionArgs3[1] = mSelectionArgs3[2] = String.valueOf(mMimeTypeIdEmail);
+ Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS,
+ EmailLookupQuery.SELECTION,
+ mSelectionArgs3, null, null, null, SECONDARY_HIT_LIMIT_STRING);
+ try {
+ while (c.moveToNext()) {
+ long contactId = c.getLong(EmailLookupQuery.CONTACT_ID);
+ matcher.updateScoreWithEmailMatch(contactId);
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ private interface PhoneLookupQuery {
+ String TABLE = Tables.PHONE_LOOKUP + " phoneA"
+ + " JOIN " + Tables.DATA + " dataA"
+ + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")"
+ + " JOIN " + Tables.PHONE_LOOKUP + " phoneB"
+ + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "="
+ + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")"
+ + " JOIN " + Tables.DATA + " dataB"
+ + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")"
+ + " JOIN " + Tables.RAW_CONTACTS
+ + " ON (dataB." + Data.RAW_CONTACT_ID + " = "
+ + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
+
+ String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?"
+ + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", "
+ + "dataB." + Phone.NUMBER + ",?)"
+ + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
+ + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
+
+ String[] COLUMNS = new String[] {
+ RawContacts.CONTACT_ID
+ };
+
+ int CONTACT_ID = 0;
+ }
+
+ private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId,
+ ContactMatcher matcher) {
+ mSelectionArgs2[0] = String.valueOf(rawContactId);
+ mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter();
+ Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS,
+ PhoneLookupQuery.SELECTION,
+ mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
+ try {
+ while (c.moveToNext()) {
+ long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID);
+ matcher.updateScoreWithPhoneNumberMatch(contactId);
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ /**
+ * Loads name lookup rows for approximate name matching and updates match scores based on that
+ * data.
+ */
+ private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
+ ContactMatcher matcher) {
+ HashSet<String> firstLetters = new HashSet<String>();
+ for (int i = 0; i < candidates.mCount; i++) {
+ final NameMatchCandidate candidate = candidates.mList.get(i);
+ if (candidate.mName.length() >= 2) {
+ String firstLetter = candidate.mName.substring(0, 2);
+ if (!firstLetters.contains(firstLetter)) {
+ firstLetters.add(firstLetter);
+ final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '"
+ + firstLetter + "*') AND "
+ + NameLookupColumns.NAME_TYPE + " IN("
+ + NameLookupType.NAME_COLLATION_KEY + ","
+ + NameLookupType.EMAIL_BASED_NICKNAME + ","
+ + NameLookupType.NICKNAME + ")";
+ matchAllCandidates(db, selection, candidates, matcher,
+ ContactMatcher.MATCHING_ALGORITHM_APPROXIMATE,
+ String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT));
+ }
+ }
+ }
+ }
+
+ private interface ContactNameLookupQuery {
+ String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS;
+
+ String[] COLUMNS = new String[] {
+ RawContacts.CONTACT_ID,
+ NameLookupColumns.NORMALIZED_NAME,
+ NameLookupColumns.NAME_TYPE
+ };
+
+ int CONTACT_ID = 0;
+ int NORMALIZED_NAME = 1;
+ int NAME_TYPE = 2;
+ }
+
+ /**
+ * Loads all candidate rows from the name lookup table and updates match scores based
+ * on that data.
+ */
+ private void matchAllCandidates(SQLiteDatabase db, String selection,
+ MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit) {
+ final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS,
+ selection, null, null, null, null, limit);
+
+ try {
+ while (c.moveToNext()) {
+ Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID);
+ String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME);
+ int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE);
+
+ // Note the N^2 complexity of the following fragment. This is not a huge concern
+ // since the number of candidates is very small and in general secondary hits
+ // in the absence of primary hits are rare.
+ for (int i = 0; i < candidates.mCount; i++) {
+ NameMatchCandidate candidate = candidates.mList.get(i);
+ matcher.matchName(contactId, candidate.mLookupType, candidate.mName,
+ nameType, name, algorithm);
+ }
+ }
+ } finally {
+ c.close();
+ }
+ }
+
+ private interface RawContactsQuery {
+ String SQL_FORMAT =
+ "SELECT "
+ + RawContactsColumns.CONCRETE_ID + ","
+ + RawContactsColumns.DISPLAY_NAME + ","
+ + RawContactsColumns.DISPLAY_NAME_SOURCE + ","
+ + AccountsColumns.CONCRETE_ACCOUNT_TYPE + ","
+ + AccountsColumns.CONCRETE_ACCOUNT_NAME + ","
+ + AccountsColumns.CONCRETE_DATA_SET + ","
+ + RawContacts.SOURCE_ID + ","
+ + RawContacts.CUSTOM_RINGTONE + ","
+ + RawContacts.SEND_TO_VOICEMAIL + ","
+ + RawContacts.LAST_TIME_CONTACTED + ","
+ + RawContacts.TIMES_CONTACTED + ","
+ + RawContacts.STARRED + ","
+ + RawContacts.NAME_VERIFIED + ","
+ + DataColumns.CONCRETE_ID + ","
+ + DataColumns.CONCRETE_MIMETYPE_ID + ","
+ + Data.IS_SUPER_PRIMARY + ","
+ + Photo.PHOTO_FILE_ID +
+ " FROM " + Tables.RAW_CONTACTS +
+ " JOIN " + Tables.ACCOUNTS + " ON ("
+ + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
+ + ")" +
+ " LEFT OUTER JOIN " + Tables.DATA +
+ " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
+ + " AND ((" + DataColumns.MIMETYPE_ID + "=%d"
+ + " AND " + Photo.PHOTO + " NOT NULL)"
+ + " OR (" + DataColumns.MIMETYPE_ID + "=%d"
+ + " AND " + Phone.NUMBER + " NOT NULL)))";
+
+ String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT +
+ " WHERE " + RawContactsColumns.CONCRETE_ID + "=?";
+
+ String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT +
+ " WHERE " + RawContacts.CONTACT_ID + "=?"
+ + " AND " + RawContacts.DELETED + "=0";
+
+ int RAW_CONTACT_ID = 0;
+ int DISPLAY_NAME = 1;
+ int DISPLAY_NAME_SOURCE = 2;
+ int ACCOUNT_TYPE = 3;
+ int ACCOUNT_NAME = 4;
+ int DATA_SET = 5;
+ int SOURCE_ID = 6;
+ int CUSTOM_RINGTONE = 7;
+ int SEND_TO_VOICEMAIL = 8;
+ int LAST_TIME_CONTACTED = 9;
+ int TIMES_CONTACTED = 10;
+ int STARRED = 11;
+ int NAME_VERIFIED = 12;
+ int DATA_ID = 13;
+ int MIMETYPE_ID = 14;
+ int IS_SUPER_PRIMARY = 15;
+ int PHOTO_FILE_ID = 16;
+ }
+
+ private interface ContactReplaceSqlStatement {
+ String UPDATE_SQL =
+ "UPDATE " + Tables.CONTACTS +
+ " SET "
+ + Contacts.NAME_RAW_CONTACT_ID + "=?, "
+ + Contacts.PHOTO_ID + "=?, "
+ + Contacts.PHOTO_FILE_ID + "=?, "
+ + Contacts.SEND_TO_VOICEMAIL + "=?, "
+ + Contacts.CUSTOM_RINGTONE + "=?, "
+ + Contacts.LAST_TIME_CONTACTED + "=?, "
+ + Contacts.TIMES_CONTACTED + "=?, "
+ + Contacts.STARRED + "=?, "
+ + Contacts.HAS_PHONE_NUMBER + "=?, "
+ + Contacts.LOOKUP_KEY + "=? " +
+ " WHERE " + Contacts._ID + "=?";
+
+ String INSERT_SQL =
+ "INSERT INTO " + Tables.CONTACTS + " ("
+ + Contacts.NAME_RAW_CONTACT_ID + ", "
+ + Contacts.PHOTO_ID + ", "
+ + Contacts.PHOTO_FILE_ID + ", "
+ + Contacts.SEND_TO_VOICEMAIL + ", "
+ + Contacts.CUSTOM_RINGTONE + ", "
+ + Contacts.LAST_TIME_CONTACTED + ", "
+ + Contacts.TIMES_CONTACTED + ", "
+ + Contacts.STARRED + ", "
+ + Contacts.HAS_PHONE_NUMBER + ", "
+ + Contacts.LOOKUP_KEY + ") " +
+ " VALUES (?,?,?,?,?,?,?,?,?,?)";
+
+ int NAME_RAW_CONTACT_ID = 1;
+ int PHOTO_ID = 2;
+ int PHOTO_FILE_ID = 3;
+ int SEND_TO_VOICEMAIL = 4;
+ int CUSTOM_RINGTONE = 5;
+ int LAST_TIME_CONTACTED = 6;
+ int TIMES_CONTACTED = 7;
+ int STARRED = 8;
+ int HAS_PHONE_NUMBER = 9;
+ int LOOKUP_KEY = 10;
+ int CONTACT_ID = 11;
+ }
+
+ /**
+ * Computes aggregate-level data for the specified aggregate contact ID.
+ */
+ private void computeAggregateData(SQLiteDatabase db, long contactId,
+ SQLiteStatement statement) {
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ computeAggregateData(db, mRawContactsQueryByContactId, mSelectionArgs1, statement);
+ }
+
+ /**
+ * Indicates whether the given photo entry and priority gives this photo a higher overall
+ * priority than the current best photo entry and priority.
+ */
+ private boolean hasHigherPhotoPriority(PhotoEntry photoEntry, int priority,
+ PhotoEntry bestPhotoEntry, int bestPriority) {
+ int photoComparison = photoEntry.compareTo(bestPhotoEntry);
+ return photoComparison < 0 || photoComparison == 0 && priority > bestPriority;
+ }
+
+ /**
+ * Computes aggregate-level data from constituent raw contacts.
+ */
+ private void computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs,
+ SQLiteStatement statement) {
+ long currentRawContactId = -1;
+ long bestPhotoId = -1;
+ long bestPhotoFileId = 0;
+ PhotoEntry bestPhotoEntry = null;
+ boolean foundSuperPrimaryPhoto = false;
+ int photoPriority = -1;
+ int totalRowCount = 0;
+ int contactSendToVoicemail = 0;
+ String contactCustomRingtone = null;
+ long contactLastTimeContacted = 0;
+ int contactTimesContacted = 0;
+ int contactStarred = 0;
+ int hasPhoneNumber = 0;
+ StringBuilder lookupKey = new StringBuilder();
+
+ mDisplayNameCandidate.clear();
+
+ Cursor c = db.rawQuery(sql, sqlArgs);
+ try {
+ while (c.moveToNext()) {
+ long rawContactId = c.getLong(RawContactsQuery.RAW_CONTACT_ID);
+ if (rawContactId != currentRawContactId) {
+ currentRawContactId = rawContactId;
+ totalRowCount++;
+
+ // Assemble sub-account.
+ String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
+ String dataSet = c.getString(RawContactsQuery.DATA_SET);
+ String accountWithDataSet = (!TextUtils.isEmpty(dataSet))
+ ? accountType + "/" + dataSet
+ : accountType;
+
+ // Display name
+ String displayName = c.getString(RawContactsQuery.DISPLAY_NAME);
+ int displayNameSource = c.getInt(RawContactsQuery.DISPLAY_NAME_SOURCE);
+ int nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED);
+ processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
+ mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet),
+ nameVerified != 0);
+
+ // Contact options
+ if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) {
+ boolean sendToVoicemail =
+ (c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0);
+ if (sendToVoicemail) {
+ contactSendToVoicemail++;
+ }
+ }
+
+ if (contactCustomRingtone == null
+ && !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) {
+ contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE);
+ }
+
+ long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED);
+ if (lastTimeContacted > contactLastTimeContacted) {
+ contactLastTimeContacted = lastTimeContacted;
+ }
+
+ int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED);
+ if (timesContacted > contactTimesContacted) {
+ contactTimesContacted = timesContacted;
+ }
+
+ if (c.getInt(RawContactsQuery.STARRED) != 0) {
+ contactStarred = 1;
+ }
+
+ appendLookupKey(
+ lookupKey,
+ accountWithDataSet,
+ c.getString(RawContactsQuery.ACCOUNT_NAME),
+ rawContactId,
+ c.getString(RawContactsQuery.SOURCE_ID),
+ displayName);
+ }
+
+ if (!c.isNull(RawContactsQuery.DATA_ID)) {
+ long dataId = c.getLong(RawContactsQuery.DATA_ID);
+ long photoFileId = c.getLong(RawContactsQuery.PHOTO_FILE_ID);
+ int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID);
+ boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0;
+ if (mimetypeId == mMimeTypeIdPhoto) {
+ if (!foundSuperPrimaryPhoto) {
+ // Lookup the metadata for the photo, if available. Note that data set
+ // does not come into play here, since accounts are looked up in the
+ // account manager in the priority resolver.
+ PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
+ String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
+ int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
+ if (superPrimary || hasHigherPhotoPriority(
+ photoEntry, priority, bestPhotoEntry, photoPriority)) {
+ bestPhotoEntry = photoEntry;
+ photoPriority = priority;
+ bestPhotoId = dataId;
+ bestPhotoFileId = photoFileId;
+ foundSuperPrimaryPhoto |= superPrimary;
+ }
+ }
+ } else if (mimetypeId == mMimeTypeIdPhone) {
+ hasPhoneNumber = 1;
+ }
+ }
+ }
+ } finally {
+ c.close();
+ }
+
+ statement.bindLong(ContactReplaceSqlStatement.NAME_RAW_CONTACT_ID,
+ mDisplayNameCandidate.rawContactId);
+
+ if (bestPhotoId != -1) {
+ statement.bindLong(ContactReplaceSqlStatement.PHOTO_ID, bestPhotoId);
+ } else {
+ statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID);
+ }
+
+ if (bestPhotoFileId != 0) {
+ statement.bindLong(ContactReplaceSqlStatement.PHOTO_FILE_ID, bestPhotoFileId);
+ } else {
+ statement.bindNull(ContactReplaceSqlStatement.PHOTO_FILE_ID);
+ }
+
+ statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL,
+ totalRowCount == contactSendToVoicemail ? 1 : 0);
+ DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE,
+ contactCustomRingtone);
+ statement.bindLong(ContactReplaceSqlStatement.LAST_TIME_CONTACTED,
+ contactLastTimeContacted);
+ statement.bindLong(ContactReplaceSqlStatement.TIMES_CONTACTED,
+ contactTimesContacted);
+ statement.bindLong(ContactReplaceSqlStatement.STARRED,
+ contactStarred);
+ statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER,
+ hasPhoneNumber);
+ statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY,
+ Uri.encode(lookupKey.toString()));
+ }
+
+ /**
+ * Builds a lookup key using the given data.
+ */
+ protected void appendLookupKey(StringBuilder sb, String accountTypeWithDataSet,
+ String accountName, long rawContactId, String sourceId, String displayName) {
+ ContactLookupKey.appendToLookupKey(sb, accountTypeWithDataSet, accountName, rawContactId,
+ sourceId, displayName);
+ }
+
+ /**
+ * Uses the supplied values to determine if they represent a "better" display name
+ * for the aggregate contact currently evaluated. If so, it updates
+ * {@link #mDisplayNameCandidate} with the new values.
+ */
+ private void processDisplayNameCandidate(long rawContactId, String displayName,
+ int displayNameSource, boolean writableAccount, boolean verified) {
+
+ boolean replace = false;
+ if (mDisplayNameCandidate.rawContactId == -1) {
+ // No previous values available
+ replace = true;
+ } else if (!TextUtils.isEmpty(displayName)) {
+ if (!mDisplayNameCandidate.verified && verified) {
+ // A verified name is better than any other name
+ replace = true;
+ } else if (mDisplayNameCandidate.verified == verified) {
+ if (mDisplayNameCandidate.displayNameSource < displayNameSource) {
+ // New values come from an superior source, e.g. structured name vs phone number
+ replace = true;
+ } else if (mDisplayNameCandidate.displayNameSource == displayNameSource) {
+ if (!mDisplayNameCandidate.writableAccount && writableAccount) {
+ replace = true;
+ } else if (mDisplayNameCandidate.writableAccount == writableAccount) {
+ if (NameNormalizer.compareComplexity(displayName,
+ mDisplayNameCandidate.displayName) > 0) {
+ // New name is more complex than the previously found one
+ replace = true;
+ }
+ }
+ }
+ }
+ }
+
+ if (replace) {
+ mDisplayNameCandidate.rawContactId = rawContactId;
+ mDisplayNameCandidate.displayName = displayName;
+ mDisplayNameCandidate.displayNameSource = displayNameSource;
+ mDisplayNameCandidate.verified = verified;
+ mDisplayNameCandidate.writableAccount = writableAccount;
+ }
+ }
+
+ private interface PhotoIdQuery {
+ final String[] COLUMNS = new String[] {
+ AccountsColumns.CONCRETE_ACCOUNT_TYPE,
+ DataColumns.CONCRETE_ID,
+ Data.IS_SUPER_PRIMARY,
+ Photo.PHOTO_FILE_ID,
+ };
+
+ int ACCOUNT_TYPE = 0;
+ int DATA_ID = 1;
+ int IS_SUPER_PRIMARY = 2;
+ int PHOTO_FILE_ID = 3;
+ }
+
+ public void updatePhotoId(SQLiteDatabase db, long rawContactId) {
+
+ long contactId = mDbHelper.getContactId(rawContactId);
+ if (contactId == 0) {
+ return;
+ }
+
+ long bestPhotoId = -1;
+ long bestPhotoFileId = 0;
+ int photoPriority = -1;
+
+ long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
+
+ String tables = Tables.RAW_CONTACTS
+ + " JOIN " + Tables.ACCOUNTS + " ON ("
+ + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
+ + ")"
+ + " JOIN " + Tables.DATA + " ON("
+ + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
+ + " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND "
+ + Photo.PHOTO + " NOT NULL))";
+
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS,
+ RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
+ try {
+ PhotoEntry bestPhotoEntry = null;
+ while (c.moveToNext()) {
+ long dataId = c.getLong(PhotoIdQuery.DATA_ID);
+ long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID);
+ boolean superPrimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0;
+ PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
+
+ // Note that data set does not come into play here, since accounts are looked up in
+ // the account manager in the priority resolver.
+ String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE);
+ int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
+ if (superPrimary || hasHigherPhotoPriority(
+ photoEntry, priority, bestPhotoEntry, photoPriority)) {
+ bestPhotoEntry = photoEntry;
+ photoPriority = priority;
+ bestPhotoId = dataId;
+ bestPhotoFileId = photoFileId;
+ if (superPrimary) {
+ break;
+ }
+ }
+ }
+ } finally {
+ c.close();
+ }
+
+ if (bestPhotoId == -1) {
+ mPhotoIdUpdate.bindNull(1);
+ } else {
+ mPhotoIdUpdate.bindLong(1, bestPhotoId);
+ }
+
+ if (bestPhotoFileId == 0) {
+ mPhotoIdUpdate.bindNull(2);
+ } else {
+ mPhotoIdUpdate.bindLong(2, bestPhotoFileId);
+ }
+
+ mPhotoIdUpdate.bindLong(3, contactId);
+ mPhotoIdUpdate.execute();
+ }
+
+ private interface PhotoFileQuery {
+ final String[] COLUMNS = new String[] {
+ PhotoFiles.HEIGHT,
+ PhotoFiles.WIDTH,
+ PhotoFiles.FILESIZE
+ };
+
+ int HEIGHT = 0;
+ int WIDTH = 1;
+ int FILESIZE = 2;
+ }
+
+ private class PhotoEntry implements Comparable<PhotoEntry> {
+ // Pixel count (width * height) for the image.
+ final int pixelCount;
+
+ // File size (in bytes) of the image. Not populated if the image is a thumbnail.
+ final int fileSize;
+
+ private PhotoEntry(int pixelCount, int fileSize) {
+ this.pixelCount = pixelCount;
+ this.fileSize = fileSize;
+ }
+
+ @Override
+ public int compareTo(PhotoEntry pe) {
+ if (pe == null) {
+ return -1;
+ }
+ if (pixelCount == pe.pixelCount) {
+ return pe.fileSize - fileSize;
+ } else {
+ return pe.pixelCount - pixelCount;
+ }
+ }
+ }
+
+ private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) {
+ if (photoFileId == 0) {
+ // Assume standard thumbnail size. Don't bother getting a file size for priority;
+ // we should fall back to photo priority resolver if all we have are thumbnails.
+ int thumbDim = mContactsProvider.getMaxThumbnailPhotoDim();
+ return new PhotoEntry(thumbDim * thumbDim, 0);
+ } else {
+ Cursor c = db.query(Tables.PHOTO_FILES, PhotoFileQuery.COLUMNS, PhotoFiles._ID + "=?",
+ new String[]{String.valueOf(photoFileId)}, null, null, null);
+ try {
+ if (c.getCount() == 1) {
+ c.moveToFirst();
+ int pixelCount =
+ c.getInt(PhotoFileQuery.HEIGHT) * c.getInt(PhotoFileQuery.WIDTH);
+ return new PhotoEntry(pixelCount, c.getInt(PhotoFileQuery.FILESIZE));
+ }
+ } finally {
+ c.close();
+ }
+ }
+ return new PhotoEntry(0, 0);
+ }
+
+ private interface DisplayNameQuery {
+ String[] COLUMNS = new String[] {
+ RawContacts._ID,
+ RawContactsColumns.DISPLAY_NAME,
+ RawContactsColumns.DISPLAY_NAME_SOURCE,
+ RawContacts.NAME_VERIFIED,
+ RawContacts.SOURCE_ID,
+ RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
+ };
+
+ int _ID = 0;
+ int DISPLAY_NAME = 1;
+ int DISPLAY_NAME_SOURCE = 2;
+ int NAME_VERIFIED = 3;
+ int SOURCE_ID = 4;
+ int ACCOUNT_TYPE_AND_DATA_SET = 5;
+ }
+
+ public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) {
+ long contactId = mDbHelper.getContactId(rawContactId);
+ if (contactId == 0) {
+ return;
+ }
+
+ updateDisplayNameForContact(db, contactId);
+ }
+
+ public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) {
+ boolean lookupKeyUpdateNeeded = false;
+
+ mDisplayNameCandidate.clear();
+
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ final Cursor c = db.query(Views.RAW_CONTACTS, DisplayNameQuery.COLUMNS,
+ RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
+ try {
+ while (c.moveToNext()) {
+ long rawContactId = c.getLong(DisplayNameQuery._ID);
+ String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME);
+ int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE);
+ int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED);
+ String accountTypeAndDataSet = c.getString(
+ DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET);
+ processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
+ mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet),
+ nameVerified != 0);
+
+ // If the raw contact has no source id, the lookup key is based on the display
+ // name, so the lookup key needs to be updated.
+ lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID);
+ }
+ } finally {
+ c.close();
+ }
+
+ if (mDisplayNameCandidate.rawContactId != -1) {
+ mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId);
+ mDisplayNameUpdate.bindLong(2, contactId);
+ mDisplayNameUpdate.execute();
+ }
+
+ if (lookupKeyUpdateNeeded) {
+ updateLookupKeyForContact(db, contactId);
+ }
+ }
+
+
+ /**
+ * Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the
+ * specified raw contact.
+ */
+ public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) {
+
+ long contactId = mDbHelper.getContactId(rawContactId);
+ if (contactId == 0) {
+ return;
+ }
+
+ final SQLiteStatement hasPhoneNumberUpdate = db.compileStatement(
+ "UPDATE " + Tables.CONTACTS +
+ " SET " + Contacts.HAS_PHONE_NUMBER + "="
+ + "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)"
+ + " FROM " + Tables.DATA_JOIN_RAW_CONTACTS
+ + " WHERE " + DataColumns.MIMETYPE_ID + "=?"
+ + " AND " + Phone.NUMBER + " NOT NULL"
+ + " AND " + RawContacts.CONTACT_ID + "=?)" +
+ " WHERE " + Contacts._ID + "=?");
+ try {
+ hasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE));
+ hasPhoneNumberUpdate.bindLong(2, contactId);
+ hasPhoneNumberUpdate.bindLong(3, contactId);
+ hasPhoneNumberUpdate.execute();
+ } finally {
+ hasPhoneNumberUpdate.close();
+ }
+ }
+
+ private interface LookupKeyQuery {
+ String TABLE = Views.RAW_CONTACTS;
+ String[] COLUMNS = new String[] {
+ RawContacts._ID,
+ RawContactsColumns.DISPLAY_NAME,
+ RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
+ RawContacts.ACCOUNT_NAME,
+ RawContacts.SOURCE_ID,
+ };
+
+ int ID = 0;
+ int DISPLAY_NAME = 1;
+ int ACCOUNT_TYPE_AND_DATA_SET = 2;
+ int ACCOUNT_NAME = 3;
+ int SOURCE_ID = 4;
+ }
+
+ public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) {
+ long contactId = mDbHelper.getContactId(rawContactId);
+ if (contactId == 0) {
+ return;
+ }
+
+ updateLookupKeyForContact(db, contactId);
+ }
+
+ private void updateLookupKeyForContact(SQLiteDatabase db, long contactId) {
+ String lookupKey = computeLookupKeyForContact(db, contactId);
+
+ if (lookupKey == null) {
+ mLookupKeyUpdate.bindNull(1);
+ } else {
+ mLookupKeyUpdate.bindString(1, Uri.encode(lookupKey));
+ }
+ mLookupKeyUpdate.bindLong(2, contactId);
+
+ mLookupKeyUpdate.execute();
+ }
+
+ protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) {
+ StringBuilder sb = new StringBuilder();
+ mSelectionArgs1[0] = String.valueOf(contactId);
+ final Cursor c = db.query(LookupKeyQuery.TABLE, LookupKeyQuery.COLUMNS,
+ RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID);
+ try {
+ while (c.moveToNext()) {
+ ContactLookupKey.appendToLookupKey(sb,
+ c.getString(LookupKeyQuery.ACCOUNT_TYPE_AND_DATA_SET),
+ c.getString(LookupKeyQuery.ACCOUNT_NAME),
+ c.getLong(LookupKeyQuery.ID),
+ c.getString(LookupKeyQuery.SOURCE_ID),
+ c.getString(LookupKeyQuery.DISPLAY_NAME));
+ }
+ } finally {
+ c.close();
+ }
+ return sb.length() == 0 ? null : sb.toString();
+ }
+
+ /**
+ * Execute {@link SQLiteStatement} that will update the
+ * {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}.
+ */
+ public void updateStarred(long rawContactId) {
+ long contactId = mDbHelper.getContactId(rawContactId);
+ if (contactId == 0) {
+ return;
+ }
+
+ mStarredUpdate.bindLong(1, contactId);
+ mStarredUpdate.execute();
+ }
+
+ /**
+ * Finds matching contacts and returns a cursor on those.
+ */
+ public Cursor queryAggregationSuggestions(SQLiteQueryBuilder qb,
+ String[] projection, long contactId, int maxSuggestions, String filter,
+ ArrayList<AggregationSuggestionParameter> parameters) {
+ final SQLiteDatabase db = mDbHelper.getReadableDatabase();
+ db.beginTransaction();
+ try {
+ List<MatchScore> bestMatches = findMatchingContacts(db, contactId, parameters);
+ return queryMatchingContacts(qb, db, projection, bestMatches, maxSuggestions, filter);
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ private interface ContactIdQuery {
+ String[] COLUMNS = new String[] {
+ Contacts._ID
+ };
+
+ int _ID = 0;
+ }
+
+ /**
+ * Loads contacts with specified IDs and returns them in the order of IDs in the
+ * supplied list.
+ */
+ private Cursor queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db,
+ String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(Contacts._ID);
+ sb.append(" IN (");
+ for (int i = 0; i < bestMatches.size(); i++) {
+ MatchScore matchScore = bestMatches.get(i);
+ if (i != 0) {
+ sb.append(",");
+ }
+ sb.append(matchScore.getContactId());
+ }
+ sb.append(")");
+
+ if (!TextUtils.isEmpty(filter)) {
+ sb.append(" AND " + Contacts._ID + " IN ");
+ mContactsProvider.appendContactFilterAsNestedQuery(sb, filter);
+ }
+
+ // Run a query and find ids of best matching contacts satisfying the filter (if any)
+ HashSet<Long> foundIds = new HashSet<Long>();
+ Cursor cursor = db.query(qb.getTables(), ContactIdQuery.COLUMNS, sb.toString(),
+ null, null, null, null);
+ try {
+ while(cursor.moveToNext()) {
+ foundIds.add(cursor.getLong(ContactIdQuery._ID));
+ }
+ } finally {
+ cursor.close();
+ }
+
+ // Exclude all contacts that did not match the filter
+ Iterator<MatchScore> iter = bestMatches.iterator();
+ while (iter.hasNext()) {
+ long id = iter.next().getContactId();
+ if (!foundIds.contains(id)) {
+ iter.remove();
+ }
+ }
+
+ // Limit the number of returned suggestions
+ final List<MatchScore> limitedMatches;
+ if (bestMatches.size() > maxSuggestions) {
+ limitedMatches = bestMatches.subList(0, maxSuggestions);
+ } else {
+ limitedMatches = bestMatches;
+ }
+
+ // Build an in-clause with the remaining contact IDs
+ sb.setLength(0);
+ sb.append(Contacts._ID);
+ sb.append(" IN (");
+ for (int i = 0; i < limitedMatches.size(); i++) {
+ MatchScore matchScore = limitedMatches.get(i);
+ if (i != 0) {
+ sb.append(",");
+ }
+ sb.append(matchScore.getContactId());
+ }
+ sb.append(")");
+
+ // Run the final query with the required projection and contact IDs found by the first query
+ cursor = qb.query(db, projection, sb.toString(), null, null, null, Contacts._ID);
+
+ // Build a sorted list of discovered IDs
+ ArrayList<Long> sortedContactIds = new ArrayList<Long>(limitedMatches.size());
+ for (MatchScore matchScore : limitedMatches) {
+ sortedContactIds.add(matchScore.getContactId());
+ }
+
+ Collections.sort(sortedContactIds);
+
+ // Map cursor indexes according to the descending order of match scores
+ int[] positionMap = new int[limitedMatches.size()];
+ for (int i = 0; i < positionMap.length; i++) {
+ long id = limitedMatches.get(i).getContactId();
+ positionMap[i] = sortedContactIds.indexOf(id);
+ }
+
+ return new ReorderingCursorWrapper(cursor, positionMap);
+ }
+
+ /**
+ * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
+ * descending order of match score.
+ * @param parameters
+ */
+ private List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId,
+ ArrayList<AggregationSuggestionParameter> parameters) {
+
+ MatchCandidateList candidates = new MatchCandidateList();
+ ContactMatcher matcher = new ContactMatcher();
+
+ // Don't aggregate a contact with itself
+ matcher.keepOut(contactId);
+
+ if (parameters == null || parameters.size() == 0) {
+ final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
+ RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
+ try {
+ while (c.moveToNext()) {
+ long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID);
+ updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates,
+ matcher);
+ }
+ } finally {
+ c.close();
+ }
+ } else {
+ updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates,
+ matcher, parameters);
+ }
+
+ return matcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SUGGEST);
+ }
+
+ /**
+ * Computes scores for contacts that have matching data rows.
+ */
+ private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
+ long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
+
+ updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
+ updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
+ updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
+ updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
+ loadNameMatchCandidates(db, rawContactId, candidates, false);
+ lookupApproximateNameMatches(db, candidates, matcher);
+ }
+
+ private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
+ MatchCandidateList candidates, ContactMatcher matcher,
+ ArrayList<AggregationSuggestionParameter> parameters) {
+ for (AggregationSuggestionParameter parameter : parameters) {
+ if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) {
+ updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher);
+ }
+
+ // TODO: add support for other parameter kinds
+ }
+ }
+}
diff --git a/src/com/android/providers/contacts/aggregation/ProfileAggregator.java b/src/com/android/providers/contacts/aggregation/ProfileAggregator.java
new file mode 100644
index 0000000..6126184
--- /dev/null
+++ b/src/com/android/providers/contacts/aggregation/ProfileAggregator.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2011 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.providers.contacts.aggregation;
+
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteDoneException;
+import android.database.sqlite.SQLiteStatement;
+import android.provider.ContactsContract.Contacts;
+
+import com.android.providers.contacts.CommonNicknameCache;
+import com.android.providers.contacts.ContactLookupKey;
+import com.android.providers.contacts.ContactsDatabaseHelper;
+import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
+import com.android.providers.contacts.ContactsProvider2;
+import com.android.providers.contacts.NameSplitter;
+import com.android.providers.contacts.PhotoPriorityResolver;
+import com.android.providers.contacts.TransactionContext;
+
+/**
+ * A version of the ContactAggregator for use against the profile database.
+ */
+public class ProfileAggregator extends ContactAggregator {
+
+ private long mContactId;
+
+ public ProfileAggregator(ContactsProvider2 contactsProvider,
+ ContactsDatabaseHelper contactsDatabaseHelper,
+ PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter,
+ CommonNicknameCache commonNicknameCache) {
+ super(contactsProvider, contactsDatabaseHelper, photoPriorityResolver, nameSplitter,
+ commonNicknameCache);
+ }
+
+ @Override
+ protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) {
+ return ContactLookupKey.PROFILE_LOOKUP_KEY;
+ }
+
+ @Override
+ protected void appendLookupKey(StringBuilder sb, String accountTypeWithDataSet,
+ String accountName, long rawContactId, String sourceId, String displayName) {
+
+ // The profile's lookup key should always be "profile".
+ sb.setLength(0);
+ sb.append(ContactLookupKey.PROFILE_LOOKUP_KEY);
+ }
+
+ @Override
+ public long onRawContactInsert(TransactionContext txContext, SQLiteDatabase db,
+ long rawContactId) {
+ aggregateContact(txContext, db, rawContactId);
+ return mContactId;
+ }
+
+ @Override
+ public void aggregateInTransaction(TransactionContext txContext, SQLiteDatabase db) {
+ // Do nothing. The contact should already be aggregated.
+ }
+
+ @Override
+ public void aggregateContact(TransactionContext txContext, SQLiteDatabase db,
+ long rawContactId) {
+ // Profile aggregation is simple - find the single contact in the database and attach to
+ // that. We look it up each time in case the profile was deleted by a previous operation
+ // and needs re-creation.
+ SQLiteStatement profileContactIdLookup = db.compileStatement(
+ "SELECT " + Contacts._ID +
+ " FROM " + Tables.CONTACTS +
+ " ORDER BY " + Contacts._ID +
+ " LIMIT 1");
+ try {
+ mContactId = profileContactIdLookup.simpleQueryForLong();
+ updateAggregateData(txContext, mContactId);
+ } catch (SQLiteDoneException e) {
+ // No valid contact ID found, so create one.
+ mContactId = insertContact(db, rawContactId);
+ } finally {
+ profileContactIdLookup.close();
+ }
+ setContactId(rawContactId, mContactId);
+ }
+}