diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/com/android/providers/contacts/aggregation/ContactAggregator2.java | 2834 |
1 files changed, 2834 insertions, 0 deletions
diff --git a/src/com/android/providers/contacts/aggregation/ContactAggregator2.java b/src/com/android/providers/contacts/aggregation/ContactAggregator2.java new file mode 100644 index 0000000..c47d4d2 --- /dev/null +++ b/src/com/android/providers/contacts/aggregation/ContactAggregator2.java @@ -0,0 +1,2834 @@ +/* + * Copyright (C) 2015 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.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.PinnedPositions; +import android.provider.ContactsContract.RawContacts; +import android.provider.ContactsContract.StatusUpdates; +import android.text.TextUtils; +import android.util.EventLog; +import android.util.Log; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.providers.contacts.ContactLookupKey; +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 com.android.providers.contacts.aggregation.util.CommonNicknameCache; +import com.android.providers.contacts.aggregation.util.ContactMatcher; +import com.android.providers.contacts.aggregation.util.ContactMatcher.MatchScore; +import com.android.providers.contacts.database.ContactsTableUtil; +import com.android.providers.contacts.util.Clock; + +import com.google.android.collect.Maps; +import com.google.android.collect.Sets; +import com.google.common.collect.Multimap; +import com.google.common.collect.HashMultimap; + +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; +import java.util.Set; + +/** + * 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 ContactAggregator2 { + + private static final String TAG = "ContactAggregator2"; + + private static final boolean DEBUG_LOGGING = Log.isLoggable(TAG, Log.DEBUG); + 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 no less than this many raw contacts in the best matching contact during + // aggregation, don't attempt to aggregate - this is likely an error or a shared corporate + // data element. + @VisibleForTesting + static final int AGGREGATION_CONTACT_SIZE_LIMIT = 50; + + // 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; + + // Return code for the canJoinIntoContact method. + private static final int JOIN = 1; + private static final int KEEP_SEPARATE = 0; + private static final int RE_AGGREGATE = -1; + + 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 mAggregatedPresenceDelete; + private SQLiteStatement mMarkForAggregation; + private SQLiteStatement mPhotoIdUpdate; + private SQLiteStatement mDisplayNameUpdate; + private SQLiteStatement mLookupKeyUpdate; + private SQLiteStatement mStarredUpdate; + private SQLiteStatement mPinnedUpdate; + private SQLiteStatement mContactIdAndMarkAggregatedUpdate; + private SQLiteStatement mContactIdUpdate; + private SQLiteStatement mMarkAggregatedUpdate; + private SQLiteStatement mContactUpdate; + private SQLiteStatement mContactInsert; + private SQLiteStatement mResetPinnedForRawContact; + + private HashMap<Long, Integer> mRawContactsMarkedForAggregation = Maps.newHashMap(); + + private String[] mSelectionArgs1 = new String[1]; + private String[] mSelectionArgs2 = new String[2]; + + 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 isNameSuperPrimary; + boolean writableAccount; + + public DisplayNameCandidate() { + clear(); + } + + public void clear() { + rawContactId = -1; + displayName = null; + displayNameSource = DisplayNameSources.UNDEFINED; + isNameSuperPrimary = false; + writableAccount = false; + } + } + + /** + * Constructor. + */ + public ContactAggregator2(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 + "<>?"); + + 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 + "=?"); + + mPinnedUpdate = db.compileStatement("UPDATE " + Tables.CONTACTS + " SET " + + Contacts.PINNED + " = IFNULL((SELECT MIN(" + RawContacts.PINNED + ") FROM " + + Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "=" + + ContactsColumns.CONCRETE_ID + " AND " + RawContacts.PINNED + ">" + + PinnedPositions.UNPINNED + ")," + PinnedPositions.UNPINNED + ") " + + "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); + + mResetPinnedForRawContact = db.compileStatement( + "UPDATE " + Tables.RAW_CONTACTS + + " SET " + RawContacts.PINNED + "=" + PinnedPositions.UNPINNED + + " WHERE " + RawContacts._ID + "=?"); + + 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, + mDbHelper.getMimeTypeIdForStructuredName(), mMimeTypeIdPhoto, mMimeTypeIdPhone); + + mRawContactsQueryByContactId = String.format(Locale.US, + RawContactsQuery.SQL_FORMAT_BY_CONTACT_ID, + mDbHelper.getMimeTypeIdForStructuredName(), 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) { + final int markedCount = mRawContactsMarkedForAggregation.size(); + if (markedCount == 0) { + return; + } + + final long start = System.currentTimeMillis(); + if (DEBUG_LOGGING) { + Log.d(TAG, "aggregateInTransaction for " + markedCount + " contacts"); + } + + EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, start, -markedCount); + + int index = 0; + + // We don't use the cached string builder (namely mSb) here, as this string can be very + // long when upgrading (where we re-aggregate all visible contacts) and StringBuilder won't + // shrink the internal storage. + // Note: don't use selection args here. We just include all IDs directly in the selection, + // because there's a limit for the number of parameters in a query. + final StringBuilder sbQuery = new StringBuilder(); + sbQuery.append(AggregationQuery.SQL); + for (long rawContactId : mRawContactsMarkedForAggregation.keySet()) { + if (index > 0) { + sbQuery.append(','); + } + sbQuery.append(rawContactId); + index++; + } + + sbQuery.append(')'); + + final long[] rawContactIds; + final long[] contactIds; + final long[] accountIds; + final int actualCount; + final Cursor c = db.rawQuery(sbQuery.toString(), null); + try { + actualCount = c.getCount(); + rawContactIds = new long[actualCount]; + contactIds = new long[actualCount]; + accountIds = new long[actualCount]; + + 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(); + } + + if (DEBUG_LOGGING) { + Log.d(TAG, "aggregateInTransaction: initial query done."); + } + + for (int i = 0; i < actualCount; i++) { + aggregateContact(txContext, db, rawContactIds[i], accountIds[i], contactIds[i], + mCandidates, mMatcher); + } + + long elapsedTime = System.currentTimeMillis() - start; + EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, elapsedTime, actualCount); + + if (DEBUG_LOGGING) { + Log.d(TAG, "Contact aggregation complete: " + actualCount + + (actualCount == 0 ? "" : ", " + (elapsedTime / actualCount) + + " ms per raw contact")); + } + } + + @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() { + // HashMap woulnd't shrink the internal table once expands it, so let's just re-create + // a new one instead of clear()ing it. + mRawContactsMarkedForAggregation = Maps.newHashMap(); + } + + 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); + // Don't re-aggregate AGGREGATION_MODE_SUSPENDED / AGGREGATION_MODE_DISABLED. + // (Also just ignore deprecated AGGREGATION_MODE_IMMEDIATE) + if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) { + markForAggregation(rawContactId, aggregationMode, true); + } + } + } finally { + cursor.close(); + } + } + + /** + * Mark all visible contacts for re-aggregation. + * + * - Set {@link RawContactsColumns#AGGREGATION_NEEDED} For all visible raw_contacts with + * {@link RawContacts#AGGREGATION_MODE_DEFAULT}. + * - Also put them into {@link #mRawContactsMarkedForAggregation}. + */ + public int markAllVisibleForAggregation(SQLiteDatabase db) { + final long start = System.currentTimeMillis(); + + // Set AGGREGATION_NEEDED for all visible raw_cotnacts with AGGREGATION_MODE_DEFAULT. + // (Don't re-aggregate AGGREGATION_MODE_SUSPENDED / AGGREGATION_MODE_DISABLED) + db.execSQL("UPDATE " + Tables.RAW_CONTACTS + " SET " + + RawContactsColumns.AGGREGATION_NEEDED + "=1" + + " WHERE " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY + + " AND " + RawContacts.AGGREGATION_MODE + "=" + RawContacts.AGGREGATION_MODE_DEFAULT + ); + + final int count; + final Cursor cursor = db.rawQuery("SELECT " + RawContacts._ID + + " FROM " + Tables.RAW_CONTACTS + + " WHERE " + RawContactsColumns.AGGREGATION_NEEDED + "=1", null); + try { + count = cursor.getCount(); + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + final long rawContactId = cursor.getLong(0); + mRawContactsMarkedForAggregation.put(rawContactId, + RawContacts.AGGREGATION_MODE_DEFAULT); + } + } finally { + cursor.close(); + } + + final long end = System.currentTimeMillis(); + Log.i(TAG, "Marked all visible contacts for aggregation: " + count + " raw contacts, " + + (end - start) + " ms"); + return count; + } + + /** + * 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) { + + if (VERBOSE_LOGGING) { + Log.v(TAG, "aggregateContact: rid=" + rawContactId + " cid=" + currentContactId); + } + + int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT; + + Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId); + if (aggModeObject != null) { + aggregationMode = aggModeObject; + } + + long contactId = -1; // Best matching contact ID. + boolean needReaggregate = false; + + final Set<Long> rawContactIdsInSameAccount = new HashSet<Long>(); + final Set<Long> rawContactIdsInOtherAccount = new HashSet<Long>(); + 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 best matched contact, find out if the raw contact can be joined + // into it + if (contactId != -1 && contactId != currentContactId) { + // List all raw contact ID and their account ID mappings in contact + // [contactId] excluding raw_contact [rawContactId]. + + // Based on the mapping, create two sets of raw contact IDs in + // [rawContactAccountId] and not in [rawContactAccountId]. We don't always + // need them, so lazily initialize them. + mSelectionArgs2[0] = String.valueOf(contactId); + mSelectionArgs2[1] = String.valueOf(rawContactId); + final Cursor rawContactsToAccountsCursor = db.rawQuery( + "SELECT " + RawContacts._ID + ", " + RawContactsColumns.ACCOUNT_ID + + " FROM " + Tables.RAW_CONTACTS + + " WHERE " + RawContacts.CONTACT_ID + "=?" + + " AND " + RawContacts._ID + "!=?", + mSelectionArgs2); + try { + rawContactsToAccountsCursor.moveToPosition(-1); + while (rawContactsToAccountsCursor.moveToNext()) { + final long rcId = rawContactsToAccountsCursor.getLong(0); + final long rc_accountId = rawContactsToAccountsCursor.getLong(1); + if (rc_accountId == accountId) { + rawContactIdsInSameAccount.add(rcId); + } else { + rawContactIdsInOtherAccount.add(rcId); + } + } + } finally { + rawContactsToAccountsCursor.close(); + } + final int actionCode; + final int totalNumOfRawContactsInCandidate = rawContactIdsInSameAccount.size() + + rawContactIdsInOtherAccount.size(); + if (totalNumOfRawContactsInCandidate >= AGGREGATION_CONTACT_SIZE_LIMIT) { + if (VERBOSE_LOGGING) { + Log.v(TAG, "Too many raw contacts (" + totalNumOfRawContactsInCandidate + + ") in the best matching contact, so skip aggregation"); + } + actionCode = KEEP_SEPARATE; + } else { + actionCode = canJoinIntoContact(db, rawContactId, + rawContactIdsInSameAccount, rawContactIdsInOtherAccount); + } + if (actionCode == KEEP_SEPARATE) { + contactId = -1; + } else if (actionCode == RE_AGGREGATE) { + needReaggregate = true; + } + } + } + } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) { + return; + } + + // # of raw_contacts in the [currentContactId] contact excluding the [rawContactId] + // raw_contact. + 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); + if (VERBOSE_LOGGING) { + Log.v(TAG, "Aggregation unchanged"); + } + } else if (contactId == -1) { + // create new contact for [rawContactId] + createContactForRawContacts(db, txContext, Sets.newHashSet(rawContactId), null); + if (currentContactContentsCount > 0) { + updateAggregateData(txContext, currentContactId); + } + if (VERBOSE_LOGGING) { + Log.v(TAG, "create new contact for rid=" + rawContactId); + } + } else if (needReaggregate) { + // re-aggregate + final Set<Long> allRawContactIdSet = new HashSet<Long>(); + allRawContactIdSet.addAll(rawContactIdsInSameAccount); + allRawContactIdSet.addAll(rawContactIdsInOtherAccount); + // If there is no other raw contacts aggregated with the given raw contact currently, + // we might as well reuse it. + currentContactId = (currentContactId != 0 && currentContactContentsCount == 0) + ? currentContactId : 0; + reAggregateRawContacts(txContext, db, contactId, currentContactId, rawContactId, + allRawContactIdSet); + if (VERBOSE_LOGGING) { + Log.v(TAG, "Re-aggregating rid=" + rawContactId + " and cid=" + contactId); + } + } else { + // Joining with an existing aggregate + if (currentContactContentsCount == 0) { + // Delete a previous aggregate if it only contained this raw contact + ContactsTableUtil.deleteContact(db, currentContactId); + + mAggregatedPresenceDelete.bindLong(1, currentContactId); + mAggregatedPresenceDelete.execute(); + } + + clearSuperPrimarySetting(db, contactId, rawContactId); + setContactIdAndMarkAggregated(rawContactId, contactId); + computeAggregateData(db, contactId, mContactUpdate); + mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId); + mContactUpdate.execute(); + mDbHelper.updateContactVisible(txContext, contactId); + updateAggregatedStatusUpdate(contactId); + // Make sure the raw contact does not contribute to the current contact + if (currentContactId != 0) { + updateAggregateData(txContext, currentContactId); + } + if (VERBOSE_LOGGING) { + Log.v(TAG, "Join rid=" + rawContactId + " with cid=" + contactId); + } + } + } + + /** + * Find out which mime-types are shared by raw contact of {@code rawContactId} and raw contacts + * of {@code contactId}. Clear the is_super_primary settings for these mime-types. + */ + private void clearSuperPrimarySetting(SQLiteDatabase db, long contactId, long rawContactId) { + final String[] args = {String.valueOf(contactId), String.valueOf(rawContactId)}; + + // Find out which mime-types exist with is_super_primary=true on both the raw contact of + // rawContactId and raw contacts of contactId + int index = 0; + final StringBuilder mimeTypeCondition = new StringBuilder(); + mimeTypeCondition.append(" AND " + DataColumns.MIMETYPE_ID + " IN ("); + + final Cursor c = db.rawQuery( + "SELECT DISTINCT(a." + DataColumns.MIMETYPE_ID + ")" + + " FROM (SELECT " + DataColumns.MIMETYPE_ID + " FROM " + Tables.DATA + " WHERE " + + Data.IS_SUPER_PRIMARY + " =1 AND " + + Data.RAW_CONTACT_ID + " IN (SELECT " + RawContacts._ID + " FROM " + + Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "=?1)) AS a" + + " JOIN (SELECT " + DataColumns.MIMETYPE_ID + " FROM " + Tables.DATA + " WHERE " + + Data.IS_SUPER_PRIMARY + " =1 AND " + + Data.RAW_CONTACT_ID + "=?2) AS b" + + " ON a." + DataColumns.MIMETYPE_ID + "=b." + DataColumns.MIMETYPE_ID, + args); + try { + c.moveToPosition(-1); + while (c.moveToNext()) { + if (index > 0) { + mimeTypeCondition.append(','); + } + mimeTypeCondition.append(c.getLong((0))); + index++; + } + } finally { + c.close(); + } + + if (index == 0) { + return; + } + + // Clear is_super_primary setting for all the mime-types with is_super_primary=true + // in both raw contact of rawContactId and raw contacts of contactId + String superPrimaryUpdateSql = "UPDATE " + Tables.DATA + + " SET " + Data.IS_SUPER_PRIMARY + "=0" + + " WHERE (" + Data.RAW_CONTACT_ID + + " IN (SELECT " + RawContacts._ID + " FROM " + Tables.RAW_CONTACTS + + " WHERE " + RawContacts.CONTACT_ID + "=?1)" + + " OR " + Data.RAW_CONTACT_ID + "=?2)"; + + mimeTypeCondition.append(')'); + superPrimaryUpdateSql += mimeTypeCondition.toString(); + db.execSQL(superPrimaryUpdateSql, args); + } + + /** + * @return JOIN if the raw contact of {@code rawContactId} can be joined into the existing + * contact of {@code contactId}. KEEP_SEPARATE if the raw contact of {@code rawContactId} + * cannot be joined into the existing contact of {@code contactId}. RE_AGGREGATE if raw contact + * of {@code rawContactId} and all the raw contacts of contact of {@code contactId} need to be + * re-aggregated. + * + * If contact of {@code contactId} doesn't contain any raw contacts from the same account as + * raw contact of {@code rawContactId}, join raw contact with contact if there is no identity + * mismatch between them on the same namespace, otherwise, keep them separate. + * + * If contact of {@code contactId} contains raw contacts from the same account as raw contact of + * {@code rawContactId}, join raw contact with contact if there's at least one raw contact in + * those raw contacts that shares at least one email address, phone number, or identity; + * otherwise, re-aggregate raw contact and all the raw contacts of contact. + */ + private int canJoinIntoContact(SQLiteDatabase db, long rawContactId, + Set<Long> rawContactIdsInSameAccount, Set<Long> rawContactIdsInOtherAccount ) { + + if (rawContactIdsInSameAccount.isEmpty()) { + final String rid = String.valueOf(rawContactId); + final String ridsInOtherAccts = TextUtils.join(",", rawContactIdsInOtherAccount); + // If there is no identity match between raw contact of [rawContactId] and + // any raw contact in other accounts on the same namespace, and there is at least + // one identity mismatch exist, keep raw contact separate from contact. + if (DatabaseUtils.longForQuery(db, buildIdentityMatchingSql(rid, ridsInOtherAccts, + /* isIdentityMatching =*/ true, /* countOnly =*/ true), null) == 0 && + DatabaseUtils.longForQuery(db, buildIdentityMatchingSql(rid, ridsInOtherAccts, + /* isIdentityMatching =*/ false, /* countOnly =*/ true), null) > 0) { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: no duplicates, but has no matching identity " + + "and has mis-matching identity on the same namespace between rid=" + + rid + " and ridsInOtherAccts=" + ridsInOtherAccts); + } + return KEEP_SEPARATE; // has identity and identity doesn't match + } else { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: can join the first raw contact from the same " + + "account without any identity mismatch."); + } + return JOIN; // no identity or identity match + } + } + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: " + rawContactIdsInSameAccount.size() + + " duplicate(s) found"); + } + + + final Set<Long> rawContactIdSet = new HashSet<Long>(); + rawContactIdSet.add(rawContactId); + if (rawContactIdsInSameAccount.size() > 0 && + isDataMaching(db, rawContactIdSet, rawContactIdsInSameAccount)) { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: join if there is a data matching found in the " + + "same account"); + } + return JOIN; + } else { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: re-aggregate rid=" + rawContactId + + " with its best matching contact to connected component"); + } + return RE_AGGREGATE; + } + } + + private interface RawContactMatchingSelectionStatement { + String SELECT_COUNT = "SELECT count(*) " ; + String SELECT_ID = "SELECT d1." + Data.RAW_CONTACT_ID + ",d2." + Data.RAW_CONTACT_ID ; + } + + /** + * Build sql to check if there is any identity match/mis-match between two sets of raw contact + * ids on the same namespace. + */ + private String buildIdentityMatchingSql(String rawContactIdSet1, String rawContactIdSet2, + boolean isIdentityMatching, boolean countOnly) { + final String identityType = String.valueOf(mMimeTypeIdIdentity); + final String matchingOperator = (isIdentityMatching) ? "=" : "!="; + final String sql = + " FROM " + Tables.DATA + " AS d1" + + " JOIN " + Tables.DATA + " AS d2" + + " ON (d1." + Identity.IDENTITY + matchingOperator + + " d2." + Identity.IDENTITY + " AND" + + " d1." + Identity.NAMESPACE + " = d2." + Identity.NAMESPACE + " )" + + " WHERE d1." + DataColumns.MIMETYPE_ID + " = " + identityType + + " AND d2." + DataColumns.MIMETYPE_ID + " = " + identityType + + " AND d1." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet1 + ")" + + " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet2 + ")"; + return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql : + RawContactMatchingSelectionStatement.SELECT_ID + sql; + } + + private String buildEmailMatchingSql(String rawContactIdSet1, String rawContactIdSet2, + boolean countOnly) { + final String emailType = String.valueOf(mMimeTypeIdEmail); + final String sql = + " FROM " + Tables.DATA + " AS d1" + + " JOIN " + Tables.DATA + " AS d2" + + " ON lower(d1." + Email.ADDRESS + ")= lower(d2." + Email.ADDRESS + ")" + + " WHERE d1." + DataColumns.MIMETYPE_ID + " = " + emailType + + " AND d2." + DataColumns.MIMETYPE_ID + " = " + emailType + + " AND d1." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet1 + ")" + + " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet2 + ")"; + return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql : + RawContactMatchingSelectionStatement.SELECT_ID + sql; + } + + private String buildPhoneMatchingSql(String rawContactIdSet1, String rawContactIdSet2, + boolean countOnly) { + // It's a bit tricker because it has to be consistent with + // updateMatchScoresBasedOnPhoneMatches(). + final String phoneType = String.valueOf(mMimeTypeIdPhone); + final String sql = + " FROM " + Tables.PHONE_LOOKUP + " AS p1" + + " JOIN " + Tables.DATA + " AS d1 ON " + + "(d1." + Data._ID + "=p1." + PhoneLookupColumns.DATA_ID + ")" + + " JOIN " + Tables.PHONE_LOOKUP + " AS p2 ON (p1." + PhoneLookupColumns.MIN_MATCH + + "=p2." + PhoneLookupColumns.MIN_MATCH + ")" + + " JOIN " + Tables.DATA + " AS d2 ON " + + "(d2." + Data._ID + "=p2." + PhoneLookupColumns.DATA_ID + ")" + + " WHERE d1." + DataColumns.MIMETYPE_ID + " = " + phoneType + + " AND d2." + DataColumns.MIMETYPE_ID + " = " + phoneType + + " AND d1." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet1 + ")" + + " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet2 + ")" + + " AND PHONE_NUMBERS_EQUAL(d1." + Phone.NUMBER + ",d2." + Phone.NUMBER + "," + + String.valueOf(mDbHelper.getUseStrictPhoneNumberComparisonParameter()) + + ")"; + return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql : + RawContactMatchingSelectionStatement.SELECT_ID + sql; + } + + private String buildExceptionMatchingSql(String rawContactIdSet1, String rawContactIdSet2) { + return "SELECT " + AggregationExceptions.RAW_CONTACT_ID1 + ", " + + AggregationExceptions.RAW_CONTACT_ID2 + + " FROM " + Tables.AGGREGATION_EXCEPTIONS + + " WHERE " + AggregationExceptions.RAW_CONTACT_ID1 + " IN (" + + rawContactIdSet1 + ")" + + " AND " + AggregationExceptions.RAW_CONTACT_ID2 + " IN (" + rawContactIdSet2 + ")" + + " AND " + AggregationExceptions.TYPE + "=" + + AggregationExceptions.TYPE_KEEP_TOGETHER ; + } + + private boolean isFirstColumnGreaterThanZero(SQLiteDatabase db, String query) { + return DatabaseUtils.longForQuery(db, query, null) > 0; + } + + /** + * If there's any identity, email address or a phone number matching between two raw contact + * sets. + */ + private boolean isDataMaching(SQLiteDatabase db, Set<Long> rawContactIdSet1, + Set<Long> rawContactIdSet2) { + final String rawContactIds1 = TextUtils.join(",", rawContactIdSet1); + final String rawContactIds2 = TextUtils.join(",", rawContactIdSet2); + // First, check for the identity + if (isFirstColumnGreaterThanZero(db, buildIdentityMatchingSql( + rawContactIds1, rawContactIds2, /* isIdentityMatching =*/ true, + /* countOnly =*/true))) { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: identity match found between " + rawContactIds1 + + " and " + rawContactIds2); + } + return true; + } + + // Next, check for the email address. + if (isFirstColumnGreaterThanZero(db, + buildEmailMatchingSql(rawContactIds1, rawContactIds2, true))) { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: email match found between " + rawContactIds1 + + " and " + rawContactIds2); + } + return true; + } + + // Lastly, the phone number. + if (isFirstColumnGreaterThanZero(db, + buildPhoneMatchingSql(rawContactIds1, rawContactIds2, true))) { + if (VERBOSE_LOGGING) { + Log.v(TAG, "canJoinIntoContact: phone match found between " + rawContactIds1 + + " and " + rawContactIds2); + } + return true; + } + return false; + } + + /** + * Re-aggregate rawContact of {@code rawContactId} and all the raw contacts of + * {@code existingRawContactIds} into connected components. This only happens when a given + * raw contacts cannot be joined with its best matching contacts directly. + * + * Two raw contacts are considered connected if they share at least one email address, phone + * number or identity. Create new contact for each connected component except the very first + * one that doesn't contain rawContactId of {@code rawContactId}. + */ + private void reAggregateRawContacts(TransactionContext txContext, SQLiteDatabase db, + long contactId, long currentContactId, long rawContactId, + Set<Long> existingRawContactIds) { + // Find the connected component based on the aggregation exceptions or + // identity/email/phone matching for all the raw contacts of [contactId] and the give + // raw contact. + final Set<Long> allIds = new HashSet<Long>(); + allIds.add(rawContactId); + allIds.addAll(existingRawContactIds); + final Set<Set<Long>> connectedRawContactSets = findConnectedRawContacts(db, allIds); + + if (connectedRawContactSets.size() == 1) { + // If everything is connected, create one contact with [contactId] + createContactForRawContacts(db, txContext, connectedRawContactSets.iterator().next(), + contactId); + } else { + for (Set<Long> connectedRawContactIds : connectedRawContactSets) { + if (connectedRawContactIds.contains(rawContactId)) { + // crate contact for connect component containing [rawContactId], reuse + // [currentContactId] if possible. + createContactForRawContacts(db, txContext, connectedRawContactIds, + currentContactId == 0 ? null : currentContactId); + connectedRawContactSets.remove(connectedRawContactIds); + break; + } + } + // Create new contact for each connected component except the last one. The last one + // will reuse [contactId]. Only the last one can reuse [contactId] when all other raw + // contacts has already been assigned new contact Id, so that the contact aggregation + // stats could be updated correctly. + int index = connectedRawContactSets.size(); + for (Set<Long> connectedRawContactIds : connectedRawContactSets) { + if (index > 1) { + createContactForRawContacts(db, txContext, connectedRawContactIds, null); + index--; + } else { + createContactForRawContacts(db, txContext, connectedRawContactIds, contactId); + } + } + } + } + + /** + * Partition the given raw contact Ids to connected component based on aggregation exception, + * identity matching, email matching or phone matching. + */ + private Set<Set<Long>> findConnectedRawContacts(SQLiteDatabase db, Set<Long> rawContactIdSet) { + // Connections between two raw contacts + final Multimap<Long, Long> matchingRawIdPairs = HashMultimap.create(); + String rawContactIds = TextUtils.join(",", rawContactIdSet); + findIdPairs(db, buildExceptionMatchingSql(rawContactIds, rawContactIds), + matchingRawIdPairs); + findIdPairs(db, buildIdentityMatchingSql(rawContactIds, rawContactIds, + /* isIdentityMatching =*/ true, /* countOnly =*/false), matchingRawIdPairs); + findIdPairs(db, buildEmailMatchingSql(rawContactIds, rawContactIds, /* countOnly =*/false), + matchingRawIdPairs); + findIdPairs(db, buildPhoneMatchingSql(rawContactIds, rawContactIds, /* countOnly =*/false), + matchingRawIdPairs); + + return findConnectedComponents(rawContactIdSet, matchingRawIdPairs); + } + + /** + * Given a set of raw contact ids {@code rawContactIdSet} and the connection among them + * {@code matchingRawIdPairs}, find the connected components. + */ + @VisibleForTesting + static Set<Set<Long>> findConnectedComponents(Set<Long> rawContactIdSet, Multimap<Long, + Long> matchingRawIdPairs) { + Set<Set<Long>> connectedRawContactSets = new HashSet<Set<Long>>(); + Set<Long> visited = new HashSet<Long>(); + for (Long id : rawContactIdSet) { + if (!visited.contains(id)) { + Set<Long> set = new HashSet<Long>(); + findConnectedComponentForRawContact(matchingRawIdPairs, visited, id, set); + connectedRawContactSets.add(set); + } + } + return connectedRawContactSets; + } + + private static void findConnectedComponentForRawContact(Multimap<Long, Long> connections, + Set<Long> visited, Long rawContactId, Set<Long> results) { + visited.add(rawContactId); + results.add(rawContactId); + for (long match : connections.get(rawContactId)) { + if (!visited.contains(match)) { + findConnectedComponentForRawContact(connections, visited, match, results); + } + } + } + + /** + * Given a query which will return two non-null IDs in the first two columns as results, this + * method will put two entries into the given result map for each pair of different IDs, one + * keyed by each ID. + */ + private void findIdPairs(SQLiteDatabase db, String query, Multimap<Long, Long> results) { + Cursor cursor = db.rawQuery(query, null); + try { + cursor.moveToPosition(-1); + while (cursor.moveToNext()) { + long idA = cursor.getLong(0); + long idB = cursor.getLong(1); + if (idA != idB) { + results.put(idA, idB); + results.put(idB, idA); + } + } + } finally { + cursor.close(); + } + } + + /** + * Creates a new Contact for a given set of the raw contacts of {@code rawContactIds} if the + * given contactId is null. Otherwise, regroup them into contact with {@code contactId}. + */ + private void createContactForRawContacts(SQLiteDatabase db, TransactionContext txContext, + Set<Long> rawContactIds, Long contactId) { + if (rawContactIds.isEmpty()) { + // No raw contact id is provided. + return; + } + + // If contactId is not provided, generates a new one. + if (contactId == null) { + mSelectionArgs1[0]= String.valueOf(rawContactIds.iterator().next()); + computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1, + mContactInsert); + contactId = mContactInsert.executeInsert(); + } + for (Long rawContactId : rawContactIds) { + // Regrouped contacts should automatically be unpinned. + unpinRawContact(rawContactId); + setContactIdAndMarkAggregated(rawContactId, contactId); + setPresenceContactId(rawContactId, contactId); + } + updateAggregateData(txContext, 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(); + } + + private void unpinRawContact(long rawContactId) { + mResetPinnedForRawContact.bindLong(1, rawContactId); + mResetPinnedForRawContact.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 + "=?1" + + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2" + + " AND dataA." + Identity.NAMESPACE + " NOT NULL" + + " AND dataA." + Identity.IDENTITY + " NOT NULL" + + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2" + + " 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) { + mSelectionArgs2[0] = String.valueOf(rawContactId); + mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity); + Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS, + IdentityLookupMatchQuery.SELECTION, + mSelectionArgs2, 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 lower(" + "dataA." + Email.DATA + ")=lower(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 + "=?1" + + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2" + + " AND dataA." + Email.DATA + " NOT NULL" + + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2" + + " 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) { + mSelectionArgs2[0] = String.valueOf(rawContactId); + mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail); + Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS, + EmailLookupQuery.SELECTION, + mSelectionArgs2, 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 + ")) AND " + + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; + 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_HAS_SUPER_PRIMARY_NAME = + " EXISTS(SELECT 1 " + + " FROM " + Tables.DATA + " d " + + " WHERE d." + DataColumns.MIMETYPE_ID + "=%d " + + " AND d." + Data.RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID + + " AND d." + Data.IS_SUPER_PRIMARY + "=1)"; + + 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.PINNED + "," + + DataColumns.CONCRETE_ID + "," + + DataColumns.CONCRETE_MIMETYPE_ID + "," + + Data.IS_SUPER_PRIMARY + "," + + Photo.PHOTO_FILE_ID + "," + + SQL_FORMAT_HAS_SUPER_PRIMARY_NAME + + " 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 PINNED = 12; + int DATA_ID = 13; + int MIMETYPE_ID = 14; + int IS_SUPER_PRIMARY = 15; + int PHOTO_FILE_ID = 16; + int HAS_SUPER_PRIMARY_NAME = 17; + } + + 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.PINNED + "=?, " + + Contacts.HAS_PHONE_NUMBER + "=?, " + + Contacts.LOOKUP_KEY + "=?, " + + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + "=? " + + " 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.PINNED + ", " + + Contacts.HAS_PHONE_NUMBER + ", " + + Contacts.LOOKUP_KEY + ", " + + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + + ") " + + " 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 PINNED = 9; + int HAS_PHONE_NUMBER = 10; + int LOOKUP_KEY = 11; + int CONTACT_LAST_UPDATED_TIMESTAMP = 12; + int CONTACT_ID = 13; + } + + /** + * 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 contactPinned = Integer.MAX_VALUE; + 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 isNameSuperPrimary = c.getInt(RawContactsQuery.HAS_SUPER_PRIMARY_NAME); + processDisplayNameCandidate(rawContactId, displayName, displayNameSource, + mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet), + isNameSuperPrimary != 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; + } + + // contactPinned should be the lowest value of its constituent raw contacts, + // excluding negative integers + final int rawContactPinned = c.getInt(RawContactsQuery.PINNED); + if (rawContactPinned > PinnedPositions.UNPINNED) { + contactPinned = Math.min(contactPinned, rawContactPinned); + } + + 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(); + } + + if (contactPinned == Integer.MAX_VALUE) { + contactPinned = PinnedPositions.UNPINNED; + } + + 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.PINNED, + contactPinned); + statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER, + hasPhoneNumber); + statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY, + Uri.encode(lookupKey.toString())); + statement.bindLong(ContactReplaceSqlStatement.CONTACT_LAST_UPDATED_TIMESTAMP, + Clock.getInstance().currentTimeMillis()); + } + + /** + * 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 isNameSuperPrimary) { + + boolean replace = false; + if (mDisplayNameCandidate.rawContactId == -1) { + // No previous values available + replace = true; + } else if (!TextUtils.isEmpty(displayName)) { + if (isNameSuperPrimary) { + // A super primary name is better than any other name + replace = true; + } else if (mDisplayNameCandidate.isNameSuperPrimary == isNameSuperPrimary) { + 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.isNameSuperPrimary = isNameSuperPrimary; + 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.getMaxThumbnailDim(); + 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 SQL_HAS_SUPER_PRIMARY_NAME = + " EXISTS(SELECT 1 " + + " FROM " + Tables.DATA + " d " + + " WHERE d." + DataColumns.MIMETYPE_ID + "=? " + + " AND d." + Data.RAW_CONTACT_ID + "=" + Views.RAW_CONTACTS + + "." + RawContacts._ID + + " AND d." + Data.IS_SUPER_PRIMARY + "=1)"; + + String SQL = + "SELECT " + + RawContacts._ID + "," + + RawContactsColumns.DISPLAY_NAME + "," + + RawContactsColumns.DISPLAY_NAME_SOURCE + "," + + SQL_HAS_SUPER_PRIMARY_NAME + "," + + RawContacts.SOURCE_ID + "," + + RawContacts.ACCOUNT_TYPE_AND_DATA_SET + + " FROM " + Views.RAW_CONTACTS + + " WHERE " + RawContacts.CONTACT_ID + "=? "; + + int _ID = 0; + int DISPLAY_NAME = 1; + int DISPLAY_NAME_SOURCE = 2; + int HAS_SUPER_PRIMARY_NAME = 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(); + + mSelectionArgs2[0] = String.valueOf(mDbHelper.getMimeTypeIdForStructuredName()); + mSelectionArgs2[1] = String.valueOf(contactId); + final Cursor c = db.rawQuery(DisplayNameQuery.SQL, mSelectionArgs2); + 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 isNameSuperPrimary = c.getInt(DisplayNameQuery.HAS_SUPER_PRIMARY_NAME); + String accountTypeAndDataSet = c.getString( + DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET); + processDisplayNameCandidate(rawContactId, displayName, displayNameSource, + mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet), + isNameSuperPrimary != 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(); + } + + /** + * Execute {@link SQLiteStatement} that will update the + * {@link Contacts#PINNED} flag for the given {@link RawContacts#_ID}. + */ + public void updatePinned(long rawContactId) { + long contactId = mDbHelper.getContactId(rawContactId); + if (contactId == 0) { + return; + } + mPinnedUpdate.bindLong(1, contactId); + mPinnedUpdate.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 + } + } +} |