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