blob: ce2c50af5a04f0a4b2c8dfe6261fd724c168ad27 [file] [log] [blame]
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.providers.contacts.aggregation;
import 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 ContactAggregator {
private static final String TAG = "ContactAggregator";
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 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 verified;
boolean writableAccount;
public DisplayNameCandidate() {
clear();
}
public void clear() {
rawContactId = -1;
displayName = null;
displayNameSource = DisplayNameSources.UNDEFINED;
verified = false;
writableAccount = false;
}
}
/**
* Constructor.
*/
public ContactAggregator(ContactsProvider2 contactsProvider,
ContactsDatabaseHelper contactsDatabaseHelper,
PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter,
CommonNicknameCache commonNicknameCache) {
mContactsProvider = contactsProvider;
mDbHelper = contactsDatabaseHelper;
mPhotoPriorityResolver = photoPriorityResolver;
mNameSplitter = nameSplitter;
mCommonNicknameCache = commonNicknameCache;
SQLiteDatabase db = mDbHelper.getReadableDatabase();
// Since we have no way of determining which custom status was set last,
// we'll just pick one randomly. We are using MAX as an approximation of randomness
final String replaceAggregatePresenceSql =
"INSERT OR REPLACE INTO " + Tables.AGGREGATED_PRESENCE + "("
+ AggregatedPresenceColumns.CONTACT_ID + ", "
+ StatusUpdates.PRESENCE + ", "
+ StatusUpdates.CHAT_CAPABILITY + ")"
+ " SELECT " + PresenceColumns.CONTACT_ID + ","
+ StatusUpdates.PRESENCE + ","
+ StatusUpdates.CHAT_CAPABILITY
+ " FROM " + Tables.PRESENCE
+ " WHERE "
+ " (" + StatusUpdates.PRESENCE
+ " * 10 + " + StatusUpdates.CHAT_CAPABILITY + ")"
+ " = (SELECT "
+ "MAX (" + StatusUpdates.PRESENCE
+ " * 10 + " + StatusUpdates.CHAT_CAPABILITY + ")"
+ " FROM " + Tables.PRESENCE
+ " WHERE " + PresenceColumns.CONTACT_ID
+ "=?)"
+ " AND " + PresenceColumns.CONTACT_ID
+ "=?;";
mAggregatedPresenceReplace = db.compileStatement(replaceAggregatePresenceSql);
mRawContactCountQuery = db.compileStatement(
"SELECT COUNT(" + RawContacts._ID + ")" +
" FROM " + Tables.RAW_CONTACTS +
" WHERE " + RawContacts.CONTACT_ID + "=?"
+ " AND " + RawContacts._ID + "<>?");
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,
mMimeTypeIdPhoto, mMimeTypeIdPhone);
mRawContactsQueryByContactId = String.format(Locale.US,
RawContactsQuery.SQL_FORMAT_BY_CONTACT_ID,
mMimeTypeIdPhoto, mMimeTypeIdPhone);
}
public void setEnabled(boolean enabled) {
mEnabled = enabled;
}
public boolean isEnabled() {
return mEnabled;
}
private interface AggregationQuery {
String SQL =
"SELECT " + RawContacts._ID + "," + RawContacts.CONTACT_ID +
", " + RawContactsColumns.ACCOUNT_ID +
" FROM " + Tables.RAW_CONTACTS +
" WHERE " + RawContacts._ID + " IN(";
int _ID = 0;
int CONTACT_ID = 1;
int ACCOUNT_ID = 2;
}
/**
* Aggregate all raw contacts that were marked for aggregation in the current transaction.
* Call just before committing the transaction.
*/
public void aggregateInTransaction(TransactionContext txContext, SQLiteDatabase db) {
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 = 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 are shared by 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.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.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();
}
// Clear is_super_primary setting for all the mime-types exist 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)";
if (index > 0) {
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 =
"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 + ","
+ RawContacts.NAME_VERIFIED + ","
+ DataColumns.CONCRETE_ID + ","
+ DataColumns.CONCRETE_MIMETYPE_ID + ","
+ Data.IS_SUPER_PRIMARY + ","
+ Photo.PHOTO_FILE_ID +
" FROM " + Tables.RAW_CONTACTS +
" JOIN " + Tables.ACCOUNTS + " ON ("
+ AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
+ ")" +
" LEFT OUTER JOIN " + Tables.DATA +
" ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
+ " AND ((" + DataColumns.MIMETYPE_ID + "=%d"
+ " AND " + Photo.PHOTO + " NOT NULL)"
+ " OR (" + DataColumns.MIMETYPE_ID + "=%d"
+ " AND " + Phone.NUMBER + " NOT NULL)))";
String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT +
" WHERE " + RawContactsColumns.CONCRETE_ID + "=?";
String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT +
" WHERE " + RawContacts.CONTACT_ID + "=?"
+ " AND " + RawContacts.DELETED + "=0";
int RAW_CONTACT_ID = 0;
int DISPLAY_NAME = 1;
int DISPLAY_NAME_SOURCE = 2;
int ACCOUNT_TYPE = 3;
int ACCOUNT_NAME = 4;
int DATA_SET = 5;
int SOURCE_ID = 6;
int CUSTOM_RINGTONE = 7;
int SEND_TO_VOICEMAIL = 8;
int LAST_TIME_CONTACTED = 9;
int TIMES_CONTACTED = 10;
int STARRED = 11;
int PINNED = 12;
int NAME_VERIFIED = 13;
int DATA_ID = 14;
int MIMETYPE_ID = 15;
int IS_SUPER_PRIMARY = 16;
int PHOTO_FILE_ID = 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 nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED);
processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet),
nameVerified != 0);
// Contact options
if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) {
boolean sendToVoicemail =
(c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0);
if (sendToVoicemail) {
contactSendToVoicemail++;
}
}
if (contactCustomRingtone == null
&& !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) {
contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE);
}
long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED);
if (lastTimeContacted > contactLastTimeContacted) {
contactLastTimeContacted = lastTimeContacted;
}
int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED);
if (timesContacted > contactTimesContacted) {
contactTimesContacted = timesContacted;
}
if (c.getInt(RawContactsQuery.STARRED) != 0) {
contactStarred = 1;
}
// 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 verified) {
boolean replace = false;
if (mDisplayNameCandidate.rawContactId == -1) {
// No previous values available
replace = true;
} else if (!TextUtils.isEmpty(displayName)) {
if (!mDisplayNameCandidate.verified && verified) {
// A verified name is better than any other name
replace = true;
} else if (mDisplayNameCandidate.verified == verified) {
if (mDisplayNameCandidate.displayNameSource < displayNameSource) {
// New values come from an superior source, e.g. structured name vs phone number
replace = true;
} else if (mDisplayNameCandidate.displayNameSource == displayNameSource) {
if (!mDisplayNameCandidate.writableAccount && writableAccount) {
replace = true;
} else if (mDisplayNameCandidate.writableAccount == writableAccount) {
if (NameNormalizer.compareComplexity(displayName,
mDisplayNameCandidate.displayName) > 0) {
// New name is more complex than the previously found one
replace = true;
}
}
}
}
}
if (replace) {
mDisplayNameCandidate.rawContactId = rawContactId;
mDisplayNameCandidate.displayName = displayName;
mDisplayNameCandidate.displayNameSource = displayNameSource;
mDisplayNameCandidate.verified = verified;
mDisplayNameCandidate.writableAccount = writableAccount;
}
}
private interface PhotoIdQuery {
final String[] COLUMNS = new String[] {
AccountsColumns.CONCRETE_ACCOUNT_TYPE,
DataColumns.CONCRETE_ID,
Data.IS_SUPER_PRIMARY,
Photo.PHOTO_FILE_ID,
};
int ACCOUNT_TYPE = 0;
int DATA_ID = 1;
int IS_SUPER_PRIMARY = 2;
int PHOTO_FILE_ID = 3;
}
public void updatePhotoId(SQLiteDatabase db, long rawContactId) {
long contactId = mDbHelper.getContactId(rawContactId);
if (contactId == 0) {
return;
}
long bestPhotoId = -1;
long bestPhotoFileId = 0;
int photoPriority = -1;
long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
String tables = Tables.RAW_CONTACTS
+ " JOIN " + Tables.ACCOUNTS + " ON ("
+ AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
+ ")"
+ " JOIN " + Tables.DATA + " ON("
+ DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
+ " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND "
+ Photo.PHOTO + " NOT NULL))";
mSelectionArgs1[0] = String.valueOf(contactId);
final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS,
RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
try {
PhotoEntry bestPhotoEntry = null;
while (c.moveToNext()) {
long dataId = c.getLong(PhotoIdQuery.DATA_ID);
long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID);
boolean superPrimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0;
PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
// Note that data set does not come into play here, since accounts are looked up in
// the account manager in the priority resolver.
String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE);
int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
if (superPrimary || hasHigherPhotoPriority(
photoEntry, priority, bestPhotoEntry, photoPriority)) {
bestPhotoEntry = photoEntry;
photoPriority = priority;
bestPhotoId = dataId;
bestPhotoFileId = photoFileId;
if (superPrimary) {
break;
}
}
}
} finally {
c.close();
}
if (bestPhotoId == -1) {
mPhotoIdUpdate.bindNull(1);
} else {
mPhotoIdUpdate.bindLong(1, bestPhotoId);
}
if (bestPhotoFileId == 0) {
mPhotoIdUpdate.bindNull(2);
} else {
mPhotoIdUpdate.bindLong(2, bestPhotoFileId);
}
mPhotoIdUpdate.bindLong(3, contactId);
mPhotoIdUpdate.execute();
}
private interface PhotoFileQuery {
final String[] COLUMNS = new String[] {
PhotoFiles.HEIGHT,
PhotoFiles.WIDTH,
PhotoFiles.FILESIZE
};
int HEIGHT = 0;
int WIDTH = 1;
int FILESIZE = 2;
}
private class PhotoEntry implements Comparable<PhotoEntry> {
// Pixel count (width * height) for the image.
final int pixelCount;
// File size (in bytes) of the image. Not populated if the image is a thumbnail.
final int fileSize;
private PhotoEntry(int pixelCount, int fileSize) {
this.pixelCount = pixelCount;
this.fileSize = fileSize;
}
@Override
public int compareTo(PhotoEntry pe) {
if (pe == null) {
return -1;
}
if (pixelCount == pe.pixelCount) {
return pe.fileSize - fileSize;
} else {
return pe.pixelCount - pixelCount;
}
}
}
private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) {
if (photoFileId == 0) {
// Assume standard thumbnail size. Don't bother getting a file size for priority;
// we should fall back to photo priority resolver if all we have are thumbnails.
int thumbDim = mContactsProvider.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[] COLUMNS = new String[] {
RawContacts._ID,
RawContactsColumns.DISPLAY_NAME,
RawContactsColumns.DISPLAY_NAME_SOURCE,
RawContacts.NAME_VERIFIED,
RawContacts.SOURCE_ID,
RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
};
int _ID = 0;
int DISPLAY_NAME = 1;
int DISPLAY_NAME_SOURCE = 2;
int NAME_VERIFIED = 3;
int SOURCE_ID = 4;
int ACCOUNT_TYPE_AND_DATA_SET = 5;
}
public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) {
long contactId = mDbHelper.getContactId(rawContactId);
if (contactId == 0) {
return;
}
updateDisplayNameForContact(db, contactId);
}
public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) {
boolean lookupKeyUpdateNeeded = false;
mDisplayNameCandidate.clear();
mSelectionArgs1[0] = String.valueOf(contactId);
final Cursor c = db.query(Views.RAW_CONTACTS, DisplayNameQuery.COLUMNS,
RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
try {
while (c.moveToNext()) {
long rawContactId = c.getLong(DisplayNameQuery._ID);
String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME);
int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE);
int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED);
String accountTypeAndDataSet = c.getString(
DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET);
processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet),
nameVerified != 0);
// If the raw contact has no source id, the lookup key is based on the display
// name, so the lookup key needs to be updated.
lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID);
}
} finally {
c.close();
}
if (mDisplayNameCandidate.rawContactId != -1) {
mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId);
mDisplayNameUpdate.bindLong(2, contactId);
mDisplayNameUpdate.execute();
}
if (lookupKeyUpdateNeeded) {
updateLookupKeyForContact(db, contactId);
}
}
/**
* Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the
* specified raw contact.
*/
public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) {
long contactId = mDbHelper.getContactId(rawContactId);
if (contactId == 0) {
return;
}
final SQLiteStatement hasPhoneNumberUpdate = db.compileStatement(
"UPDATE " + Tables.CONTACTS +
" SET " + Contacts.HAS_PHONE_NUMBER + "="
+ "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)"
+ " FROM " + Tables.DATA_JOIN_RAW_CONTACTS
+ " WHERE " + DataColumns.MIMETYPE_ID + "=?"
+ " AND " + Phone.NUMBER + " NOT NULL"
+ " AND " + RawContacts.CONTACT_ID + "=?)" +
" WHERE " + Contacts._ID + "=?");
try {
hasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE));
hasPhoneNumberUpdate.bindLong(2, contactId);
hasPhoneNumberUpdate.bindLong(3, contactId);
hasPhoneNumberUpdate.execute();
} finally {
hasPhoneNumberUpdate.close();
}
}
private interface LookupKeyQuery {
String TABLE = Views.RAW_CONTACTS;
String[] COLUMNS = new String[] {
RawContacts._ID,
RawContactsColumns.DISPLAY_NAME,
RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
RawContacts.ACCOUNT_NAME,
RawContacts.SOURCE_ID,
};
int ID = 0;
int DISPLAY_NAME = 1;
int ACCOUNT_TYPE_AND_DATA_SET = 2;
int ACCOUNT_NAME = 3;
int SOURCE_ID = 4;
}
public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) {
long contactId = mDbHelper.getContactId(rawContactId);
if (contactId == 0) {
return;
}
updateLookupKeyForContact(db, contactId);
}
private void updateLookupKeyForContact(SQLiteDatabase db, long contactId) {
String lookupKey = computeLookupKeyForContact(db, contactId);
if (lookupKey == null) {
mLookupKeyUpdate.bindNull(1);
} else {
mLookupKeyUpdate.bindString(1, Uri.encode(lookupKey));
}
mLookupKeyUpdate.bindLong(2, contactId);
mLookupKeyUpdate.execute();
}
protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) {
StringBuilder sb = new StringBuilder();
mSelectionArgs1[0] = String.valueOf(contactId);
final Cursor c = db.query(LookupKeyQuery.TABLE, LookupKeyQuery.COLUMNS,
RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID);
try {
while (c.moveToNext()) {
ContactLookupKey.appendToLookupKey(sb,
c.getString(LookupKeyQuery.ACCOUNT_TYPE_AND_DATA_SET),
c.getString(LookupKeyQuery.ACCOUNT_NAME),
c.getLong(LookupKeyQuery.ID),
c.getString(LookupKeyQuery.SOURCE_ID),
c.getString(LookupKeyQuery.DISPLAY_NAME));
}
} finally {
c.close();
}
return sb.length() == 0 ? null : sb.toString();
}
/**
* Execute {@link SQLiteStatement} that will update the
* {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}.
*/
public void updateStarred(long rawContactId) {
long contactId = mDbHelper.getContactId(rawContactId);
if (contactId == 0) {
return;
}
mStarredUpdate.bindLong(1, contactId);
mStarredUpdate.execute();
}
/**
* 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
}
}
}