• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2009 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package com.android.providers.contacts.aggregation;
18 
19 import android.database.Cursor;
20 import android.database.DatabaseUtils;
21 import android.database.sqlite.SQLiteDatabase;
22 import android.database.sqlite.SQLiteQueryBuilder;
23 import android.database.sqlite.SQLiteStatement;
24 import android.net.Uri;
25 import android.provider.ContactsContract.AggregationExceptions;
26 import android.provider.ContactsContract.CommonDataKinds.Email;
27 import android.provider.ContactsContract.CommonDataKinds.Identity;
28 import android.provider.ContactsContract.CommonDataKinds.Phone;
29 import android.provider.ContactsContract.CommonDataKinds.Photo;
30 import android.provider.ContactsContract.Contacts;
31 import android.provider.ContactsContract.Contacts.AggregationSuggestions;
32 import android.provider.ContactsContract.Data;
33 import android.provider.ContactsContract.DisplayNameSources;
34 import android.provider.ContactsContract.FullNameStyle;
35 import android.provider.ContactsContract.PhotoFiles;
36 import android.provider.ContactsContract.RawContacts;
37 import android.provider.ContactsContract.StatusUpdates;
38 import android.text.TextUtils;
39 import android.util.EventLog;
40 import android.util.Log;
41 
42 import com.android.providers.contacts.ContactLookupKey;
43 import com.android.providers.contacts.ContactsDatabaseHelper;
44 import com.android.providers.contacts.ContactsDatabaseHelper.AccountsColumns;
45 import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns;
46 import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
47 import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns;
48 import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns;
49 import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType;
50 import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns;
51 import com.android.providers.contacts.ContactsDatabaseHelper.PresenceColumns;
52 import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns;
53 import com.android.providers.contacts.ContactsDatabaseHelper.StatusUpdatesColumns;
54 import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
55 import com.android.providers.contacts.ContactsDatabaseHelper.Views;
56 import com.android.providers.contacts.ContactsProvider2;
57 import com.android.providers.contacts.NameLookupBuilder;
58 import com.android.providers.contacts.NameNormalizer;
59 import com.android.providers.contacts.NameSplitter;
60 import com.android.providers.contacts.PhotoPriorityResolver;
61 import com.android.providers.contacts.ReorderingCursorWrapper;
62 import com.android.providers.contacts.TransactionContext;
63 import com.android.providers.contacts.aggregation.util.CommonNicknameCache;
64 import com.android.providers.contacts.aggregation.util.ContactMatcher;
65 import com.android.providers.contacts.aggregation.util.ContactMatcher.MatchScore;
66 import com.android.providers.contacts.database.ContactsTableUtil;
67 import com.android.providers.contacts.util.Clock;
68 
69 import com.google.android.collect.Maps;
70 
71 import java.util.ArrayList;
72 import java.util.Collections;
73 import java.util.HashMap;
74 import java.util.HashSet;
75 import java.util.Iterator;
76 import java.util.List;
77 import java.util.Locale;
78 
79 /**
80  * ContactAggregator deals with aggregating contact information coming from different sources.
81  * Two John Doe contacts from two disjoint sources are presumed to be the same
82  * person unless the user declares otherwise.
83  */
84 public class ContactAggregator {
85 
86     private static final String TAG = "ContactAggregator";
87 
88     private static final boolean DEBUG_LOGGING = Log.isLoggable(TAG, Log.DEBUG);
89     private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE);
90 
91     private static final String STRUCTURED_NAME_BASED_LOOKUP_SQL =
92             NameLookupColumns.NAME_TYPE + " IN ("
93                     + NameLookupType.NAME_EXACT + ","
94                     + NameLookupType.NAME_VARIANT + ","
95                     + NameLookupType.NAME_COLLATION_KEY + ")";
96 
97 
98     /**
99      * SQL statement that sets the {@link ContactsColumns#LAST_STATUS_UPDATE_ID} column
100      * on the contact to point to the latest social status update.
101      */
102     private static final String UPDATE_LAST_STATUS_UPDATE_ID_SQL =
103             "UPDATE " + Tables.CONTACTS +
104             " SET " + ContactsColumns.LAST_STATUS_UPDATE_ID + "=" +
105                     "(SELECT " + DataColumns.CONCRETE_ID +
106                     " FROM " + Tables.STATUS_UPDATES +
107                     " JOIN " + Tables.DATA +
108                     "   ON (" + StatusUpdatesColumns.DATA_ID + "="
109                             + DataColumns.CONCRETE_ID + ")" +
110                     " JOIN " + Tables.RAW_CONTACTS +
111                     "   ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "="
112                             + RawContactsColumns.CONCRETE_ID + ")" +
113                     " WHERE " + RawContacts.CONTACT_ID + "=?" +
114                     " ORDER BY " + StatusUpdates.STATUS_TIMESTAMP + " DESC,"
115                             + StatusUpdates.STATUS +
116                     " LIMIT 1)" +
117             " WHERE " + ContactsColumns.CONCRETE_ID + "=?";
118 
119     // From system/core/logcat/event-log-tags
120     // aggregator [time, count] will be logged for each aggregator cycle.
121     // For the query (as opposed to the merge), count will be negative
122     public static final int LOG_SYNC_CONTACTS_AGGREGATION = 2747;
123 
124     // If we encounter more than this many contacts with matching names, aggregate only this many
125     private static final int PRIMARY_HIT_LIMIT = 15;
126     private static final String PRIMARY_HIT_LIMIT_STRING = String.valueOf(PRIMARY_HIT_LIMIT);
127 
128     // If we encounter more than this many contacts with matching phone number or email,
129     // don't attempt to aggregate - this is likely an error or a shared corporate data element.
130     private static final int SECONDARY_HIT_LIMIT = 20;
131     private static final String SECONDARY_HIT_LIMIT_STRING = String.valueOf(SECONDARY_HIT_LIMIT);
132 
133     // If we encounter more than this many contacts with matching name during aggregation
134     // suggestion lookup, ignore the remaining results.
135     private static final int FIRST_LETTER_SUGGESTION_HIT_LIMIT = 100;
136 
137     private final ContactsProvider2 mContactsProvider;
138     private final ContactsDatabaseHelper mDbHelper;
139     private PhotoPriorityResolver mPhotoPriorityResolver;
140     private final NameSplitter mNameSplitter;
141     private final CommonNicknameCache mCommonNicknameCache;
142 
143     private boolean mEnabled = true;
144 
145     /** Precompiled sql statement for setting an aggregated presence */
146     private SQLiteStatement mAggregatedPresenceReplace;
147     private SQLiteStatement mPresenceContactIdUpdate;
148     private SQLiteStatement mRawContactCountQuery;
149     private SQLiteStatement mAggregatedPresenceDelete;
150     private SQLiteStatement mMarkForAggregation;
151     private SQLiteStatement mPhotoIdUpdate;
152     private SQLiteStatement mDisplayNameUpdate;
153     private SQLiteStatement mLookupKeyUpdate;
154     private SQLiteStatement mStarredUpdate;
155     private SQLiteStatement mContactIdAndMarkAggregatedUpdate;
156     private SQLiteStatement mContactIdUpdate;
157     private SQLiteStatement mMarkAggregatedUpdate;
158     private SQLiteStatement mContactUpdate;
159     private SQLiteStatement mContactInsert;
160 
161     private HashMap<Long, Integer> mRawContactsMarkedForAggregation = Maps.newHashMap();
162 
163     private String[] mSelectionArgs1 = new String[1];
164     private String[] mSelectionArgs2 = new String[2];
165     private String[] mSelectionArgs3 = new String[3];
166     private long mMimeTypeIdIdentity;
167     private long mMimeTypeIdEmail;
168     private long mMimeTypeIdPhoto;
169     private long mMimeTypeIdPhone;
170     private String mRawContactsQueryByRawContactId;
171     private String mRawContactsQueryByContactId;
172     private StringBuilder mSb = new StringBuilder();
173     private MatchCandidateList mCandidates = new MatchCandidateList();
174     private ContactMatcher mMatcher = new ContactMatcher();
175     private DisplayNameCandidate mDisplayNameCandidate = new DisplayNameCandidate();
176 
177     /**
178      * Parameter for the suggestion lookup query.
179      */
180     public static final class AggregationSuggestionParameter {
181         public final String kind;
182         public final String value;
183 
AggregationSuggestionParameter(String kind, String value)184         public AggregationSuggestionParameter(String kind, String value) {
185             this.kind = kind;
186             this.value = value;
187         }
188     }
189 
190     /**
191      * Captures a potential match for a given name. The matching algorithm
192      * constructs a bunch of NameMatchCandidate objects for various potential matches
193      * and then executes the search in bulk.
194      */
195     private static class NameMatchCandidate {
196         String mName;
197         int mLookupType;
198 
NameMatchCandidate(String name, int nameLookupType)199         public NameMatchCandidate(String name, int nameLookupType) {
200             mName = name;
201             mLookupType = nameLookupType;
202         }
203     }
204 
205     /**
206      * A list of {@link NameMatchCandidate} that keeps its elements even when the list is
207      * truncated. This is done for optimization purposes to avoid excessive object allocation.
208      */
209     private static class MatchCandidateList {
210         private final ArrayList<NameMatchCandidate> mList = new ArrayList<NameMatchCandidate>();
211         private int mCount;
212 
213         /**
214          * Adds a {@link NameMatchCandidate} element or updates the next one if it already exists.
215          */
add(String name, int nameLookupType)216         public void add(String name, int nameLookupType) {
217             if (mCount >= mList.size()) {
218                 mList.add(new NameMatchCandidate(name, nameLookupType));
219             } else {
220                 NameMatchCandidate candidate = mList.get(mCount);
221                 candidate.mName = name;
222                 candidate.mLookupType = nameLookupType;
223             }
224             mCount++;
225         }
226 
clear()227         public void clear() {
228             mCount = 0;
229         }
230 
isEmpty()231         public boolean isEmpty() {
232             return mCount == 0;
233         }
234     }
235 
236     /**
237      * A convenience class used in the algorithm that figures out which of available
238      * display names to use for an aggregate contact.
239      */
240     private static class DisplayNameCandidate {
241         long rawContactId;
242         String displayName;
243         int displayNameSource;
244         boolean verified;
245         boolean writableAccount;
246 
DisplayNameCandidate()247         public DisplayNameCandidate() {
248             clear();
249         }
250 
clear()251         public void clear() {
252             rawContactId = -1;
253             displayName = null;
254             displayNameSource = DisplayNameSources.UNDEFINED;
255             verified = false;
256             writableAccount = false;
257         }
258     }
259 
260     /**
261      * Constructor.
262      */
ContactAggregator(ContactsProvider2 contactsProvider, ContactsDatabaseHelper contactsDatabaseHelper, PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter, CommonNicknameCache commonNicknameCache)263     public ContactAggregator(ContactsProvider2 contactsProvider,
264             ContactsDatabaseHelper contactsDatabaseHelper,
265             PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter,
266             CommonNicknameCache commonNicknameCache) {
267         mContactsProvider = contactsProvider;
268         mDbHelper = contactsDatabaseHelper;
269         mPhotoPriorityResolver = photoPriorityResolver;
270         mNameSplitter = nameSplitter;
271         mCommonNicknameCache = commonNicknameCache;
272 
273         SQLiteDatabase db = mDbHelper.getReadableDatabase();
274 
275         // Since we have no way of determining which custom status was set last,
276         // we'll just pick one randomly.  We are using MAX as an approximation of randomness
277         final String replaceAggregatePresenceSql =
278                 "INSERT OR REPLACE INTO " + Tables.AGGREGATED_PRESENCE + "("
279                 + AggregatedPresenceColumns.CONTACT_ID + ", "
280                 + StatusUpdates.PRESENCE + ", "
281                 + StatusUpdates.CHAT_CAPABILITY + ")"
282                 + " SELECT " + PresenceColumns.CONTACT_ID + ","
283                 + StatusUpdates.PRESENCE + ","
284                 + StatusUpdates.CHAT_CAPABILITY
285                 + " FROM " + Tables.PRESENCE
286                 + " WHERE "
287                 + " (" + StatusUpdates.PRESENCE
288                 +       " * 10 + " + StatusUpdates.CHAT_CAPABILITY + ")"
289                 + " = (SELECT "
290                 + "MAX (" + StatusUpdates.PRESENCE
291                 +       " * 10 + " + StatusUpdates.CHAT_CAPABILITY + ")"
292                 + " FROM " + Tables.PRESENCE
293                 + " WHERE " + PresenceColumns.CONTACT_ID
294                 + "=?)"
295                 + " AND " + PresenceColumns.CONTACT_ID
296                 + "=?;";
297         mAggregatedPresenceReplace = db.compileStatement(replaceAggregatePresenceSql);
298 
299         mRawContactCountQuery = db.compileStatement(
300                 "SELECT COUNT(" + RawContacts._ID + ")" +
301                 " FROM " + Tables.RAW_CONTACTS +
302                 " WHERE " + RawContacts.CONTACT_ID + "=?"
303                         + " AND " + RawContacts._ID + "<>?");
304 
305         mAggregatedPresenceDelete = db.compileStatement(
306                 "DELETE FROM " + Tables.AGGREGATED_PRESENCE +
307                 " WHERE " + AggregatedPresenceColumns.CONTACT_ID + "=?");
308 
309         mMarkForAggregation = db.compileStatement(
310                 "UPDATE " + Tables.RAW_CONTACTS +
311                 " SET " + RawContactsColumns.AGGREGATION_NEEDED + "=1" +
312                 " WHERE " + RawContacts._ID + "=?"
313                         + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0");
314 
315         mPhotoIdUpdate = db.compileStatement(
316                 "UPDATE " + Tables.CONTACTS +
317                 " SET " + Contacts.PHOTO_ID + "=?," + Contacts.PHOTO_FILE_ID + "=? " +
318                 " WHERE " + Contacts._ID + "=?");
319 
320         mDisplayNameUpdate = db.compileStatement(
321                 "UPDATE " + Tables.CONTACTS +
322                 " SET " + Contacts.NAME_RAW_CONTACT_ID + "=? " +
323                 " WHERE " + Contacts._ID + "=?");
324 
325         mLookupKeyUpdate = db.compileStatement(
326                 "UPDATE " + Tables.CONTACTS +
327                 " SET " + Contacts.LOOKUP_KEY + "=? " +
328                 " WHERE " + Contacts._ID + "=?");
329 
330         mStarredUpdate = db.compileStatement("UPDATE " + Tables.CONTACTS + " SET "
331                 + Contacts.STARRED + "=(SELECT (CASE WHEN COUNT(" + RawContacts.STARRED
332                 + ")=0 THEN 0 ELSE 1 END) FROM " + Tables.RAW_CONTACTS + " WHERE "
333                 + RawContacts.CONTACT_ID + "=" + ContactsColumns.CONCRETE_ID + " AND "
334                 + RawContacts.STARRED + "=1)" + " WHERE " + Contacts._ID + "=?");
335 
336         mContactIdAndMarkAggregatedUpdate = db.compileStatement(
337                 "UPDATE " + Tables.RAW_CONTACTS +
338                 " SET " + RawContacts.CONTACT_ID + "=?, "
339                         + RawContactsColumns.AGGREGATION_NEEDED + "=0" +
340                 " WHERE " + RawContacts._ID + "=?");
341 
342         mContactIdUpdate = db.compileStatement(
343                 "UPDATE " + Tables.RAW_CONTACTS +
344                 " SET " + RawContacts.CONTACT_ID + "=?" +
345                 " WHERE " + RawContacts._ID + "=?");
346 
347         mMarkAggregatedUpdate = db.compileStatement(
348                 "UPDATE " + Tables.RAW_CONTACTS +
349                 " SET " + RawContactsColumns.AGGREGATION_NEEDED + "=0" +
350                 " WHERE " + RawContacts._ID + "=?");
351 
352         mPresenceContactIdUpdate = db.compileStatement(
353                 "UPDATE " + Tables.PRESENCE +
354                 " SET " + PresenceColumns.CONTACT_ID + "=?" +
355                 " WHERE " + PresenceColumns.RAW_CONTACT_ID + "=?");
356 
357         mContactUpdate = db.compileStatement(ContactReplaceSqlStatement.UPDATE_SQL);
358         mContactInsert = db.compileStatement(ContactReplaceSqlStatement.INSERT_SQL);
359 
360         mMimeTypeIdEmail = mDbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE);
361         mMimeTypeIdIdentity = mDbHelper.getMimeTypeId(Identity.CONTENT_ITEM_TYPE);
362         mMimeTypeIdPhoto = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
363         mMimeTypeIdPhone = mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE);
364 
365         // Query used to retrieve data from raw contacts to populate the corresponding aggregate
366         mRawContactsQueryByRawContactId = String.format(Locale.US,
367                 RawContactsQuery.SQL_FORMAT_BY_RAW_CONTACT_ID,
368                 mMimeTypeIdPhoto, mMimeTypeIdPhone);
369 
370         mRawContactsQueryByContactId = String.format(Locale.US,
371                 RawContactsQuery.SQL_FORMAT_BY_CONTACT_ID,
372                 mMimeTypeIdPhoto, mMimeTypeIdPhone);
373     }
374 
setEnabled(boolean enabled)375     public void setEnabled(boolean enabled) {
376         mEnabled = enabled;
377     }
378 
isEnabled()379     public boolean isEnabled() {
380         return mEnabled;
381     }
382 
383     private interface AggregationQuery {
384         String SQL =
385                 "SELECT " + RawContacts._ID + "," + RawContacts.CONTACT_ID +
386                         ", " + RawContactsColumns.ACCOUNT_ID +
387                 " FROM " + Tables.RAW_CONTACTS +
388                 " WHERE " + RawContacts._ID + " IN(";
389 
390         int _ID = 0;
391         int CONTACT_ID = 1;
392         int ACCOUNT_ID = 2;
393     }
394 
395     /**
396      * Aggregate all raw contacts that were marked for aggregation in the current transaction.
397      * Call just before committing the transaction.
398      */
aggregateInTransaction(TransactionContext txContext, SQLiteDatabase db)399     public void aggregateInTransaction(TransactionContext txContext, SQLiteDatabase db) {
400         final int markedCount = mRawContactsMarkedForAggregation.size();
401         if (markedCount == 0) {
402             return;
403         }
404 
405         final long start = System.currentTimeMillis();
406         if (DEBUG_LOGGING) {
407             Log.d(TAG, "aggregateInTransaction for " + markedCount + " contacts");
408         }
409 
410         EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, start, -markedCount);
411 
412         int index = 0;
413 
414         // We don't use the cached string builder (namely mSb)  here, as this string can be very
415         // long when upgrading (where we re-aggregate all visible contacts) and StringBuilder won't
416         // shrink the internal storage.
417         // Note: don't use selection args here.  We just include all IDs directly in the selection,
418         // because there's a limit for the number of parameters in a query.
419         final StringBuilder sbQuery = new StringBuilder();
420         sbQuery.append(AggregationQuery.SQL);
421         for (long rawContactId : mRawContactsMarkedForAggregation.keySet()) {
422             if (index > 0) {
423                 sbQuery.append(',');
424             }
425             sbQuery.append(rawContactId);
426             index++;
427         }
428 
429         sbQuery.append(')');
430 
431         final long[] rawContactIds;
432         final long[] contactIds;
433         final long[] accountIds;
434         final int actualCount;
435         final Cursor c = db.rawQuery(sbQuery.toString(), null);
436         try {
437             actualCount = c.getCount();
438             rawContactIds = new long[actualCount];
439             contactIds = new long[actualCount];
440             accountIds = new long[actualCount];
441 
442             index = 0;
443             while (c.moveToNext()) {
444                 rawContactIds[index] = c.getLong(AggregationQuery._ID);
445                 contactIds[index] = c.getLong(AggregationQuery.CONTACT_ID);
446                 accountIds[index] = c.getLong(AggregationQuery.ACCOUNT_ID);
447                 index++;
448             }
449         } finally {
450             c.close();
451         }
452 
453         if (DEBUG_LOGGING) {
454             Log.d(TAG, "aggregateInTransaction: initial query done.");
455         }
456 
457         for (int i = 0; i < actualCount; i++) {
458             aggregateContact(txContext, db, rawContactIds[i], accountIds[i], contactIds[i],
459                     mCandidates, mMatcher);
460         }
461 
462         long elapsedTime = System.currentTimeMillis() - start;
463         EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, elapsedTime, actualCount);
464 
465         if (DEBUG_LOGGING) {
466             Log.d(TAG, "Contact aggregation complete: " + actualCount +
467                     (actualCount == 0 ? "" : ", " + (elapsedTime / actualCount)
468                             + " ms per raw contact"));
469         }
470     }
471 
472     @SuppressWarnings("deprecation")
triggerAggregation(TransactionContext txContext, long rawContactId)473     public void triggerAggregation(TransactionContext txContext, long rawContactId) {
474         if (!mEnabled) {
475             return;
476         }
477 
478         int aggregationMode = mDbHelper.getAggregationMode(rawContactId);
479         switch (aggregationMode) {
480             case RawContacts.AGGREGATION_MODE_DISABLED:
481                 break;
482 
483             case RawContacts.AGGREGATION_MODE_DEFAULT: {
484                 markForAggregation(rawContactId, aggregationMode, false);
485                 break;
486             }
487 
488             case RawContacts.AGGREGATION_MODE_SUSPENDED: {
489                 long contactId = mDbHelper.getContactId(rawContactId);
490 
491                 if (contactId != 0) {
492                     updateAggregateData(txContext, contactId);
493                 }
494                 break;
495             }
496 
497             case RawContacts.AGGREGATION_MODE_IMMEDIATE: {
498                 aggregateContact(txContext, mDbHelper.getWritableDatabase(), rawContactId);
499                 break;
500             }
501         }
502     }
503 
clearPendingAggregations()504     public void clearPendingAggregations() {
505         // HashMap woulnd't shrink the internal table once expands it, so let's just re-create
506         // a new one instead of clear()ing it.
507         mRawContactsMarkedForAggregation = Maps.newHashMap();
508     }
509 
markNewForAggregation(long rawContactId, int aggregationMode)510     public void markNewForAggregation(long rawContactId, int aggregationMode) {
511         mRawContactsMarkedForAggregation.put(rawContactId, aggregationMode);
512     }
513 
markForAggregation(long rawContactId, int aggregationMode, boolean force)514     public void markForAggregation(long rawContactId, int aggregationMode, boolean force) {
515         final int effectiveAggregationMode;
516         if (!force && mRawContactsMarkedForAggregation.containsKey(rawContactId)) {
517             // As per ContactsContract documentation, default aggregation mode
518             // does not override a previously set mode
519             if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
520                 effectiveAggregationMode = mRawContactsMarkedForAggregation.get(rawContactId);
521             } else {
522                 effectiveAggregationMode = aggregationMode;
523             }
524         } else {
525             mMarkForAggregation.bindLong(1, rawContactId);
526             mMarkForAggregation.execute();
527             effectiveAggregationMode = aggregationMode;
528         }
529 
530         mRawContactsMarkedForAggregation.put(rawContactId, effectiveAggregationMode);
531     }
532 
533     private static class RawContactIdAndAggregationModeQuery {
534         public static final String TABLE = Tables.RAW_CONTACTS;
535 
536         public static final String[] COLUMNS = { RawContacts._ID, RawContacts.AGGREGATION_MODE };
537 
538         public static final String SELECTION = RawContacts.CONTACT_ID + "=?";
539 
540         public static final int _ID = 0;
541         public static final int AGGREGATION_MODE = 1;
542     }
543 
544     /**
545      * Marks all constituent raw contacts of an aggregated contact for re-aggregation.
546      */
markContactForAggregation(SQLiteDatabase db, long contactId)547     private void markContactForAggregation(SQLiteDatabase db, long contactId) {
548         mSelectionArgs1[0] = String.valueOf(contactId);
549         Cursor cursor = db.query(RawContactIdAndAggregationModeQuery.TABLE,
550                 RawContactIdAndAggregationModeQuery.COLUMNS,
551                 RawContactIdAndAggregationModeQuery.SELECTION, mSelectionArgs1, null, null, null);
552         try {
553             if (cursor.moveToFirst()) {
554                 long rawContactId = cursor.getLong(RawContactIdAndAggregationModeQuery._ID);
555                 int aggregationMode = cursor.getInt(
556                         RawContactIdAndAggregationModeQuery.AGGREGATION_MODE);
557                 // Don't re-aggregate AGGREGATION_MODE_SUSPENDED / AGGREGATION_MODE_DISABLED.
558                 // (Also just ignore deprecated AGGREGATION_MODE_IMMEDIATE)
559                 if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
560                     markForAggregation(rawContactId, aggregationMode, true);
561                 }
562             }
563         } finally {
564             cursor.close();
565         }
566     }
567 
568     /**
569      * Mark all visible contacts for re-aggregation.
570      *
571      * - Set {@link RawContactsColumns#AGGREGATION_NEEDED} For all visible raw_contacts with
572      *   {@link RawContacts#AGGREGATION_MODE_DEFAULT}.
573      * - Also put them into {@link #mRawContactsMarkedForAggregation}.
574      */
markAllVisibleForAggregation(SQLiteDatabase db)575     public int markAllVisibleForAggregation(SQLiteDatabase db) {
576         final long start = System.currentTimeMillis();
577 
578         // Set AGGREGATION_NEEDED for all visible raw_cotnacts with AGGREGATION_MODE_DEFAULT.
579         // (Don't re-aggregate AGGREGATION_MODE_SUSPENDED / AGGREGATION_MODE_DISABLED)
580         db.execSQL("UPDATE " + Tables.RAW_CONTACTS + " SET " +
581                 RawContactsColumns.AGGREGATION_NEEDED + "=1" +
582                 " WHERE " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY +
583                 " AND " + RawContacts.AGGREGATION_MODE + "=" + RawContacts.AGGREGATION_MODE_DEFAULT
584                 );
585 
586         final int count;
587         final Cursor cursor = db.rawQuery("SELECT " + RawContacts._ID +
588                 " FROM " + Tables.RAW_CONTACTS +
589                 " WHERE " + RawContactsColumns.AGGREGATION_NEEDED + "=1", null);
590         try {
591             count = cursor.getCount();
592             cursor.moveToPosition(-1);
593             while (cursor.moveToNext()) {
594                 final long rawContactId = cursor.getLong(0);
595                 mRawContactsMarkedForAggregation.put(rawContactId,
596                         RawContacts.AGGREGATION_MODE_DEFAULT);
597             }
598         } finally {
599             cursor.close();
600         }
601 
602         final long end = System.currentTimeMillis();
603         Log.i(TAG, "Marked all visible contacts for aggregation: " + count + " raw contacts, " +
604                 (end - start) + " ms");
605         return count;
606     }
607 
608     /**
609      * Creates a new contact based on the given raw contact.  Does not perform aggregation.  Returns
610      * the ID of the contact that was created.
611      */
onRawContactInsert( TransactionContext txContext, SQLiteDatabase db, long rawContactId)612     public long onRawContactInsert(
613             TransactionContext txContext, SQLiteDatabase db, long rawContactId) {
614         long contactId = insertContact(db, rawContactId);
615         setContactId(rawContactId, contactId);
616         mDbHelper.updateContactVisible(txContext, contactId);
617         return contactId;
618     }
619 
insertContact(SQLiteDatabase db, long rawContactId)620     protected long insertContact(SQLiteDatabase db, long rawContactId) {
621         mSelectionArgs1[0] = String.valueOf(rawContactId);
622         computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1, mContactInsert);
623         return mContactInsert.executeInsert();
624     }
625 
626     private static final class RawContactIdAndAccountQuery {
627         public static final String TABLE = Tables.RAW_CONTACTS;
628 
629         public static final String[] COLUMNS = {
630                 RawContacts.CONTACT_ID,
631                 RawContactsColumns.ACCOUNT_ID
632         };
633 
634         public static final String SELECTION = RawContacts._ID + "=?";
635 
636         public static final int CONTACT_ID = 0;
637         public static final int ACCOUNT_ID = 1;
638     }
639 
aggregateContact( TransactionContext txContext, SQLiteDatabase db, long rawContactId)640     public void aggregateContact(
641             TransactionContext txContext, SQLiteDatabase db, long rawContactId) {
642         if (!mEnabled) {
643             return;
644         }
645 
646         MatchCandidateList candidates = new MatchCandidateList();
647         ContactMatcher matcher = new ContactMatcher();
648 
649         long contactId = 0;
650         long accountId = 0;
651         mSelectionArgs1[0] = String.valueOf(rawContactId);
652         Cursor cursor = db.query(RawContactIdAndAccountQuery.TABLE,
653                 RawContactIdAndAccountQuery.COLUMNS, RawContactIdAndAccountQuery.SELECTION,
654                 mSelectionArgs1, null, null, null);
655         try {
656             if (cursor.moveToFirst()) {
657                 contactId = cursor.getLong(RawContactIdAndAccountQuery.CONTACT_ID);
658                 accountId = cursor.getLong(RawContactIdAndAccountQuery.ACCOUNT_ID);
659             }
660         } finally {
661             cursor.close();
662         }
663 
664         aggregateContact(txContext, db, rawContactId, accountId, contactId,
665                 candidates, matcher);
666     }
667 
updateAggregateData(TransactionContext txContext, long contactId)668     public void updateAggregateData(TransactionContext txContext, long contactId) {
669         if (!mEnabled) {
670             return;
671         }
672 
673         final SQLiteDatabase db = mDbHelper.getWritableDatabase();
674         computeAggregateData(db, contactId, mContactUpdate);
675         mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId);
676         mContactUpdate.execute();
677 
678         mDbHelper.updateContactVisible(txContext, contactId);
679         updateAggregatedStatusUpdate(contactId);
680     }
681 
updateAggregatedStatusUpdate(long contactId)682     private void updateAggregatedStatusUpdate(long contactId) {
683         mAggregatedPresenceReplace.bindLong(1, contactId);
684         mAggregatedPresenceReplace.bindLong(2, contactId);
685         mAggregatedPresenceReplace.execute();
686         updateLastStatusUpdateId(contactId);
687     }
688 
689     /**
690      * Adjusts the reference to the latest status update for the specified contact.
691      */
updateLastStatusUpdateId(long contactId)692     public void updateLastStatusUpdateId(long contactId) {
693         String contactIdString = String.valueOf(contactId);
694         mDbHelper.getWritableDatabase().execSQL(UPDATE_LAST_STATUS_UPDATE_ID_SQL,
695                 new String[]{contactIdString, contactIdString});
696     }
697 
698     /**
699      * Given a specific raw contact, finds all matching aggregate contacts and chooses the one
700      * with the highest match score.  If no such contact is found, creates a new contact.
701      */
aggregateContact(TransactionContext txContext, SQLiteDatabase db, long rawContactId, long accountId, long currentContactId, MatchCandidateList candidates, ContactMatcher matcher)702     private synchronized void aggregateContact(TransactionContext txContext, SQLiteDatabase db,
703             long rawContactId, long accountId, long currentContactId, MatchCandidateList candidates,
704             ContactMatcher matcher) {
705 
706         if (VERBOSE_LOGGING) {
707             Log.v(TAG, "aggregateContact: rid=" + rawContactId + " cid=" + currentContactId);
708         }
709 
710         int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT;
711 
712         Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId);
713         if (aggModeObject != null) {
714             aggregationMode = aggModeObject;
715         }
716 
717         long contactId = -1; // Best matching contact ID.
718         long contactIdToSplit = -1;
719 
720         if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
721             candidates.clear();
722             matcher.clear();
723 
724             contactId = pickBestMatchBasedOnExceptions(db, rawContactId, matcher);
725             if (contactId == -1) {
726 
727                 // If this is a newly inserted contact or a visible contact, look for
728                 // data matches.
729                 if (currentContactId == 0
730                         || mDbHelper.isContactInDefaultDirectory(db, currentContactId)) {
731                     contactId = pickBestMatchBasedOnData(db, rawContactId, candidates, matcher);
732                 }
733 
734                 // If we found an aggregate to join, but it already contains raw contacts from
735                 // the same account, not only will we not join it, but also we will split
736                 // that other aggregate
737                 if (contactId != -1 && contactId != currentContactId &&
738                         !canJoinIntoContact(db, contactId, rawContactId, accountId)) {
739                     contactIdToSplit = contactId;
740                     contactId = -1;
741                 }
742             }
743         } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) {
744             return;
745         }
746 
747         // # of raw_contacts in the [currentContactId] contact excluding the [rawContactId]
748         // raw_contact.
749         long currentContactContentsCount = 0;
750 
751         if (currentContactId != 0) {
752             mRawContactCountQuery.bindLong(1, currentContactId);
753             mRawContactCountQuery.bindLong(2, rawContactId);
754             currentContactContentsCount = mRawContactCountQuery.simpleQueryForLong();
755         }
756 
757         // If there are no other raw contacts in the current aggregate, we might as well reuse it.
758         // Also, if the aggregation mode is SUSPENDED, we must reuse the same aggregate.
759         if (contactId == -1
760                 && currentContactId != 0
761                 && (currentContactContentsCount == 0
762                         || aggregationMode == RawContacts.AGGREGATION_MODE_SUSPENDED)) {
763             contactId = currentContactId;
764         }
765 
766         if (contactId == currentContactId) {
767             // Aggregation unchanged
768             markAggregated(rawContactId);
769         } else if (contactId == -1) {
770             // Splitting an aggregate
771             createNewContactForRawContact(txContext, db, rawContactId);
772             if (currentContactContentsCount > 0) {
773                 updateAggregateData(txContext, currentContactId);
774             }
775         } else {
776             // Joining with an existing aggregate
777             if (currentContactContentsCount == 0) {
778                 // Delete a previous aggregate if it only contained this raw contact
779                 ContactsTableUtil.deleteContact(db, currentContactId);
780 
781                 mAggregatedPresenceDelete.bindLong(1, currentContactId);
782                 mAggregatedPresenceDelete.execute();
783             }
784 
785             setContactIdAndMarkAggregated(rawContactId, contactId);
786             computeAggregateData(db, contactId, mContactUpdate);
787             mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId);
788             mContactUpdate.execute();
789             mDbHelper.updateContactVisible(txContext, contactId);
790             updateAggregatedStatusUpdate(contactId);
791             // Make sure the raw contact does not contribute to the current contact
792             if (currentContactId != 0) {
793                 updateAggregateData(txContext, currentContactId);
794             }
795         }
796 
797         if (contactIdToSplit != -1) {
798             splitAutomaticallyAggregatedRawContacts(txContext, db, contactIdToSplit);
799         }
800     }
801 
802     /**
803      * @return true if the raw contact of {@code rawContactId} can be joined into the existing
804      * contact of {@code of contactId}.
805      *
806      * Now a raw contact can be merged into a contact containing raw contacts from
807      * the same account if there's at least one raw contact in those raw contacts
808      * that shares at least one email address, phone number, or identity.
809      */
canJoinIntoContact(SQLiteDatabase db, long contactId, long rawContactId, long rawContactAccountId)810     private boolean canJoinIntoContact(SQLiteDatabase db, long contactId,
811             long rawContactId, long rawContactAccountId) {
812         // First, list all raw contact IDs in contact [contactId] on account [rawContactAccountId],
813         // excluding raw_contact [rawContactId].
814 
815         // Append all found raw contact IDs into this SB to create a comma separated list of
816         // the IDs.
817         // We don't always need it, so lazily initialize it.
818         StringBuilder rawContactIdsBuilder;
819 
820         mSelectionArgs3[0] = String.valueOf(contactId);
821         mSelectionArgs3[1] = String.valueOf(rawContactId);
822         mSelectionArgs3[2] = String.valueOf(rawContactAccountId);
823         final Cursor duplicatesCursor = db.rawQuery(
824                 "SELECT " + RawContacts._ID +
825                 " FROM " + Tables.RAW_CONTACTS +
826                 " WHERE " + RawContacts.CONTACT_ID + "=?" +
827                 " AND " + RawContacts._ID + "!=?" +
828                 " AND " + RawContactsColumns.ACCOUNT_ID +"=?",
829                 mSelectionArgs3);
830         try {
831             final int duplicateCount = duplicatesCursor.getCount();
832             if (duplicateCount == 0) {
833                 return true; // No duplicates -- common case -- bail early.
834             }
835             if (VERBOSE_LOGGING) {
836                 Log.v(TAG, "canJoinIntoContact: " + duplicateCount + " duplicate(s) found");
837             }
838 
839             rawContactIdsBuilder = new StringBuilder();
840 
841             duplicatesCursor.moveToPosition(-1);
842             while (duplicatesCursor.moveToNext()) {
843                 if (rawContactIdsBuilder.length() > 0) {
844                     rawContactIdsBuilder.append(',');
845                 }
846                 rawContactIdsBuilder.append(duplicatesCursor.getLong(0));
847             }
848         } finally {
849             duplicatesCursor.close();
850         }
851 
852         // Comma separated raw_contacts IDs.
853         final String rawContactIds = rawContactIdsBuilder.toString();
854 
855         // See if there's any raw_contacts that share an email address, a phone number, or
856         // an identity with raw_contact [rawContactId].
857 
858         // First, check for the email address.
859         mSelectionArgs2[0] = String.valueOf(mMimeTypeIdEmail);
860         mSelectionArgs2[1] = String.valueOf(rawContactId);
861         if (isFirstColumnGreaterThanZero(db,
862                 "SELECT count(*)" +
863                 " FROM " + Tables.DATA + " AS d1" +
864                 " JOIN " + Tables.DATA + " AS d2"
865                     + " ON (d1." + Email.ADDRESS + " = d2." + Email.ADDRESS + ")" +
866                 " WHERE d1." + DataColumns.MIMETYPE_ID + " = ?1" +
867                 " AND d2." + DataColumns.MIMETYPE_ID + " = ?1" +
868                 " AND d1." + Data.RAW_CONTACT_ID + " = ?2" +
869                 " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ")",
870                 mSelectionArgs2)) {
871             if (VERBOSE_LOGGING) {
872                 Log.v(TAG, "Relaxing rule SA: email match found for rid=" + rawContactId);
873             }
874             return true;
875         }
876 
877         // Next, check for the identity.
878         mSelectionArgs2[0] = String.valueOf(mMimeTypeIdIdentity);
879         mSelectionArgs2[1] = String.valueOf(rawContactId);
880         if (isFirstColumnGreaterThanZero(db,
881                 "SELECT count(*)" +
882                 " FROM " + Tables.DATA + " AS d1" +
883                 " JOIN " + Tables.DATA + " AS d2"
884                     + " ON (d1." + Identity.IDENTITY + " = d2." + Identity.IDENTITY + " AND" +
885                            " d1." + Identity.NAMESPACE + " = d2." + Identity.NAMESPACE + " )" +
886                 " WHERE d1." + DataColumns.MIMETYPE_ID + " = ?1" +
887                 " AND d2." + DataColumns.MIMETYPE_ID + " = ?1" +
888                 " AND d1." + Data.RAW_CONTACT_ID + " = ?2" +
889                 " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ")",
890                 mSelectionArgs2)) {
891             if (VERBOSE_LOGGING) {
892                 Log.v(TAG, "Relaxing rule SA: identity match found for rid=" + rawContactId);
893             }
894             return true;
895         }
896 
897         // Lastly, the phone number.
898         // It's a bit tricker because it has to be consistent with
899         // updateMatchScoresBasedOnPhoneMatches().
900         mSelectionArgs3[0] = String.valueOf(mMimeTypeIdPhone);
901         mSelectionArgs3[1] = String.valueOf(rawContactId);
902         mSelectionArgs3[2] = String.valueOf(mDbHelper.getUseStrictPhoneNumberComparisonParameter());
903 
904         if (isFirstColumnGreaterThanZero(db,
905                 "SELECT count(*)" +
906                 " FROM " + Tables.PHONE_LOOKUP + " AS p1" +
907                 " JOIN " + Tables.DATA + " AS d1 ON " +
908                     "(d1." + Data._ID + "=p1." + PhoneLookupColumns.DATA_ID + ")" +
909                 " JOIN " + Tables.PHONE_LOOKUP + " AS p2 ON (p1." + PhoneLookupColumns.MIN_MATCH +
910                     "=p2." + PhoneLookupColumns.MIN_MATCH + ")" +
911                 " JOIN " + Tables.DATA + " AS d2 ON " +
912                     "(d2." + Data._ID + "=p2." + PhoneLookupColumns.DATA_ID + ")" +
913                 " WHERE d1." + DataColumns.MIMETYPE_ID + " = ?1" +
914                 " AND d2." + DataColumns.MIMETYPE_ID + " = ?1" +
915                 " AND d1." + Data.RAW_CONTACT_ID + " = ?2" +
916                 " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ")" +
917                 " AND PHONE_NUMBERS_EQUAL(d1." + Phone.NUMBER + ",d2." + Phone.NUMBER + ",?3)",
918                 mSelectionArgs3)) {
919             if (VERBOSE_LOGGING) {
920                 Log.v(TAG, "Relaxing rule SA: phone match found for rid=" + rawContactId);
921             }
922             return true;
923         }
924         if (VERBOSE_LOGGING) {
925             Log.v(TAG, "Rule SA splitting up cid=" + contactId + " for rid=" + rawContactId);
926         }
927         return false;
928     }
929 
isFirstColumnGreaterThanZero(SQLiteDatabase db, String query, String[] selectionArgs)930     private boolean isFirstColumnGreaterThanZero(SQLiteDatabase db, String query,
931             String[] selectionArgs) {
932         final Cursor cursor = db.rawQuery(query, selectionArgs);
933         try {
934             return cursor.moveToFirst() && (cursor.getInt(0) > 0);
935         } finally {
936             cursor.close();
937         }
938     }
939 
940     /**
941      * Breaks up an existing aggregate when a new raw contact is inserted that has
942      * come from the same account as one of the raw contacts in this aggregate.
943      */
splitAutomaticallyAggregatedRawContacts( TransactionContext txContext, SQLiteDatabase db, long contactId)944     private void splitAutomaticallyAggregatedRawContacts(
945             TransactionContext txContext, SQLiteDatabase db, long contactId) {
946         mSelectionArgs1[0] = String.valueOf(contactId);
947         int count = (int) DatabaseUtils.longForQuery(db,
948                 "SELECT COUNT(" + RawContacts._ID + ")" +
949                 " FROM " + Tables.RAW_CONTACTS +
950                 " WHERE " + RawContacts.CONTACT_ID + "=?", mSelectionArgs1);
951         if (count < 2) {
952             // A single-raw-contact aggregate does not need to be split up
953             return;
954         }
955 
956         // Find all constituent raw contacts that are not held together by
957         // an explicit aggregation exception
958         String query =
959                 "SELECT " + RawContacts._ID +
960                 " FROM " + Tables.RAW_CONTACTS +
961                 " WHERE " + RawContacts.CONTACT_ID + "=?" +
962                 "   AND " + RawContacts._ID + " NOT IN " +
963                         "(SELECT " + AggregationExceptions.RAW_CONTACT_ID1 +
964                         " FROM " + Tables.AGGREGATION_EXCEPTIONS +
965                         " WHERE " + AggregationExceptions.TYPE + "="
966                                 + AggregationExceptions.TYPE_KEEP_TOGETHER +
967                         " UNION SELECT " + AggregationExceptions.RAW_CONTACT_ID2 +
968                         " FROM " + Tables.AGGREGATION_EXCEPTIONS +
969                         " WHERE " + AggregationExceptions.TYPE + "="
970                                 + AggregationExceptions.TYPE_KEEP_TOGETHER +
971                         ")";
972 
973         Cursor cursor = db.rawQuery(query, mSelectionArgs1);
974         try {
975             // Process up to count-1 raw contact, leaving the last one alone.
976             for (int i = 0; i < count - 1; i++) {
977                 if (!cursor.moveToNext()) {
978                     break;
979                 }
980                 long rawContactId = cursor.getLong(0);
981                 createNewContactForRawContact(txContext, db, rawContactId);
982             }
983         } finally {
984             cursor.close();
985         }
986         if (contactId > 0) {
987             updateAggregateData(txContext, contactId);
988         }
989     }
990 
991     /**
992      * Creates a stand-alone Contact for the given raw contact ID.
993      */
createNewContactForRawContact( TransactionContext txContext, SQLiteDatabase db, long rawContactId)994     private void createNewContactForRawContact(
995             TransactionContext txContext, SQLiteDatabase db, long rawContactId) {
996         mSelectionArgs1[0] = String.valueOf(rawContactId);
997         computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1,
998                 mContactInsert);
999         long contactId = mContactInsert.executeInsert();
1000         setContactIdAndMarkAggregated(rawContactId, contactId);
1001         mDbHelper.updateContactVisible(txContext, contactId);
1002         setPresenceContactId(rawContactId, contactId);
1003         updateAggregatedStatusUpdate(contactId);
1004     }
1005 
1006     private static class RawContactIdQuery {
1007         public static final String TABLE = Tables.RAW_CONTACTS;
1008         public static final String[] COLUMNS = { RawContacts._ID };
1009         public static final String SELECTION = RawContacts.CONTACT_ID + "=?";
1010         public static final int RAW_CONTACT_ID = 0;
1011     }
1012 
1013     /**
1014      * Ensures that automatic aggregation rules are followed after a contact
1015      * becomes visible or invisible. Specifically, consider this case: there are
1016      * three contacts named Foo. Two of them come from account A1 and one comes
1017      * from account A2. The aggregation rules say that in this case none of the
1018      * three Foo's should be aggregated: two of them are in the same account, so
1019      * they don't get aggregated; the third has two affinities, so it does not
1020      * join either of them.
1021      * <p>
1022      * Consider what happens if one of the "Foo"s from account A1 becomes
1023      * invisible. Nothing stands in the way of aggregating the other two
1024      * anymore, so they should get joined.
1025      * <p>
1026      * What if the invisible "Foo" becomes visible after that? We should split the
1027      * aggregate between the other two.
1028      */
updateAggregationAfterVisibilityChange(long contactId)1029     public void updateAggregationAfterVisibilityChange(long contactId) {
1030         SQLiteDatabase db = mDbHelper.getWritableDatabase();
1031         boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId);
1032         if (visible) {
1033             markContactForAggregation(db, contactId);
1034         } else {
1035             // Find all contacts that _could be_ aggregated with this one and
1036             // rerun aggregation for all of them
1037             mSelectionArgs1[0] = String.valueOf(contactId);
1038             Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
1039                     RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null);
1040             try {
1041                 while (cursor.moveToNext()) {
1042                     long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID);
1043                     mMatcher.clear();
1044 
1045                     updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher);
1046                     updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher);
1047                     List<MatchScore> bestMatches =
1048                             mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_PRIMARY);
1049                     for (MatchScore matchScore : bestMatches) {
1050                         markContactForAggregation(db, matchScore.getContactId());
1051                     }
1052 
1053                     mMatcher.clear();
1054                     updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher);
1055                     updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher);
1056                     bestMatches =
1057                             mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SECONDARY);
1058                     for (MatchScore matchScore : bestMatches) {
1059                         markContactForAggregation(db, matchScore.getContactId());
1060                     }
1061                 }
1062             } finally {
1063                 cursor.close();
1064             }
1065         }
1066     }
1067 
1068     /**
1069      * Updates the contact ID for the specified contact.
1070      */
setContactId(long rawContactId, long contactId)1071     protected void setContactId(long rawContactId, long contactId) {
1072         mContactIdUpdate.bindLong(1, contactId);
1073         mContactIdUpdate.bindLong(2, rawContactId);
1074         mContactIdUpdate.execute();
1075     }
1076 
1077     /**
1078      * Marks the specified raw contact ID as aggregated
1079      */
markAggregated(long rawContactId)1080     private void markAggregated(long rawContactId) {
1081         mMarkAggregatedUpdate.bindLong(1, rawContactId);
1082         mMarkAggregatedUpdate.execute();
1083     }
1084 
1085     /**
1086      * Updates the contact ID for the specified contact and marks the raw contact as aggregated.
1087      */
setContactIdAndMarkAggregated(long rawContactId, long contactId)1088     private void setContactIdAndMarkAggregated(long rawContactId, long contactId) {
1089         mContactIdAndMarkAggregatedUpdate.bindLong(1, contactId);
1090         mContactIdAndMarkAggregatedUpdate.bindLong(2, rawContactId);
1091         mContactIdAndMarkAggregatedUpdate.execute();
1092     }
1093 
setPresenceContactId(long rawContactId, long contactId)1094     private void setPresenceContactId(long rawContactId, long contactId) {
1095         mPresenceContactIdUpdate.bindLong(1, contactId);
1096         mPresenceContactIdUpdate.bindLong(2, rawContactId);
1097         mPresenceContactIdUpdate.execute();
1098     }
1099 
1100     interface AggregateExceptionPrefetchQuery {
1101         String TABLE = Tables.AGGREGATION_EXCEPTIONS;
1102 
1103         String[] COLUMNS = {
1104             AggregationExceptions.RAW_CONTACT_ID1,
1105             AggregationExceptions.RAW_CONTACT_ID2,
1106         };
1107 
1108         int RAW_CONTACT_ID1 = 0;
1109         int RAW_CONTACT_ID2 = 1;
1110     }
1111 
1112     // A set of raw contact IDs for which there are aggregation exceptions
1113     private final HashSet<Long> mAggregationExceptionIds = new HashSet<Long>();
1114     private boolean mAggregationExceptionIdsValid;
1115 
invalidateAggregationExceptionCache()1116     public void invalidateAggregationExceptionCache() {
1117         mAggregationExceptionIdsValid = false;
1118     }
1119 
1120     /**
1121      * Finds all raw contact IDs for which there are aggregation exceptions. The list of
1122      * ids is used as an optimization in aggregation: there is no point to run a query against
1123      * the agg_exceptions table if it is known that there are no records there for a given
1124      * raw contact ID.
1125      */
prefetchAggregationExceptionIds(SQLiteDatabase db)1126     private void prefetchAggregationExceptionIds(SQLiteDatabase db) {
1127         mAggregationExceptionIds.clear();
1128         final Cursor c = db.query(AggregateExceptionPrefetchQuery.TABLE,
1129                 AggregateExceptionPrefetchQuery.COLUMNS,
1130                 null, null, null, null, null);
1131 
1132         try {
1133             while (c.moveToNext()) {
1134                 long rawContactId1 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID1);
1135                 long rawContactId2 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID2);
1136                 mAggregationExceptionIds.add(rawContactId1);
1137                 mAggregationExceptionIds.add(rawContactId2);
1138             }
1139         } finally {
1140             c.close();
1141         }
1142 
1143         mAggregationExceptionIdsValid = true;
1144     }
1145 
1146     interface AggregateExceptionQuery {
1147         String TABLE = Tables.AGGREGATION_EXCEPTIONS
1148             + " JOIN raw_contacts raw_contacts1 "
1149                     + " ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) "
1150             + " JOIN raw_contacts raw_contacts2 "
1151                     + " ON (agg_exceptions.raw_contact_id2 = raw_contacts2._id) ";
1152 
1153         String[] COLUMNS = {
1154             AggregationExceptions.TYPE,
1155             AggregationExceptions.RAW_CONTACT_ID1,
1156             "raw_contacts1." + RawContacts.CONTACT_ID,
1157             "raw_contacts1." + RawContactsColumns.AGGREGATION_NEEDED,
1158             "raw_contacts2." + RawContacts.CONTACT_ID,
1159             "raw_contacts2." + RawContactsColumns.AGGREGATION_NEEDED,
1160         };
1161 
1162         int TYPE = 0;
1163         int RAW_CONTACT_ID1 = 1;
1164         int CONTACT_ID1 = 2;
1165         int AGGREGATION_NEEDED_1 = 3;
1166         int CONTACT_ID2 = 4;
1167         int AGGREGATION_NEEDED_2 = 5;
1168     }
1169 
1170     /**
1171      * Computes match scores based on exceptions entered by the user: always match and never match.
1172      * Returns the aggregate contact with the always match exception if any.
1173      */
pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId, ContactMatcher matcher)1174     private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId,
1175             ContactMatcher matcher) {
1176         if (!mAggregationExceptionIdsValid) {
1177             prefetchAggregationExceptionIds(db);
1178         }
1179 
1180         // If there are no aggregation exceptions involving this raw contact, there is no need to
1181         // run a query and we can just return -1, which stands for "nothing found"
1182         if (!mAggregationExceptionIds.contains(rawContactId)) {
1183             return -1;
1184         }
1185 
1186         final Cursor c = db.query(AggregateExceptionQuery.TABLE,
1187                 AggregateExceptionQuery.COLUMNS,
1188                 AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId
1189                         + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId,
1190                 null, null, null, null);
1191 
1192         try {
1193             while (c.moveToNext()) {
1194                 int type = c.getInt(AggregateExceptionQuery.TYPE);
1195                 long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
1196                 long contactId = -1;
1197                 if (rawContactId == rawContactId1) {
1198                     if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_2) == 0
1199                             && !c.isNull(AggregateExceptionQuery.CONTACT_ID2)) {
1200                         contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2);
1201                     }
1202                 } else {
1203                     if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_1) == 0
1204                             && !c.isNull(AggregateExceptionQuery.CONTACT_ID1)) {
1205                         contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1);
1206                     }
1207                 }
1208                 if (contactId != -1) {
1209                     if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) {
1210                         matcher.keepIn(contactId);
1211                     } else {
1212                         matcher.keepOut(contactId);
1213                     }
1214                 }
1215             }
1216         } finally {
1217             c.close();
1218         }
1219 
1220         return matcher.pickBestMatch(ContactMatcher.MAX_SCORE, true);
1221     }
1222 
1223     /**
1224      * Picks the best matching contact based on matches between data elements.  It considers
1225      * name match to be primary and phone, email etc matches to be secondary.  A good primary
1226      * match triggers aggregation, while a good secondary match only triggers aggregation in
1227      * the absence of a strong primary mismatch.
1228      * <p>
1229      * Consider these examples:
1230      * <p>
1231      * John Doe with phone number 111-111-1111 and Jon Doe with phone number 111-111-1111 should
1232      * be aggregated (same number, similar names).
1233      * <p>
1234      * John Doe with phone number 111-111-1111 and Deborah Doe with phone number 111-111-1111 should
1235      * not be aggregated (same number, different names).
1236      */
pickBestMatchBasedOnData(SQLiteDatabase db, long rawContactId, MatchCandidateList candidates, ContactMatcher matcher)1237     private long pickBestMatchBasedOnData(SQLiteDatabase db, long rawContactId,
1238             MatchCandidateList candidates, ContactMatcher matcher) {
1239 
1240         // Find good matches based on name alone
1241         long bestMatch = updateMatchScoresBasedOnDataMatches(db, rawContactId, matcher);
1242         if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) {
1243             // We found multiple matches on the name - do not aggregate because of the ambiguity
1244             return -1;
1245         } else if (bestMatch == -1) {
1246             // We haven't found a good match on name, see if we have any matches on phone, email etc
1247             bestMatch = pickBestMatchBasedOnSecondaryData(db, rawContactId, candidates, matcher);
1248             if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) {
1249                 return -1;
1250             }
1251         }
1252 
1253         return bestMatch;
1254     }
1255 
1256 
1257     /**
1258      * Picks the best matching contact based on secondary data matches.  The method loads
1259      * structured names for all candidate contacts and recomputes match scores using approximate
1260      * matching.
1261      */
pickBestMatchBasedOnSecondaryData(SQLiteDatabase db, long rawContactId, MatchCandidateList candidates, ContactMatcher matcher)1262     private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db,
1263             long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
1264         List<Long> secondaryContactIds = matcher.prepareSecondaryMatchCandidates(
1265                 ContactMatcher.SCORE_THRESHOLD_PRIMARY);
1266         if (secondaryContactIds == null || secondaryContactIds.size() > SECONDARY_HIT_LIMIT) {
1267             return -1;
1268         }
1269 
1270         loadNameMatchCandidates(db, rawContactId, candidates, true);
1271 
1272         mSb.setLength(0);
1273         mSb.append(RawContacts.CONTACT_ID).append(" IN (");
1274         for (int i = 0; i < secondaryContactIds.size(); i++) {
1275             if (i != 0) {
1276                 mSb.append(',');
1277             }
1278             mSb.append(secondaryContactIds.get(i));
1279         }
1280 
1281         // We only want to compare structured names to structured names
1282         // at this stage, we need to ignore all other sources of name lookup data.
1283         mSb.append(") AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL);
1284 
1285         matchAllCandidates(db, mSb.toString(), candidates, matcher,
1286                 ContactMatcher.MATCHING_ALGORITHM_CONSERVATIVE, null);
1287 
1288         return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY, false);
1289     }
1290 
1291     private interface NameLookupQuery {
1292         String TABLE = Tables.NAME_LOOKUP;
1293 
1294         String SELECTION = NameLookupColumns.RAW_CONTACT_ID + "=?";
1295         String SELECTION_STRUCTURED_NAME_BASED =
1296                 SELECTION + " AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL;
1297 
1298         String[] COLUMNS = new String[] {
1299                 NameLookupColumns.NORMALIZED_NAME,
1300                 NameLookupColumns.NAME_TYPE
1301         };
1302 
1303         int NORMALIZED_NAME = 0;
1304         int NAME_TYPE = 1;
1305     }
1306 
loadNameMatchCandidates(SQLiteDatabase db, long rawContactId, MatchCandidateList candidates, boolean structuredNameBased)1307     private void loadNameMatchCandidates(SQLiteDatabase db, long rawContactId,
1308             MatchCandidateList candidates, boolean structuredNameBased) {
1309         candidates.clear();
1310         mSelectionArgs1[0] = String.valueOf(rawContactId);
1311         Cursor c = db.query(NameLookupQuery.TABLE, NameLookupQuery.COLUMNS,
1312                 structuredNameBased
1313                         ? NameLookupQuery.SELECTION_STRUCTURED_NAME_BASED
1314                         : NameLookupQuery.SELECTION,
1315                 mSelectionArgs1, null, null, null);
1316         try {
1317             while (c.moveToNext()) {
1318                 String normalizedName = c.getString(NameLookupQuery.NORMALIZED_NAME);
1319                 int type = c.getInt(NameLookupQuery.NAME_TYPE);
1320                 candidates.add(normalizedName, type);
1321             }
1322         } finally {
1323             c.close();
1324         }
1325     }
1326 
1327     /**
1328      * Computes scores for contacts that have matching data rows.
1329      */
updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId, ContactMatcher matcher)1330     private long updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId,
1331             ContactMatcher matcher) {
1332 
1333         updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
1334         updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
1335         long bestMatch = matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_PRIMARY, false);
1336         if (bestMatch != -1) {
1337             return bestMatch;
1338         }
1339 
1340         updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
1341         updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
1342 
1343         return -1;
1344     }
1345 
1346     private interface IdentityLookupMatchQuery {
1347         final String TABLE = Tables.DATA + " dataA"
1348                 + " JOIN " + Tables.DATA + " dataB" +
1349                 " ON (dataA." + Identity.NAMESPACE + "=dataB." + Identity.NAMESPACE +
1350                 " AND dataA." + Identity.IDENTITY + "=dataB." + Identity.IDENTITY + ")"
1351                 + " JOIN " + Tables.RAW_CONTACTS +
1352                 " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1353                 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1354 
1355         final String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1"
1356                 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2"
1357                 + " AND dataA." + Identity.NAMESPACE + " NOT NULL"
1358                 + " AND dataA." + Identity.IDENTITY + " NOT NULL"
1359                 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2"
1360                 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1361                 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1362 
1363         final String[] COLUMNS = new String[] {
1364             RawContacts.CONTACT_ID
1365         };
1366 
1367         int CONTACT_ID = 0;
1368     }
1369 
1370     /**
1371      * Finds contacts with exact identity matches to the the specified raw contact.
1372      */
updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId, ContactMatcher matcher)1373     private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId,
1374             ContactMatcher matcher) {
1375         mSelectionArgs2[0] = String.valueOf(rawContactId);
1376         mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity);
1377         Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS,
1378                 IdentityLookupMatchQuery.SELECTION,
1379                 mSelectionArgs2, RawContacts.CONTACT_ID, null, null);
1380         try {
1381             while (c.moveToNext()) {
1382                 final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID);
1383                 matcher.matchIdentity(contactId);
1384             }
1385         } finally {
1386             c.close();
1387         }
1388 
1389     }
1390 
1391     private interface NameLookupMatchQuery {
1392         String TABLE = Tables.NAME_LOOKUP + " nameA"
1393                 + " JOIN " + Tables.NAME_LOOKUP + " nameB" +
1394                 " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "="
1395                         + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")"
1396                 + " JOIN " + Tables.RAW_CONTACTS +
1397                 " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = "
1398                         + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1399 
1400         String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?"
1401                 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1402                 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1403 
1404         String[] COLUMNS = new String[] {
1405             RawContacts.CONTACT_ID,
1406             "nameA." + NameLookupColumns.NORMALIZED_NAME,
1407             "nameA." + NameLookupColumns.NAME_TYPE,
1408             "nameB." + NameLookupColumns.NAME_TYPE,
1409         };
1410 
1411         int CONTACT_ID = 0;
1412         int NAME = 1;
1413         int NAME_TYPE_A = 2;
1414         int NAME_TYPE_B = 3;
1415     }
1416 
1417     /**
1418      * Finds contacts with names matching the name of the specified raw contact.
1419      */
updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId, ContactMatcher matcher)1420     private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId,
1421             ContactMatcher matcher) {
1422         mSelectionArgs1[0] = String.valueOf(rawContactId);
1423         Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS,
1424                 NameLookupMatchQuery.SELECTION,
1425                 mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING);
1426         try {
1427             while (c.moveToNext()) {
1428                 long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID);
1429                 String name = c.getString(NameLookupMatchQuery.NAME);
1430                 int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A);
1431                 int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B);
1432                 matcher.matchName(contactId, nameTypeA, name,
1433                         nameTypeB, name, ContactMatcher.MATCHING_ALGORITHM_EXACT);
1434                 if (nameTypeA == NameLookupType.NICKNAME &&
1435                         nameTypeB == NameLookupType.NICKNAME) {
1436                     matcher.updateScoreWithNicknameMatch(contactId);
1437                 }
1438             }
1439         } finally {
1440             c.close();
1441         }
1442     }
1443 
1444     private interface NameLookupMatchQueryWithParameter {
1445         String TABLE = Tables.NAME_LOOKUP
1446                 + " JOIN " + Tables.RAW_CONTACTS +
1447                 " ON (" + NameLookupColumns.RAW_CONTACT_ID + " = "
1448                         + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1449 
1450         String[] COLUMNS = new String[] {
1451             RawContacts.CONTACT_ID,
1452             NameLookupColumns.NORMALIZED_NAME,
1453             NameLookupColumns.NAME_TYPE,
1454         };
1455 
1456         int CONTACT_ID = 0;
1457         int NAME = 1;
1458         int NAME_TYPE = 2;
1459     }
1460 
1461     private final class NameLookupSelectionBuilder extends NameLookupBuilder {
1462 
1463         private final MatchCandidateList mNameLookupCandidates;
1464 
1465         private StringBuilder mSelection = new StringBuilder(
1466                 NameLookupColumns.NORMALIZED_NAME + " IN(");
1467 
1468 
NameLookupSelectionBuilder(NameSplitter splitter, MatchCandidateList candidates)1469         public NameLookupSelectionBuilder(NameSplitter splitter, MatchCandidateList candidates) {
1470             super(splitter);
1471             this.mNameLookupCandidates = candidates;
1472         }
1473 
1474         @Override
getCommonNicknameClusters(String normalizedName)1475         protected String[] getCommonNicknameClusters(String normalizedName) {
1476             return mCommonNicknameCache.getCommonNicknameClusters(normalizedName);
1477         }
1478 
1479         @Override
insertNameLookup( long rawContactId, long dataId, int lookupType, String string)1480         protected void insertNameLookup(
1481                 long rawContactId, long dataId, int lookupType, String string) {
1482             mNameLookupCandidates.add(string, lookupType);
1483             DatabaseUtils.appendEscapedSQLString(mSelection, string);
1484             mSelection.append(',');
1485         }
1486 
isEmpty()1487         public boolean isEmpty() {
1488             return mNameLookupCandidates.isEmpty();
1489         }
1490 
getSelection()1491         public String getSelection() {
1492             mSelection.setLength(mSelection.length() - 1);      // Strip last comma
1493             mSelection.append(')');
1494             return mSelection.toString();
1495         }
1496 
getLookupType(String name)1497         public int getLookupType(String name) {
1498             for (int i = 0; i < mNameLookupCandidates.mCount; i++) {
1499                 if (mNameLookupCandidates.mList.get(i).mName.equals(name)) {
1500                     return mNameLookupCandidates.mList.get(i).mLookupType;
1501                 }
1502             }
1503             throw new IllegalStateException();
1504         }
1505     }
1506 
1507     /**
1508      * Finds contacts with names matching the specified name.
1509      */
updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query, MatchCandidateList candidates, ContactMatcher matcher)1510     private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query,
1511             MatchCandidateList candidates, ContactMatcher matcher) {
1512         candidates.clear();
1513         NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder(
1514                 mNameSplitter, candidates);
1515         builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED);
1516         if (builder.isEmpty()) {
1517             return;
1518         }
1519 
1520         Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE,
1521                 NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null,
1522                 null, PRIMARY_HIT_LIMIT_STRING);
1523         try {
1524             while (c.moveToNext()) {
1525                 long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID);
1526                 String name = c.getString(NameLookupMatchQueryWithParameter.NAME);
1527                 int nameTypeA = builder.getLookupType(name);
1528                 int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE);
1529                 matcher.matchName(contactId, nameTypeA, name, nameTypeB, name,
1530                         ContactMatcher.MATCHING_ALGORITHM_EXACT);
1531                 if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) {
1532                     matcher.updateScoreWithNicknameMatch(contactId);
1533                 }
1534             }
1535         } finally {
1536             c.close();
1537         }
1538     }
1539 
1540     private interface EmailLookupQuery {
1541         String TABLE = Tables.DATA + " dataA"
1542                 + " JOIN " + Tables.DATA + " dataB" +
1543                 " ON (" + "dataA." + Email.DATA + "=dataB." + Email.DATA + ")"
1544                 + " JOIN " + Tables.RAW_CONTACTS +
1545                 " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1546                         + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1547 
1548         String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1"
1549                 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2"
1550                 + " AND dataA." + Email.DATA + " NOT NULL"
1551                 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2"
1552                 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1553                 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1554 
1555         String[] COLUMNS = new String[] {
1556             RawContacts.CONTACT_ID
1557         };
1558 
1559         int CONTACT_ID = 0;
1560     }
1561 
updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId, ContactMatcher matcher)1562     private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId,
1563             ContactMatcher matcher) {
1564         mSelectionArgs2[0] = String.valueOf(rawContactId);
1565         mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail);
1566         Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS,
1567                 EmailLookupQuery.SELECTION,
1568                 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
1569         try {
1570             while (c.moveToNext()) {
1571                 long contactId = c.getLong(EmailLookupQuery.CONTACT_ID);
1572                 matcher.updateScoreWithEmailMatch(contactId);
1573             }
1574         } finally {
1575             c.close();
1576         }
1577     }
1578 
1579     private interface PhoneLookupQuery {
1580         String TABLE = Tables.PHONE_LOOKUP + " phoneA"
1581                 + " JOIN " + Tables.DATA + " dataA"
1582                 + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")"
1583                 + " JOIN " + Tables.PHONE_LOOKUP + " phoneB"
1584                 + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "="
1585                         + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")"
1586                 + " JOIN " + Tables.DATA + " dataB"
1587                 + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")"
1588                 + " JOIN " + Tables.RAW_CONTACTS
1589                 + " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1590                         + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1591 
1592         String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?"
1593                 + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", "
1594                         + "dataB." + Phone.NUMBER + ",?)"
1595                 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1596                 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1597 
1598         String[] COLUMNS = new String[] {
1599             RawContacts.CONTACT_ID
1600         };
1601 
1602         int CONTACT_ID = 0;
1603     }
1604 
updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId, ContactMatcher matcher)1605     private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId,
1606             ContactMatcher matcher) {
1607         mSelectionArgs2[0] = String.valueOf(rawContactId);
1608         mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter();
1609         Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS,
1610                 PhoneLookupQuery.SELECTION,
1611                 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
1612         try {
1613             while (c.moveToNext()) {
1614                 long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID);
1615                 matcher.updateScoreWithPhoneNumberMatch(contactId);
1616             }
1617         } finally {
1618             c.close();
1619         }
1620     }
1621 
1622     /**
1623      * Loads name lookup rows for approximate name matching and updates match scores based on that
1624      * data.
1625      */
lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates, ContactMatcher matcher)1626     private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
1627             ContactMatcher matcher) {
1628         HashSet<String> firstLetters = new HashSet<String>();
1629         for (int i = 0; i < candidates.mCount; i++) {
1630             final NameMatchCandidate candidate = candidates.mList.get(i);
1631             if (candidate.mName.length() >= 2) {
1632                 String firstLetter = candidate.mName.substring(0, 2);
1633                 if (!firstLetters.contains(firstLetter)) {
1634                     firstLetters.add(firstLetter);
1635                     final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '"
1636                             + firstLetter + "*') AND "
1637                             + "(" + NameLookupColumns.NAME_TYPE + " IN("
1638                                     + NameLookupType.NAME_COLLATION_KEY + ","
1639                                     + NameLookupType.EMAIL_BASED_NICKNAME + ","
1640                                     + NameLookupType.NICKNAME + ")) AND "
1641                             + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1642                     matchAllCandidates(db, selection, candidates, matcher,
1643                             ContactMatcher.MATCHING_ALGORITHM_APPROXIMATE,
1644                             String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT));
1645                 }
1646             }
1647         }
1648     }
1649 
1650     private interface ContactNameLookupQuery {
1651         String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS;
1652 
1653         String[] COLUMNS = new String[] {
1654                 RawContacts.CONTACT_ID,
1655                 NameLookupColumns.NORMALIZED_NAME,
1656                 NameLookupColumns.NAME_TYPE
1657         };
1658 
1659         int CONTACT_ID = 0;
1660         int NORMALIZED_NAME = 1;
1661         int NAME_TYPE = 2;
1662     }
1663 
1664     /**
1665      * Loads all candidate rows from the name lookup table and updates match scores based
1666      * on that data.
1667      */
matchAllCandidates(SQLiteDatabase db, String selection, MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit)1668     private void matchAllCandidates(SQLiteDatabase db, String selection,
1669             MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit) {
1670         final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS,
1671                 selection, null, null, null, null, limit);
1672 
1673         try {
1674             while (c.moveToNext()) {
1675                 Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID);
1676                 String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME);
1677                 int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE);
1678 
1679                 // Note the N^2 complexity of the following fragment. This is not a huge concern
1680                 // since the number of candidates is very small and in general secondary hits
1681                 // in the absence of primary hits are rare.
1682                 for (int i = 0; i < candidates.mCount; i++) {
1683                     NameMatchCandidate candidate = candidates.mList.get(i);
1684                     matcher.matchName(contactId, candidate.mLookupType, candidate.mName,
1685                             nameType, name, algorithm);
1686                 }
1687             }
1688         } finally {
1689             c.close();
1690         }
1691     }
1692 
1693     private interface RawContactsQuery {
1694         String SQL_FORMAT =
1695                 "SELECT "
1696                         + RawContactsColumns.CONCRETE_ID + ","
1697                         + RawContactsColumns.DISPLAY_NAME + ","
1698                         + RawContactsColumns.DISPLAY_NAME_SOURCE + ","
1699                         + AccountsColumns.CONCRETE_ACCOUNT_TYPE + ","
1700                         + AccountsColumns.CONCRETE_ACCOUNT_NAME + ","
1701                         + AccountsColumns.CONCRETE_DATA_SET + ","
1702                         + RawContacts.SOURCE_ID + ","
1703                         + RawContacts.CUSTOM_RINGTONE + ","
1704                         + RawContacts.SEND_TO_VOICEMAIL + ","
1705                         + RawContacts.LAST_TIME_CONTACTED + ","
1706                         + RawContacts.TIMES_CONTACTED + ","
1707                         + RawContacts.STARRED + ","
1708                         + RawContacts.NAME_VERIFIED + ","
1709                         + DataColumns.CONCRETE_ID + ","
1710                         + DataColumns.CONCRETE_MIMETYPE_ID + ","
1711                         + Data.IS_SUPER_PRIMARY + ","
1712                         + Photo.PHOTO_FILE_ID +
1713                 " FROM " + Tables.RAW_CONTACTS +
1714                 " JOIN " + Tables.ACCOUNTS + " ON ("
1715                     + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
1716                     + ")" +
1717                 " LEFT OUTER JOIN " + Tables.DATA +
1718                 " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
1719                         + " AND ((" + DataColumns.MIMETYPE_ID + "=%d"
1720                                 + " AND " + Photo.PHOTO + " NOT NULL)"
1721                         + " OR (" + DataColumns.MIMETYPE_ID + "=%d"
1722                                 + " AND " + Phone.NUMBER + " NOT NULL)))";
1723 
1724         String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT +
1725                 " WHERE " + RawContactsColumns.CONCRETE_ID + "=?";
1726 
1727         String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT +
1728                 " WHERE " + RawContacts.CONTACT_ID + "=?"
1729                 + " AND " + RawContacts.DELETED + "=0";
1730 
1731         int RAW_CONTACT_ID = 0;
1732         int DISPLAY_NAME = 1;
1733         int DISPLAY_NAME_SOURCE = 2;
1734         int ACCOUNT_TYPE = 3;
1735         int ACCOUNT_NAME = 4;
1736         int DATA_SET = 5;
1737         int SOURCE_ID = 6;
1738         int CUSTOM_RINGTONE = 7;
1739         int SEND_TO_VOICEMAIL = 8;
1740         int LAST_TIME_CONTACTED = 9;
1741         int TIMES_CONTACTED = 10;
1742         int STARRED = 11;
1743         int NAME_VERIFIED = 12;
1744         int DATA_ID = 13;
1745         int MIMETYPE_ID = 14;
1746         int IS_SUPER_PRIMARY = 15;
1747         int PHOTO_FILE_ID = 16;
1748     }
1749 
1750     private interface ContactReplaceSqlStatement {
1751         String UPDATE_SQL =
1752                 "UPDATE " + Tables.CONTACTS +
1753                 " SET "
1754                         + Contacts.NAME_RAW_CONTACT_ID + "=?, "
1755                         + Contacts.PHOTO_ID + "=?, "
1756                         + Contacts.PHOTO_FILE_ID + "=?, "
1757                         + Contacts.SEND_TO_VOICEMAIL + "=?, "
1758                         + Contacts.CUSTOM_RINGTONE + "=?, "
1759                         + Contacts.LAST_TIME_CONTACTED + "=?, "
1760                         + Contacts.TIMES_CONTACTED + "=?, "
1761                         + Contacts.STARRED + "=?, "
1762                         + Contacts.HAS_PHONE_NUMBER + "=?, "
1763                         + Contacts.LOOKUP_KEY + "=?, "
1764                         + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + "=? " +
1765                 " WHERE " + Contacts._ID + "=?";
1766 
1767         String INSERT_SQL =
1768                 "INSERT INTO " + Tables.CONTACTS + " ("
1769                         + Contacts.NAME_RAW_CONTACT_ID + ", "
1770                         + Contacts.PHOTO_ID + ", "
1771                         + Contacts.PHOTO_FILE_ID + ", "
1772                         + Contacts.SEND_TO_VOICEMAIL + ", "
1773                         + Contacts.CUSTOM_RINGTONE + ", "
1774                         + Contacts.LAST_TIME_CONTACTED + ", "
1775                         + Contacts.TIMES_CONTACTED + ", "
1776                         + Contacts.STARRED + ", "
1777                         + Contacts.HAS_PHONE_NUMBER + ", "
1778                         + Contacts.LOOKUP_KEY + ", "
1779                         + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP
1780                         + ") " +
1781                 " VALUES (?,?,?,?,?,?,?,?,?,?,?)";
1782 
1783         int NAME_RAW_CONTACT_ID = 1;
1784         int PHOTO_ID = 2;
1785         int PHOTO_FILE_ID = 3;
1786         int SEND_TO_VOICEMAIL = 4;
1787         int CUSTOM_RINGTONE = 5;
1788         int LAST_TIME_CONTACTED = 6;
1789         int TIMES_CONTACTED = 7;
1790         int STARRED = 8;
1791         int HAS_PHONE_NUMBER = 9;
1792         int LOOKUP_KEY = 10;
1793         int CONTACT_LAST_UPDATED_TIMESTAMP = 11;
1794         int CONTACT_ID = 12;
1795     }
1796 
1797     /**
1798      * Computes aggregate-level data for the specified aggregate contact ID.
1799      */
computeAggregateData(SQLiteDatabase db, long contactId, SQLiteStatement statement)1800     private void computeAggregateData(SQLiteDatabase db, long contactId,
1801             SQLiteStatement statement) {
1802         mSelectionArgs1[0] = String.valueOf(contactId);
1803         computeAggregateData(db, mRawContactsQueryByContactId, mSelectionArgs1, statement);
1804     }
1805 
1806     /**
1807      * Indicates whether the given photo entry and priority gives this photo a higher overall
1808      * priority than the current best photo entry and priority.
1809      */
hasHigherPhotoPriority(PhotoEntry photoEntry, int priority, PhotoEntry bestPhotoEntry, int bestPriority)1810     private boolean hasHigherPhotoPriority(PhotoEntry photoEntry, int priority,
1811             PhotoEntry bestPhotoEntry, int bestPriority) {
1812         int photoComparison = photoEntry.compareTo(bestPhotoEntry);
1813         return photoComparison < 0 || photoComparison == 0 && priority > bestPriority;
1814     }
1815 
1816     /**
1817      * Computes aggregate-level data from constituent raw contacts.
1818      */
computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs, SQLiteStatement statement)1819     private void computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs,
1820             SQLiteStatement statement) {
1821         long currentRawContactId = -1;
1822         long bestPhotoId = -1;
1823         long bestPhotoFileId = 0;
1824         PhotoEntry bestPhotoEntry = null;
1825         boolean foundSuperPrimaryPhoto = false;
1826         int photoPriority = -1;
1827         int totalRowCount = 0;
1828         int contactSendToVoicemail = 0;
1829         String contactCustomRingtone = null;
1830         long contactLastTimeContacted = 0;
1831         int contactTimesContacted = 0;
1832         int contactStarred = 0;
1833         int hasPhoneNumber = 0;
1834         StringBuilder lookupKey = new StringBuilder();
1835 
1836         mDisplayNameCandidate.clear();
1837 
1838         Cursor c = db.rawQuery(sql, sqlArgs);
1839         try {
1840             while (c.moveToNext()) {
1841                 long rawContactId = c.getLong(RawContactsQuery.RAW_CONTACT_ID);
1842                 if (rawContactId != currentRawContactId) {
1843                     currentRawContactId = rawContactId;
1844                     totalRowCount++;
1845 
1846                     // Assemble sub-account.
1847                     String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
1848                     String dataSet = c.getString(RawContactsQuery.DATA_SET);
1849                     String accountWithDataSet = (!TextUtils.isEmpty(dataSet))
1850                             ? accountType + "/" + dataSet
1851                             : accountType;
1852 
1853                     // Display name
1854                     String displayName = c.getString(RawContactsQuery.DISPLAY_NAME);
1855                     int displayNameSource = c.getInt(RawContactsQuery.DISPLAY_NAME_SOURCE);
1856                     int nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED);
1857                     processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
1858                             mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet),
1859                             nameVerified != 0);
1860 
1861                     // Contact options
1862                     if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) {
1863                         boolean sendToVoicemail =
1864                                 (c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0);
1865                         if (sendToVoicemail) {
1866                             contactSendToVoicemail++;
1867                         }
1868                     }
1869 
1870                     if (contactCustomRingtone == null
1871                             && !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) {
1872                         contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE);
1873                     }
1874 
1875                     long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED);
1876                     if (lastTimeContacted > contactLastTimeContacted) {
1877                         contactLastTimeContacted = lastTimeContacted;
1878                     }
1879 
1880                     int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED);
1881                     if (timesContacted > contactTimesContacted) {
1882                         contactTimesContacted = timesContacted;
1883                     }
1884 
1885                     if (c.getInt(RawContactsQuery.STARRED) != 0) {
1886                         contactStarred = 1;
1887                     }
1888 
1889                     appendLookupKey(
1890                             lookupKey,
1891                             accountWithDataSet,
1892                             c.getString(RawContactsQuery.ACCOUNT_NAME),
1893                             rawContactId,
1894                             c.getString(RawContactsQuery.SOURCE_ID),
1895                             displayName);
1896                 }
1897 
1898                 if (!c.isNull(RawContactsQuery.DATA_ID)) {
1899                     long dataId = c.getLong(RawContactsQuery.DATA_ID);
1900                     long photoFileId = c.getLong(RawContactsQuery.PHOTO_FILE_ID);
1901                     int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID);
1902                     boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0;
1903                     if (mimetypeId == mMimeTypeIdPhoto) {
1904                         if (!foundSuperPrimaryPhoto) {
1905                             // Lookup the metadata for the photo, if available.  Note that data set
1906                             // does not come into play here, since accounts are looked up in the
1907                             // account manager in the priority resolver.
1908                             PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
1909                             String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
1910                             int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
1911                             if (superPrimary || hasHigherPhotoPriority(
1912                                     photoEntry, priority, bestPhotoEntry, photoPriority)) {
1913                                 bestPhotoEntry = photoEntry;
1914                                 photoPriority = priority;
1915                                 bestPhotoId = dataId;
1916                                 bestPhotoFileId = photoFileId;
1917                                 foundSuperPrimaryPhoto |= superPrimary;
1918                             }
1919                         }
1920                     } else if (mimetypeId == mMimeTypeIdPhone) {
1921                         hasPhoneNumber = 1;
1922                     }
1923                 }
1924             }
1925         } finally {
1926             c.close();
1927         }
1928 
1929         statement.bindLong(ContactReplaceSqlStatement.NAME_RAW_CONTACT_ID,
1930                 mDisplayNameCandidate.rawContactId);
1931 
1932         if (bestPhotoId != -1) {
1933             statement.bindLong(ContactReplaceSqlStatement.PHOTO_ID, bestPhotoId);
1934         } else {
1935             statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID);
1936         }
1937 
1938         if (bestPhotoFileId != 0) {
1939             statement.bindLong(ContactReplaceSqlStatement.PHOTO_FILE_ID, bestPhotoFileId);
1940         } else {
1941             statement.bindNull(ContactReplaceSqlStatement.PHOTO_FILE_ID);
1942         }
1943 
1944         statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL,
1945                 totalRowCount == contactSendToVoicemail ? 1 : 0);
1946         DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE,
1947                 contactCustomRingtone);
1948         statement.bindLong(ContactReplaceSqlStatement.LAST_TIME_CONTACTED,
1949                 contactLastTimeContacted);
1950         statement.bindLong(ContactReplaceSqlStatement.TIMES_CONTACTED,
1951                 contactTimesContacted);
1952         statement.bindLong(ContactReplaceSqlStatement.STARRED,
1953                 contactStarred);
1954         statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER,
1955                 hasPhoneNumber);
1956         statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY,
1957                 Uri.encode(lookupKey.toString()));
1958         statement.bindLong(ContactReplaceSqlStatement.CONTACT_LAST_UPDATED_TIMESTAMP,
1959                 Clock.getInstance().currentTimeMillis());
1960     }
1961 
1962     /**
1963      * Builds a lookup key using the given data.
1964      */
appendLookupKey(StringBuilder sb, String accountTypeWithDataSet, String accountName, long rawContactId, String sourceId, String displayName)1965     protected void appendLookupKey(StringBuilder sb, String accountTypeWithDataSet,
1966             String accountName, long rawContactId, String sourceId, String displayName) {
1967         ContactLookupKey.appendToLookupKey(sb, accountTypeWithDataSet, accountName, rawContactId,
1968                 sourceId, displayName);
1969     }
1970 
1971     /**
1972      * Uses the supplied values to determine if they represent a "better" display name
1973      * for the aggregate contact currently evaluated.  If so, it updates
1974      * {@link #mDisplayNameCandidate} with the new values.
1975      */
processDisplayNameCandidate(long rawContactId, String displayName, int displayNameSource, boolean writableAccount, boolean verified)1976     private void processDisplayNameCandidate(long rawContactId, String displayName,
1977             int displayNameSource, boolean writableAccount, boolean verified) {
1978 
1979         boolean replace = false;
1980         if (mDisplayNameCandidate.rawContactId == -1) {
1981             // No previous values available
1982             replace = true;
1983         } else if (!TextUtils.isEmpty(displayName)) {
1984             if (!mDisplayNameCandidate.verified && verified) {
1985                 // A verified name is better than any other name
1986                 replace = true;
1987             } else if (mDisplayNameCandidate.verified == verified) {
1988                 if (mDisplayNameCandidate.displayNameSource < displayNameSource) {
1989                     // New values come from an superior source, e.g. structured name vs phone number
1990                     replace = true;
1991                 } else if (mDisplayNameCandidate.displayNameSource == displayNameSource) {
1992                     if (!mDisplayNameCandidate.writableAccount && writableAccount) {
1993                         replace = true;
1994                     } else if (mDisplayNameCandidate.writableAccount == writableAccount) {
1995                         if (NameNormalizer.compareComplexity(displayName,
1996                                 mDisplayNameCandidate.displayName) > 0) {
1997                             // New name is more complex than the previously found one
1998                             replace = true;
1999                         }
2000                     }
2001                 }
2002             }
2003         }
2004 
2005         if (replace) {
2006             mDisplayNameCandidate.rawContactId = rawContactId;
2007             mDisplayNameCandidate.displayName = displayName;
2008             mDisplayNameCandidate.displayNameSource = displayNameSource;
2009             mDisplayNameCandidate.verified = verified;
2010             mDisplayNameCandidate.writableAccount = writableAccount;
2011         }
2012     }
2013 
2014     private interface PhotoIdQuery {
2015         final String[] COLUMNS = new String[] {
2016             AccountsColumns.CONCRETE_ACCOUNT_TYPE,
2017             DataColumns.CONCRETE_ID,
2018             Data.IS_SUPER_PRIMARY,
2019             Photo.PHOTO_FILE_ID,
2020         };
2021 
2022         int ACCOUNT_TYPE = 0;
2023         int DATA_ID = 1;
2024         int IS_SUPER_PRIMARY = 2;
2025         int PHOTO_FILE_ID = 3;
2026     }
2027 
updatePhotoId(SQLiteDatabase db, long rawContactId)2028     public void updatePhotoId(SQLiteDatabase db, long rawContactId) {
2029 
2030         long contactId = mDbHelper.getContactId(rawContactId);
2031         if (contactId == 0) {
2032             return;
2033         }
2034 
2035         long bestPhotoId = -1;
2036         long bestPhotoFileId = 0;
2037         int photoPriority = -1;
2038 
2039         long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
2040 
2041         String tables = Tables.RAW_CONTACTS
2042                 + " JOIN " + Tables.ACCOUNTS + " ON ("
2043                     + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
2044                     + ")"
2045                 + " JOIN " + Tables.DATA + " ON("
2046                 + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
2047                 + " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND "
2048                         + Photo.PHOTO + " NOT NULL))";
2049 
2050         mSelectionArgs1[0] = String.valueOf(contactId);
2051         final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS,
2052                 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
2053         try {
2054             PhotoEntry bestPhotoEntry = null;
2055             while (c.moveToNext()) {
2056                 long dataId = c.getLong(PhotoIdQuery.DATA_ID);
2057                 long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID);
2058                 boolean superPrimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0;
2059                 PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
2060 
2061                 // Note that data set does not come into play here, since accounts are looked up in
2062                 // the account manager in the priority resolver.
2063                 String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE);
2064                 int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
2065                 if (superPrimary || hasHigherPhotoPriority(
2066                         photoEntry, priority, bestPhotoEntry, photoPriority)) {
2067                     bestPhotoEntry = photoEntry;
2068                     photoPriority = priority;
2069                     bestPhotoId = dataId;
2070                     bestPhotoFileId = photoFileId;
2071                     if (superPrimary) {
2072                         break;
2073                     }
2074                 }
2075             }
2076         } finally {
2077             c.close();
2078         }
2079 
2080         if (bestPhotoId == -1) {
2081             mPhotoIdUpdate.bindNull(1);
2082         } else {
2083             mPhotoIdUpdate.bindLong(1, bestPhotoId);
2084         }
2085 
2086         if (bestPhotoFileId == 0) {
2087             mPhotoIdUpdate.bindNull(2);
2088         } else {
2089             mPhotoIdUpdate.bindLong(2, bestPhotoFileId);
2090         }
2091 
2092         mPhotoIdUpdate.bindLong(3, contactId);
2093         mPhotoIdUpdate.execute();
2094     }
2095 
2096     private interface PhotoFileQuery {
2097         final String[] COLUMNS = new String[] {
2098                 PhotoFiles.HEIGHT,
2099                 PhotoFiles.WIDTH,
2100                 PhotoFiles.FILESIZE
2101         };
2102 
2103         int HEIGHT = 0;
2104         int WIDTH = 1;
2105         int FILESIZE = 2;
2106     }
2107 
2108     private class PhotoEntry implements Comparable<PhotoEntry> {
2109         // Pixel count (width * height) for the image.
2110         final int pixelCount;
2111 
2112         // File size (in bytes) of the image.  Not populated if the image is a thumbnail.
2113         final int fileSize;
2114 
PhotoEntry(int pixelCount, int fileSize)2115         private PhotoEntry(int pixelCount, int fileSize) {
2116             this.pixelCount = pixelCount;
2117             this.fileSize = fileSize;
2118         }
2119 
2120         @Override
compareTo(PhotoEntry pe)2121         public int compareTo(PhotoEntry pe) {
2122             if (pe == null) {
2123                 return -1;
2124             }
2125             if (pixelCount == pe.pixelCount) {
2126                 return pe.fileSize - fileSize;
2127             } else {
2128                 return pe.pixelCount - pixelCount;
2129             }
2130         }
2131     }
2132 
getPhotoMetadata(SQLiteDatabase db, long photoFileId)2133     private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) {
2134         if (photoFileId == 0) {
2135             // Assume standard thumbnail size.  Don't bother getting a file size for priority;
2136             // we should fall back to photo priority resolver if all we have are thumbnails.
2137             int thumbDim = mContactsProvider.getMaxThumbnailDim();
2138             return new PhotoEntry(thumbDim * thumbDim, 0);
2139         } else {
2140             Cursor c = db.query(Tables.PHOTO_FILES, PhotoFileQuery.COLUMNS, PhotoFiles._ID + "=?",
2141                     new String[]{String.valueOf(photoFileId)}, null, null, null);
2142             try {
2143                 if (c.getCount() == 1) {
2144                     c.moveToFirst();
2145                     int pixelCount =
2146                             c.getInt(PhotoFileQuery.HEIGHT) * c.getInt(PhotoFileQuery.WIDTH);
2147                     return new PhotoEntry(pixelCount, c.getInt(PhotoFileQuery.FILESIZE));
2148                 }
2149             } finally {
2150                 c.close();
2151             }
2152         }
2153         return new PhotoEntry(0, 0);
2154     }
2155 
2156     private interface DisplayNameQuery {
2157         String[] COLUMNS = new String[] {
2158             RawContacts._ID,
2159             RawContactsColumns.DISPLAY_NAME,
2160             RawContactsColumns.DISPLAY_NAME_SOURCE,
2161             RawContacts.NAME_VERIFIED,
2162             RawContacts.SOURCE_ID,
2163             RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
2164         };
2165 
2166         int _ID = 0;
2167         int DISPLAY_NAME = 1;
2168         int DISPLAY_NAME_SOURCE = 2;
2169         int NAME_VERIFIED = 3;
2170         int SOURCE_ID = 4;
2171         int ACCOUNT_TYPE_AND_DATA_SET = 5;
2172     }
2173 
updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId)2174     public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) {
2175         long contactId = mDbHelper.getContactId(rawContactId);
2176         if (contactId == 0) {
2177             return;
2178         }
2179 
2180         updateDisplayNameForContact(db, contactId);
2181     }
2182 
updateDisplayNameForContact(SQLiteDatabase db, long contactId)2183     public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) {
2184         boolean lookupKeyUpdateNeeded = false;
2185 
2186         mDisplayNameCandidate.clear();
2187 
2188         mSelectionArgs1[0] = String.valueOf(contactId);
2189         final Cursor c = db.query(Views.RAW_CONTACTS, DisplayNameQuery.COLUMNS,
2190                 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
2191         try {
2192             while (c.moveToNext()) {
2193                 long rawContactId = c.getLong(DisplayNameQuery._ID);
2194                 String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME);
2195                 int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE);
2196                 int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED);
2197                 String accountTypeAndDataSet = c.getString(
2198                         DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET);
2199                 processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
2200                         mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet),
2201                         nameVerified != 0);
2202 
2203                 // If the raw contact has no source id, the lookup key is based on the display
2204                 // name, so the lookup key needs to be updated.
2205                 lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID);
2206             }
2207         } finally {
2208             c.close();
2209         }
2210 
2211         if (mDisplayNameCandidate.rawContactId != -1) {
2212             mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId);
2213             mDisplayNameUpdate.bindLong(2, contactId);
2214             mDisplayNameUpdate.execute();
2215         }
2216 
2217         if (lookupKeyUpdateNeeded) {
2218             updateLookupKeyForContact(db, contactId);
2219         }
2220     }
2221 
2222 
2223     /**
2224      * Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the
2225      * specified raw contact.
2226      */
updateHasPhoneNumber(SQLiteDatabase db, long rawContactId)2227     public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) {
2228 
2229         long contactId = mDbHelper.getContactId(rawContactId);
2230         if (contactId == 0) {
2231             return;
2232         }
2233 
2234         final SQLiteStatement hasPhoneNumberUpdate = db.compileStatement(
2235                 "UPDATE " + Tables.CONTACTS +
2236                 " SET " + Contacts.HAS_PHONE_NUMBER + "="
2237                         + "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)"
2238                         + " FROM " + Tables.DATA_JOIN_RAW_CONTACTS
2239                         + " WHERE " + DataColumns.MIMETYPE_ID + "=?"
2240                                 + " AND " + Phone.NUMBER + " NOT NULL"
2241                                 + " AND " + RawContacts.CONTACT_ID + "=?)" +
2242                 " WHERE " + Contacts._ID + "=?");
2243         try {
2244             hasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE));
2245             hasPhoneNumberUpdate.bindLong(2, contactId);
2246             hasPhoneNumberUpdate.bindLong(3, contactId);
2247             hasPhoneNumberUpdate.execute();
2248         } finally {
2249             hasPhoneNumberUpdate.close();
2250         }
2251     }
2252 
2253     private interface LookupKeyQuery {
2254         String TABLE = Views.RAW_CONTACTS;
2255         String[] COLUMNS = new String[] {
2256             RawContacts._ID,
2257             RawContactsColumns.DISPLAY_NAME,
2258             RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
2259             RawContacts.ACCOUNT_NAME,
2260             RawContacts.SOURCE_ID,
2261         };
2262 
2263         int ID = 0;
2264         int DISPLAY_NAME = 1;
2265         int ACCOUNT_TYPE_AND_DATA_SET = 2;
2266         int ACCOUNT_NAME = 3;
2267         int SOURCE_ID = 4;
2268     }
2269 
updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId)2270     public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) {
2271         long contactId = mDbHelper.getContactId(rawContactId);
2272         if (contactId == 0) {
2273             return;
2274         }
2275 
2276         updateLookupKeyForContact(db, contactId);
2277     }
2278 
updateLookupKeyForContact(SQLiteDatabase db, long contactId)2279     private void updateLookupKeyForContact(SQLiteDatabase db, long contactId) {
2280         String lookupKey = computeLookupKeyForContact(db, contactId);
2281 
2282         if (lookupKey == null) {
2283             mLookupKeyUpdate.bindNull(1);
2284         } else {
2285             mLookupKeyUpdate.bindString(1, Uri.encode(lookupKey));
2286         }
2287         mLookupKeyUpdate.bindLong(2, contactId);
2288 
2289         mLookupKeyUpdate.execute();
2290     }
2291 
computeLookupKeyForContact(SQLiteDatabase db, long contactId)2292     protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) {
2293         StringBuilder sb = new StringBuilder();
2294         mSelectionArgs1[0] = String.valueOf(contactId);
2295         final Cursor c = db.query(LookupKeyQuery.TABLE, LookupKeyQuery.COLUMNS,
2296                 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID);
2297         try {
2298             while (c.moveToNext()) {
2299                 ContactLookupKey.appendToLookupKey(sb,
2300                         c.getString(LookupKeyQuery.ACCOUNT_TYPE_AND_DATA_SET),
2301                         c.getString(LookupKeyQuery.ACCOUNT_NAME),
2302                         c.getLong(LookupKeyQuery.ID),
2303                         c.getString(LookupKeyQuery.SOURCE_ID),
2304                         c.getString(LookupKeyQuery.DISPLAY_NAME));
2305             }
2306         } finally {
2307             c.close();
2308         }
2309         return sb.length() == 0 ? null : sb.toString();
2310     }
2311 
2312     /**
2313      * Execute {@link SQLiteStatement} that will update the
2314      * {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}.
2315      */
updateStarred(long rawContactId)2316     public void updateStarred(long rawContactId) {
2317         long contactId = mDbHelper.getContactId(rawContactId);
2318         if (contactId == 0) {
2319             return;
2320         }
2321 
2322         mStarredUpdate.bindLong(1, contactId);
2323         mStarredUpdate.execute();
2324     }
2325 
2326     /**
2327      * Finds matching contacts and returns a cursor on those.
2328      */
queryAggregationSuggestions(SQLiteQueryBuilder qb, String[] projection, long contactId, int maxSuggestions, String filter, ArrayList<AggregationSuggestionParameter> parameters)2329     public Cursor queryAggregationSuggestions(SQLiteQueryBuilder qb,
2330             String[] projection, long contactId, int maxSuggestions, String filter,
2331             ArrayList<AggregationSuggestionParameter> parameters) {
2332         final SQLiteDatabase db = mDbHelper.getReadableDatabase();
2333         db.beginTransaction();
2334         try {
2335             List<MatchScore> bestMatches = findMatchingContacts(db, contactId, parameters);
2336             return queryMatchingContacts(qb, db, projection, bestMatches, maxSuggestions, filter);
2337         } finally {
2338             db.endTransaction();
2339         }
2340     }
2341 
2342     private interface ContactIdQuery {
2343         String[] COLUMNS = new String[] {
2344             Contacts._ID
2345         };
2346 
2347         int _ID = 0;
2348     }
2349 
2350     /**
2351      * Loads contacts with specified IDs and returns them in the order of IDs in the
2352      * supplied list.
2353      */
queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db, String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter)2354     private Cursor queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db,
2355             String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter) {
2356         StringBuilder sb = new StringBuilder();
2357         sb.append(Contacts._ID);
2358         sb.append(" IN (");
2359         for (int i = 0; i < bestMatches.size(); i++) {
2360             MatchScore matchScore = bestMatches.get(i);
2361             if (i != 0) {
2362                 sb.append(",");
2363             }
2364             sb.append(matchScore.getContactId());
2365         }
2366         sb.append(")");
2367 
2368         if (!TextUtils.isEmpty(filter)) {
2369             sb.append(" AND " + Contacts._ID + " IN ");
2370             mContactsProvider.appendContactFilterAsNestedQuery(sb, filter);
2371         }
2372 
2373         // Run a query and find ids of best matching contacts satisfying the filter (if any)
2374         HashSet<Long> foundIds = new HashSet<Long>();
2375         Cursor cursor = db.query(qb.getTables(), ContactIdQuery.COLUMNS, sb.toString(),
2376                 null, null, null, null);
2377         try {
2378             while(cursor.moveToNext()) {
2379                 foundIds.add(cursor.getLong(ContactIdQuery._ID));
2380             }
2381         } finally {
2382             cursor.close();
2383         }
2384 
2385         // Exclude all contacts that did not match the filter
2386         Iterator<MatchScore> iter = bestMatches.iterator();
2387         while (iter.hasNext()) {
2388             long id = iter.next().getContactId();
2389             if (!foundIds.contains(id)) {
2390                 iter.remove();
2391             }
2392         }
2393 
2394         // Limit the number of returned suggestions
2395         final List<MatchScore> limitedMatches;
2396         if (bestMatches.size() > maxSuggestions) {
2397             limitedMatches = bestMatches.subList(0, maxSuggestions);
2398         } else {
2399             limitedMatches = bestMatches;
2400         }
2401 
2402         // Build an in-clause with the remaining contact IDs
2403         sb.setLength(0);
2404         sb.append(Contacts._ID);
2405         sb.append(" IN (");
2406         for (int i = 0; i < limitedMatches.size(); i++) {
2407             MatchScore matchScore = limitedMatches.get(i);
2408             if (i != 0) {
2409                 sb.append(",");
2410             }
2411             sb.append(matchScore.getContactId());
2412         }
2413         sb.append(")");
2414 
2415         // Run the final query with the required projection and contact IDs found by the first query
2416         cursor = qb.query(db, projection, sb.toString(), null, null, null, Contacts._ID);
2417 
2418         // Build a sorted list of discovered IDs
2419         ArrayList<Long> sortedContactIds = new ArrayList<Long>(limitedMatches.size());
2420         for (MatchScore matchScore : limitedMatches) {
2421             sortedContactIds.add(matchScore.getContactId());
2422         }
2423 
2424         Collections.sort(sortedContactIds);
2425 
2426         // Map cursor indexes according to the descending order of match scores
2427         int[] positionMap = new int[limitedMatches.size()];
2428         for (int i = 0; i < positionMap.length; i++) {
2429             long id = limitedMatches.get(i).getContactId();
2430             positionMap[i] = sortedContactIds.indexOf(id);
2431         }
2432 
2433         return new ReorderingCursorWrapper(cursor, positionMap);
2434     }
2435 
2436     /**
2437      * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
2438      * descending order of match score.
2439      * @param parameters
2440      */
findMatchingContacts(final SQLiteDatabase db, long contactId, ArrayList<AggregationSuggestionParameter> parameters)2441     private List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId,
2442             ArrayList<AggregationSuggestionParameter> parameters) {
2443 
2444         MatchCandidateList candidates = new MatchCandidateList();
2445         ContactMatcher matcher = new ContactMatcher();
2446 
2447         // Don't aggregate a contact with itself
2448         matcher.keepOut(contactId);
2449 
2450         if (parameters == null || parameters.size() == 0) {
2451             final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
2452                     RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
2453             try {
2454                 while (c.moveToNext()) {
2455                     long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID);
2456                     updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates,
2457                             matcher);
2458                 }
2459             } finally {
2460                 c.close();
2461             }
2462         } else {
2463             updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates,
2464                     matcher, parameters);
2465         }
2466 
2467         return matcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SUGGEST);
2468     }
2469 
2470     /**
2471      * Computes scores for contacts that have matching data rows.
2472      */
updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, long rawContactId, MatchCandidateList candidates, ContactMatcher matcher)2473     private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
2474             long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
2475 
2476         updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
2477         updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
2478         updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
2479         updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
2480         loadNameMatchCandidates(db, rawContactId, candidates, false);
2481         lookupApproximateNameMatches(db, candidates, matcher);
2482     }
2483 
updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, MatchCandidateList candidates, ContactMatcher matcher, ArrayList<AggregationSuggestionParameter> parameters)2484     private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
2485             MatchCandidateList candidates, ContactMatcher matcher,
2486             ArrayList<AggregationSuggestionParameter> parameters) {
2487         for (AggregationSuggestionParameter parameter : parameters) {
2488             if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) {
2489                 updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher);
2490             }
2491 
2492             // TODO: add support for other parameter kinds
2493         }
2494     }
2495 }
2496