ContactAggregator.java revision 085a7471448168069dac091eeb8a1c5e76138ffb
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
17package com.android.providers.contacts.aggregation;
18
19import android.database.Cursor;
20import android.database.DatabaseUtils;
21import android.database.sqlite.SQLiteDatabase;
22import android.database.sqlite.SQLiteQueryBuilder;
23import android.database.sqlite.SQLiteStatement;
24import android.net.Uri;
25import android.provider.ContactsContract.AggregationExceptions;
26import android.provider.ContactsContract.CommonDataKinds.Email;
27import android.provider.ContactsContract.CommonDataKinds.Identity;
28import android.provider.ContactsContract.CommonDataKinds.Phone;
29import android.provider.ContactsContract.CommonDataKinds.Photo;
30import android.provider.ContactsContract.Contacts;
31import android.provider.ContactsContract.Contacts.AggregationSuggestions;
32import android.provider.ContactsContract.Data;
33import android.provider.ContactsContract.DisplayNameSources;
34import android.provider.ContactsContract.FullNameStyle;
35import android.provider.ContactsContract.PhotoFiles;
36import android.provider.ContactsContract.PinnedPositions;
37import android.provider.ContactsContract.RawContacts;
38import android.provider.ContactsContract.StatusUpdates;
39import android.text.TextUtils;
40import android.util.EventLog;
41import android.util.Log;
42
43import com.android.internal.annotations.VisibleForTesting;
44import com.android.providers.contacts.ContactLookupKey;
45import com.android.providers.contacts.ContactsDatabaseHelper;
46import com.android.providers.contacts.ContactsDatabaseHelper.AccountsColumns;
47import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns;
48import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns;
49import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns;
50import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns;
51import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType;
52import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns;
53import com.android.providers.contacts.ContactsDatabaseHelper.PresenceColumns;
54import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns;
55import com.android.providers.contacts.ContactsDatabaseHelper.StatusUpdatesColumns;
56import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
57import com.android.providers.contacts.ContactsDatabaseHelper.Views;
58import com.android.providers.contacts.ContactsProvider2;
59import com.android.providers.contacts.NameLookupBuilder;
60import com.android.providers.contacts.NameNormalizer;
61import com.android.providers.contacts.NameSplitter;
62import com.android.providers.contacts.PhotoPriorityResolver;
63import com.android.providers.contacts.ReorderingCursorWrapper;
64import com.android.providers.contacts.TransactionContext;
65import com.android.providers.contacts.aggregation.util.CommonNicknameCache;
66import com.android.providers.contacts.aggregation.util.ContactMatcher;
67import com.android.providers.contacts.aggregation.util.ContactMatcher.MatchScore;
68import com.android.providers.contacts.database.ContactsTableUtil;
69import com.android.providers.contacts.util.Clock;
70
71import com.google.android.collect.Maps;
72import com.google.android.collect.Sets;
73import com.google.common.collect.Multimap;
74import com.google.common.collect.HashMultimap;
75
76import java.util.ArrayList;
77import java.util.Collections;
78import java.util.HashMap;
79import java.util.HashSet;
80import java.util.Iterator;
81import java.util.List;
82import java.util.Locale;
83import 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 */
90public 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
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
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         */
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
246        public void clear() {
247            mCount = 0;
248        }
249
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
266        public DisplayNameCandidate() {
267            clear();
268        }
269
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     */
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
406    public void setEnabled(boolean enabled) {
407        mEnabled = enabled;
408    }
409
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     */
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")
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
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
541    public void markNewForAggregation(long rawContactId, int aggregationMode) {
542        mRawContactsMarkedForAggregation.put(rawContactId, aggregationMode);
543    }
544
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     */
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     */
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     */
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
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
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
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
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     */
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     */
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     */
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        // Clear is_super_primary setting for all the mime-types exist in both raw contact
933        // of rawContactId and raw contacts of contactId
934        String superPrimaryUpdateSql = "UPDATE " + Tables.DATA +
935                " SET " + Data.IS_SUPER_PRIMARY + "=0" +
936                " WHERE (" +  Data.RAW_CONTACT_ID +
937                        " IN (SELECT " + RawContacts._ID +  " FROM " + Tables.RAW_CONTACTS +
938                        " WHERE " + RawContacts.CONTACT_ID + "=?1)" +
939                        " OR " +  Data.RAW_CONTACT_ID + "=?2)";
940
941        if (index > 0) {
942            mimeTypeCondition.append(')');
943            superPrimaryUpdateSql += mimeTypeCondition.toString();
944        }
945
946        db.execSQL(superPrimaryUpdateSql, args);
947    }
948
949    /**
950     * @return JOIN if the raw contact of {@code rawContactId} can be joined into the existing
951     * contact of {@code contactId}. KEEP_SEPARATE if the raw contact of {@code rawContactId}
952     * cannot be joined into the existing contact of {@code contactId}. RE_AGGREGATE if raw contact
953     * of {@code rawContactId} and all the raw contacts of contact of {@code contactId} need to be
954     * re-aggregated.
955     *
956     * If contact of {@code contactId} doesn't contain any raw contacts from the same account as
957     * raw contact of {@code rawContactId}, join raw contact with contact if there is no identity
958     * mismatch between them on the same namespace, otherwise, keep them separate.
959     *
960     * If contact of {@code contactId} contains raw contacts from the same account as raw contact of
961     * {@code rawContactId}, join raw contact with contact if there's at least one raw contact in
962     * those raw contacts that shares at least one email address, phone number, or identity;
963     * otherwise, re-aggregate raw contact and all the raw contacts of contact.
964     */
965    private int canJoinIntoContact(SQLiteDatabase db, long rawContactId,
966            Set<Long> rawContactIdsInSameAccount, Set<Long> rawContactIdsInOtherAccount ) {
967
968        if (rawContactIdsInSameAccount.isEmpty()) {
969            final String rid = String.valueOf(rawContactId);
970            final String ridsInOtherAccts = TextUtils.join(",", rawContactIdsInOtherAccount);
971            // If there is no identity match between raw contact of [rawContactId] and
972            // any raw contact in other accounts on the same namespace, and there is at least
973            // one identity mismatch exist, keep raw contact separate from contact.
974            if (DatabaseUtils.longForQuery(db, buildIdentityMatchingSql(rid, ridsInOtherAccts,
975                    /* isIdentityMatching =*/ true, /* countOnly =*/ true), null) == 0 &&
976                    DatabaseUtils.longForQuery(db, buildIdentityMatchingSql(rid, ridsInOtherAccts,
977                            /* isIdentityMatching =*/ false, /* countOnly =*/ true), null) > 0) {
978                if (VERBOSE_LOGGING) {
979                    Log.v(TAG, "canJoinIntoContact: no duplicates, but has no matching identity " +
980                            "and has mis-matching identity on the same namespace between rid=" +
981                            rid + " and ridsInOtherAccts=" + ridsInOtherAccts);
982                }
983                return KEEP_SEPARATE; // has identity and identity doesn't match
984            } else {
985                if (VERBOSE_LOGGING) {
986                    Log.v(TAG, "canJoinIntoContact: can join the first raw contact from the same " +
987                            "account without any identity mismatch.");
988                }
989                return JOIN; // no identity or identity match
990            }
991        }
992        if (VERBOSE_LOGGING) {
993            Log.v(TAG, "canJoinIntoContact: " + rawContactIdsInSameAccount.size() +
994                    " duplicate(s) found");
995        }
996
997
998        final Set<Long> rawContactIdSet = new HashSet<Long>();
999        rawContactIdSet.add(rawContactId);
1000        if (rawContactIdsInSameAccount.size() > 0 &&
1001                isDataMaching(db, rawContactIdSet, rawContactIdsInSameAccount)) {
1002            if (VERBOSE_LOGGING) {
1003                Log.v(TAG, "canJoinIntoContact: join if there is a data matching found in the " +
1004                        "same account");
1005            }
1006            return JOIN;
1007        } else {
1008            if (VERBOSE_LOGGING) {
1009                Log.v(TAG, "canJoinIntoContact: re-aggregate rid=" + rawContactId +
1010                        " with its best matching contact to connected component");
1011            }
1012            return RE_AGGREGATE;
1013        }
1014    }
1015
1016    private interface RawContactMatchingSelectionStatement {
1017        String SELECT_COUNT =  "SELECT count(*) " ;
1018        String SELECT_ID = "SELECT d1." + Data.RAW_CONTACT_ID + ",d2."  + Data.RAW_CONTACT_ID ;
1019    }
1020
1021    /**
1022     * Build sql to check if there is any identity match/mis-match between two sets of raw contact
1023     * ids on the same namespace.
1024     */
1025    private String buildIdentityMatchingSql(String rawContactIdSet1, String rawContactIdSet2,
1026            boolean isIdentityMatching, boolean countOnly) {
1027        final String identityType = String.valueOf(mMimeTypeIdIdentity);
1028        final String matchingOperator = (isIdentityMatching) ? "=" : "!=";
1029        final String sql =
1030                " FROM " + Tables.DATA + " AS d1" +
1031                " JOIN " + Tables.DATA + " AS d2" +
1032                        " ON (d1." + Identity.IDENTITY + matchingOperator +
1033                        " d2." + Identity.IDENTITY + " AND" +
1034                        " d1." + Identity.NAMESPACE + " = d2." + Identity.NAMESPACE + " )" +
1035                " WHERE d1." + DataColumns.MIMETYPE_ID + " = " + identityType +
1036                " AND d2." + DataColumns.MIMETYPE_ID + " = " + identityType +
1037                " AND d1." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet1 + ")" +
1038                " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet2 + ")";
1039        return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql :
1040                RawContactMatchingSelectionStatement.SELECT_ID + sql;
1041    }
1042
1043    private String buildEmailMatchingSql(String rawContactIdSet1, String rawContactIdSet2,
1044            boolean countOnly) {
1045        final String emailType = String.valueOf(mMimeTypeIdEmail);
1046        final String sql =
1047                " FROM " + Tables.DATA + " AS d1" +
1048                " JOIN " + Tables.DATA + " AS d2" +
1049                        " ON lower(d1." + Email.ADDRESS + ")= lower(d2." + Email.ADDRESS + ")" +
1050                " WHERE d1." + DataColumns.MIMETYPE_ID + " = " + emailType +
1051                " AND d2." + DataColumns.MIMETYPE_ID + " = " + emailType +
1052                " AND d1." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet1 + ")" +
1053                " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet2 + ")";
1054        return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql :
1055                RawContactMatchingSelectionStatement.SELECT_ID + sql;
1056    }
1057
1058    private String buildPhoneMatchingSql(String rawContactIdSet1, String rawContactIdSet2,
1059            boolean countOnly) {
1060        // It's a bit tricker because it has to be consistent with
1061        // updateMatchScoresBasedOnPhoneMatches().
1062        final String phoneType = String.valueOf(mMimeTypeIdPhone);
1063        final String sql =
1064                " FROM " + Tables.PHONE_LOOKUP + " AS p1" +
1065                " JOIN " + Tables.DATA + " AS d1 ON " +
1066                        "(d1." + Data._ID + "=p1." + PhoneLookupColumns.DATA_ID + ")" +
1067                " JOIN " + Tables.PHONE_LOOKUP + " AS p2 ON (p1." + PhoneLookupColumns.MIN_MATCH +
1068                        "=p2." + PhoneLookupColumns.MIN_MATCH + ")" +
1069                " JOIN " + Tables.DATA + " AS d2 ON " +
1070                        "(d2." + Data._ID + "=p2." + PhoneLookupColumns.DATA_ID + ")" +
1071                " WHERE d1." + DataColumns.MIMETYPE_ID + " = " + phoneType +
1072                " AND d2." + DataColumns.MIMETYPE_ID + " = " + phoneType +
1073                " AND d1." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet1 + ")" +
1074                " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIdSet2 + ")" +
1075                " AND PHONE_NUMBERS_EQUAL(d1." + Phone.NUMBER + ",d2." + Phone.NUMBER + "," +
1076                        String.valueOf(mDbHelper.getUseStrictPhoneNumberComparisonParameter()) +
1077                        ")";
1078        return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql :
1079                RawContactMatchingSelectionStatement.SELECT_ID + sql;
1080    }
1081
1082    private String buildExceptionMatchingSql(String rawContactIdSet1, String rawContactIdSet2) {
1083        return "SELECT " + AggregationExceptions.RAW_CONTACT_ID1 + ", " +
1084                AggregationExceptions.RAW_CONTACT_ID2 +
1085                " FROM " + Tables.AGGREGATION_EXCEPTIONS +
1086                " WHERE " + AggregationExceptions.RAW_CONTACT_ID1 + " IN (" +
1087                        rawContactIdSet1 + ")" +
1088                " AND " + AggregationExceptions.RAW_CONTACT_ID2 + " IN (" + rawContactIdSet2 + ")" +
1089                " AND " + AggregationExceptions.TYPE + "=" +
1090                        AggregationExceptions.TYPE_KEEP_TOGETHER ;
1091    }
1092
1093    private boolean isFirstColumnGreaterThanZero(SQLiteDatabase db, String query) {
1094        return DatabaseUtils.longForQuery(db, query, null) > 0;
1095    }
1096
1097    /**
1098     * If there's any identity, email address or a phone number matching between two raw contact
1099     * sets.
1100     */
1101    private boolean isDataMaching(SQLiteDatabase db, Set<Long> rawContactIdSet1,
1102            Set<Long> rawContactIdSet2) {
1103        final String rawContactIds1 = TextUtils.join(",", rawContactIdSet1);
1104        final String rawContactIds2 = TextUtils.join(",", rawContactIdSet2);
1105        // First, check for the identity
1106        if (isFirstColumnGreaterThanZero(db, buildIdentityMatchingSql(
1107                rawContactIds1, rawContactIds2,  /* isIdentityMatching =*/ true,
1108                /* countOnly =*/true))) {
1109            if (VERBOSE_LOGGING) {
1110                Log.v(TAG, "canJoinIntoContact: identity match found between " + rawContactIds1 +
1111                        " and " + rawContactIds2);
1112            }
1113            return true;
1114        }
1115
1116        // Next, check for the email address.
1117        if (isFirstColumnGreaterThanZero(db,
1118                buildEmailMatchingSql(rawContactIds1, rawContactIds2, true))) {
1119            if (VERBOSE_LOGGING) {
1120                Log.v(TAG, "canJoinIntoContact: email match found between " + rawContactIds1 +
1121                        " and " + rawContactIds2);
1122            }
1123            return true;
1124        }
1125
1126        // Lastly, the phone number.
1127        if (isFirstColumnGreaterThanZero(db,
1128                buildPhoneMatchingSql(rawContactIds1, rawContactIds2, true))) {
1129            if (VERBOSE_LOGGING) {
1130                Log.v(TAG, "canJoinIntoContact: phone match found between " + rawContactIds1 +
1131                        " and " + rawContactIds2);
1132            }
1133            return true;
1134        }
1135        return false;
1136    }
1137
1138    /**
1139     * Re-aggregate rawContact of {@code rawContactId} and all the raw contacts of
1140     * {@code existingRawContactIds} into connected components. This only happens when a given
1141     * raw contacts cannot be joined with its best matching contacts directly.
1142     *
1143     *  Two raw contacts are considered connected if they share at least one email address, phone
1144     *  number or identity. Create new contact for each connected component except the very first
1145     *  one that doesn't contain rawContactId of {@code rawContactId}.
1146     */
1147    private void reAggregateRawContacts(TransactionContext txContext, SQLiteDatabase db,
1148            long contactId, long currentContactId, long rawContactId,
1149            Set<Long> existingRawContactIds) {
1150        // Find the connected component based on the aggregation exceptions or
1151        // identity/email/phone matching for all the raw contacts of [contactId] and the give
1152        // raw contact.
1153        final Set<Long> allIds = new HashSet<Long>();
1154        allIds.add(rawContactId);
1155        allIds.addAll(existingRawContactIds);
1156        final Set<Set<Long>> connectedRawContactSets = findConnectedRawContacts(db, allIds);
1157
1158        if (connectedRawContactSets.size() == 1) {
1159            // If everything is connected, create one contact with [contactId]
1160            createContactForRawContacts(db, txContext, connectedRawContactSets.iterator().next(),
1161                    contactId);
1162        } else {
1163            for (Set<Long> connectedRawContactIds : connectedRawContactSets) {
1164                if (connectedRawContactIds.contains(rawContactId)) {
1165                    // crate contact for connect component containing [rawContactId], reuse
1166                    // [currentContactId] if possible.
1167                    createContactForRawContacts(db, txContext, connectedRawContactIds,
1168                            currentContactId == 0 ? null : currentContactId);
1169                    connectedRawContactSets.remove(connectedRawContactIds);
1170                    break;
1171                }
1172            }
1173            // Create new contact for each connected component except the last one. The last one
1174            // will reuse [contactId]. Only the last one can reuse [contactId] when all other raw
1175            // contacts has already been assigned new contact Id, so that the contact aggregation
1176            // stats could be updated correctly.
1177            int index = connectedRawContactSets.size();
1178            for (Set<Long> connectedRawContactIds : connectedRawContactSets) {
1179                if (index > 1) {
1180                    createContactForRawContacts(db, txContext, connectedRawContactIds, null);
1181                    index--;
1182                } else {
1183                    createContactForRawContacts(db, txContext, connectedRawContactIds, contactId);
1184                }
1185            }
1186        }
1187    }
1188
1189    /**
1190     * Partition the given raw contact Ids to connected component based on aggregation exception,
1191     * identity matching, email matching or phone matching.
1192     */
1193    private Set<Set<Long>> findConnectedRawContacts(SQLiteDatabase db, Set<Long> rawContactIdSet) {
1194        // Connections between two raw contacts
1195       final Multimap<Long, Long> matchingRawIdPairs = HashMultimap.create();
1196        String rawContactIds = TextUtils.join(",", rawContactIdSet);
1197        findIdPairs(db, buildExceptionMatchingSql(rawContactIds, rawContactIds),
1198                matchingRawIdPairs);
1199        findIdPairs(db, buildIdentityMatchingSql(rawContactIds, rawContactIds,
1200                /* isIdentityMatching =*/ true, /* countOnly =*/false), matchingRawIdPairs);
1201        findIdPairs(db, buildEmailMatchingSql(rawContactIds, rawContactIds, /* countOnly =*/false),
1202                matchingRawIdPairs);
1203        findIdPairs(db, buildPhoneMatchingSql(rawContactIds, rawContactIds,  /* countOnly =*/false),
1204                matchingRawIdPairs);
1205
1206        return findConnectedComponents(rawContactIdSet, matchingRawIdPairs);
1207    }
1208
1209    /**
1210     * Given a set of raw contact ids {@code rawContactIdSet} and the connection among them
1211     * {@code matchingRawIdPairs}, find the connected components.
1212     */
1213    @VisibleForTesting
1214    static Set<Set<Long>> findConnectedComponents(Set<Long> rawContactIdSet, Multimap<Long,
1215            Long> matchingRawIdPairs) {
1216        Set<Set<Long>> connectedRawContactSets = new HashSet<Set<Long>>();
1217        Set<Long> visited = new HashSet<Long>();
1218        for (Long id : rawContactIdSet) {
1219            if (!visited.contains(id)) {
1220                Set<Long> set = new HashSet<Long>();
1221                findConnectedComponentForRawContact(matchingRawIdPairs, visited, id, set);
1222                connectedRawContactSets.add(set);
1223            }
1224        }
1225        return connectedRawContactSets;
1226    }
1227
1228    private static void findConnectedComponentForRawContact(Multimap<Long, Long> connections,
1229            Set<Long> visited, Long rawContactId, Set<Long> results) {
1230        visited.add(rawContactId);
1231        results.add(rawContactId);
1232        for (long match : connections.get(rawContactId)) {
1233            if (!visited.contains(match)) {
1234                findConnectedComponentForRawContact(connections, visited, match, results);
1235            }
1236        }
1237    }
1238
1239    /**
1240     * Given a query which will return two non-null IDs in the first two columns as results, this
1241     * method will put two entries into the given result map for each pair of different IDs, one
1242     * keyed by each ID.
1243     */
1244    private void findIdPairs(SQLiteDatabase db, String query, Multimap<Long, Long> results) {
1245        Cursor cursor = db.rawQuery(query, null);
1246        try {
1247            cursor.moveToPosition(-1);
1248            while (cursor.moveToNext()) {
1249                long idA = cursor.getLong(0);
1250                long idB = cursor.getLong(1);
1251                if (idA != idB) {
1252                    results.put(idA, idB);
1253                    results.put(idB, idA);
1254                }
1255            }
1256        } finally {
1257            cursor.close();
1258        }
1259    }
1260
1261    /**
1262     * Creates a new Contact for a given set of the raw contacts of {@code rawContactIds} if the
1263     * given contactId is null. Otherwise, regroup them into contact with {@code contactId}.
1264     */
1265    private void createContactForRawContacts(SQLiteDatabase db, TransactionContext txContext,
1266            Set<Long> rawContactIds, Long contactId) {
1267        if (rawContactIds.isEmpty()) {
1268            // No raw contact id is provided.
1269            return;
1270        }
1271
1272        // If contactId is not provided, generates a new one.
1273        if (contactId == null) {
1274            mSelectionArgs1[0]= String.valueOf(rawContactIds.iterator().next());
1275            computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1,
1276                    mContactInsert);
1277            contactId = mContactInsert.executeInsert();
1278        }
1279        for (Long rawContactId : rawContactIds) {
1280            // Regrouped contacts should automatically be unpinned.
1281            unpinRawContact(rawContactId);
1282            setContactIdAndMarkAggregated(rawContactId, contactId);
1283            setPresenceContactId(rawContactId, contactId);
1284        }
1285        updateAggregateData(txContext, contactId);
1286    }
1287
1288    private static class RawContactIdQuery {
1289        public static final String TABLE = Tables.RAW_CONTACTS;
1290        public static final String[] COLUMNS = { RawContacts._ID };
1291        public static final String SELECTION = RawContacts.CONTACT_ID + "=?";
1292        public static final int RAW_CONTACT_ID = 0;
1293    }
1294
1295    /**
1296     * Ensures that automatic aggregation rules are followed after a contact
1297     * becomes visible or invisible. Specifically, consider this case: there are
1298     * three contacts named Foo. Two of them come from account A1 and one comes
1299     * from account A2. The aggregation rules say that in this case none of the
1300     * three Foo's should be aggregated: two of them are in the same account, so
1301     * they don't get aggregated; the third has two affinities, so it does not
1302     * join either of them.
1303     * <p>
1304     * Consider what happens if one of the "Foo"s from account A1 becomes
1305     * invisible. Nothing stands in the way of aggregating the other two
1306     * anymore, so they should get joined.
1307     * <p>
1308     * What if the invisible "Foo" becomes visible after that? We should split the
1309     * aggregate between the other two.
1310     */
1311    public void updateAggregationAfterVisibilityChange(long contactId) {
1312        SQLiteDatabase db = mDbHelper.getWritableDatabase();
1313        boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId);
1314        if (visible) {
1315            markContactForAggregation(db, contactId);
1316        } else {
1317            // Find all contacts that _could be_ aggregated with this one and
1318            // rerun aggregation for all of them
1319            mSelectionArgs1[0] = String.valueOf(contactId);
1320            Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
1321                    RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null);
1322            try {
1323                while (cursor.moveToNext()) {
1324                    long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID);
1325                    mMatcher.clear();
1326
1327                    updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher);
1328                    updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher);
1329                    List<MatchScore> bestMatches =
1330                            mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_PRIMARY);
1331                    for (MatchScore matchScore : bestMatches) {
1332                        markContactForAggregation(db, matchScore.getContactId());
1333                    }
1334
1335                    mMatcher.clear();
1336                    updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher);
1337                    updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher);
1338                    bestMatches =
1339                            mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SECONDARY);
1340                    for (MatchScore matchScore : bestMatches) {
1341                        markContactForAggregation(db, matchScore.getContactId());
1342                    }
1343                }
1344            } finally {
1345                cursor.close();
1346            }
1347        }
1348    }
1349
1350    /**
1351     * Updates the contact ID for the specified contact.
1352     */
1353    protected void setContactId(long rawContactId, long contactId) {
1354        mContactIdUpdate.bindLong(1, contactId);
1355        mContactIdUpdate.bindLong(2, rawContactId);
1356        mContactIdUpdate.execute();
1357    }
1358
1359    /**
1360     * Marks the specified raw contact ID as aggregated
1361     */
1362    private void markAggregated(long rawContactId) {
1363        mMarkAggregatedUpdate.bindLong(1, rawContactId);
1364        mMarkAggregatedUpdate.execute();
1365    }
1366
1367    /**
1368     * Updates the contact ID for the specified contact and marks the raw contact as aggregated.
1369     */
1370    private void setContactIdAndMarkAggregated(long rawContactId, long contactId) {
1371        mContactIdAndMarkAggregatedUpdate.bindLong(1, contactId);
1372        mContactIdAndMarkAggregatedUpdate.bindLong(2, rawContactId);
1373        mContactIdAndMarkAggregatedUpdate.execute();
1374    }
1375
1376    private void setPresenceContactId(long rawContactId, long contactId) {
1377        mPresenceContactIdUpdate.bindLong(1, contactId);
1378        mPresenceContactIdUpdate.bindLong(2, rawContactId);
1379        mPresenceContactIdUpdate.execute();
1380    }
1381
1382    private void unpinRawContact(long rawContactId) {
1383        mResetPinnedForRawContact.bindLong(1, rawContactId);
1384        mResetPinnedForRawContact.execute();
1385    }
1386
1387    interface AggregateExceptionPrefetchQuery {
1388        String TABLE = Tables.AGGREGATION_EXCEPTIONS;
1389
1390        String[] COLUMNS = {
1391            AggregationExceptions.RAW_CONTACT_ID1,
1392            AggregationExceptions.RAW_CONTACT_ID2,
1393        };
1394
1395        int RAW_CONTACT_ID1 = 0;
1396        int RAW_CONTACT_ID2 = 1;
1397    }
1398
1399    // A set of raw contact IDs for which there are aggregation exceptions
1400    private final HashSet<Long> mAggregationExceptionIds = new HashSet<Long>();
1401    private boolean mAggregationExceptionIdsValid;
1402
1403    public void invalidateAggregationExceptionCache() {
1404        mAggregationExceptionIdsValid = false;
1405    }
1406
1407    /**
1408     * Finds all raw contact IDs for which there are aggregation exceptions. The list of
1409     * ids is used as an optimization in aggregation: there is no point to run a query against
1410     * the agg_exceptions table if it is known that there are no records there for a given
1411     * raw contact ID.
1412     */
1413    private void prefetchAggregationExceptionIds(SQLiteDatabase db) {
1414        mAggregationExceptionIds.clear();
1415        final Cursor c = db.query(AggregateExceptionPrefetchQuery.TABLE,
1416                AggregateExceptionPrefetchQuery.COLUMNS,
1417                null, null, null, null, null);
1418
1419        try {
1420            while (c.moveToNext()) {
1421                long rawContactId1 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID1);
1422                long rawContactId2 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID2);
1423                mAggregationExceptionIds.add(rawContactId1);
1424                mAggregationExceptionIds.add(rawContactId2);
1425            }
1426        } finally {
1427            c.close();
1428        }
1429
1430        mAggregationExceptionIdsValid = true;
1431    }
1432
1433    interface AggregateExceptionQuery {
1434        String TABLE = Tables.AGGREGATION_EXCEPTIONS
1435            + " JOIN raw_contacts raw_contacts1 "
1436                    + " ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) "
1437            + " JOIN raw_contacts raw_contacts2 "
1438                    + " ON (agg_exceptions.raw_contact_id2 = raw_contacts2._id) ";
1439
1440        String[] COLUMNS = {
1441            AggregationExceptions.TYPE,
1442            AggregationExceptions.RAW_CONTACT_ID1,
1443            "raw_contacts1." + RawContacts.CONTACT_ID,
1444            "raw_contacts1." + RawContactsColumns.AGGREGATION_NEEDED,
1445            "raw_contacts2." + RawContacts.CONTACT_ID,
1446            "raw_contacts2." + RawContactsColumns.AGGREGATION_NEEDED,
1447        };
1448
1449        int TYPE = 0;
1450        int RAW_CONTACT_ID1 = 1;
1451        int CONTACT_ID1 = 2;
1452        int AGGREGATION_NEEDED_1 = 3;
1453        int CONTACT_ID2 = 4;
1454        int AGGREGATION_NEEDED_2 = 5;
1455    }
1456
1457    /**
1458     * Computes match scores based on exceptions entered by the user: always match and never match.
1459     * Returns the aggregate contact with the always match exception if any.
1460     */
1461    private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId,
1462            ContactMatcher matcher) {
1463        if (!mAggregationExceptionIdsValid) {
1464            prefetchAggregationExceptionIds(db);
1465        }
1466
1467        // If there are no aggregation exceptions involving this raw contact, there is no need to
1468        // run a query and we can just return -1, which stands for "nothing found"
1469        if (!mAggregationExceptionIds.contains(rawContactId)) {
1470            return -1;
1471        }
1472
1473        final Cursor c = db.query(AggregateExceptionQuery.TABLE,
1474                AggregateExceptionQuery.COLUMNS,
1475                AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId
1476                        + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId,
1477                null, null, null, null);
1478
1479        try {
1480            while (c.moveToNext()) {
1481                int type = c.getInt(AggregateExceptionQuery.TYPE);
1482                long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
1483                long contactId = -1;
1484                if (rawContactId == rawContactId1) {
1485                    if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_2) == 0
1486                            && !c.isNull(AggregateExceptionQuery.CONTACT_ID2)) {
1487                        contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2);
1488                    }
1489                } else {
1490                    if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_1) == 0
1491                            && !c.isNull(AggregateExceptionQuery.CONTACT_ID1)) {
1492                        contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1);
1493                    }
1494                }
1495                if (contactId != -1) {
1496                    if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) {
1497                        matcher.keepIn(contactId);
1498                    } else {
1499                        matcher.keepOut(contactId);
1500                    }
1501                }
1502            }
1503        } finally {
1504            c.close();
1505        }
1506
1507        return matcher.pickBestMatch(ContactMatcher.MAX_SCORE, true);
1508    }
1509
1510    /**
1511     * Picks the best matching contact based on matches between data elements.  It considers
1512     * name match to be primary and phone, email etc matches to be secondary.  A good primary
1513     * match triggers aggregation, while a good secondary match only triggers aggregation in
1514     * the absence of a strong primary mismatch.
1515     * <p>
1516     * Consider these examples:
1517     * <p>
1518     * John Doe with phone number 111-111-1111 and Jon Doe with phone number 111-111-1111 should
1519     * be aggregated (same number, similar names).
1520     * <p>
1521     * John Doe with phone number 111-111-1111 and Deborah Doe with phone number 111-111-1111 should
1522     * not be aggregated (same number, different names).
1523     */
1524    private long pickBestMatchBasedOnData(SQLiteDatabase db, long rawContactId,
1525            MatchCandidateList candidates, ContactMatcher matcher) {
1526
1527        // Find good matches based on name alone
1528        long bestMatch = updateMatchScoresBasedOnDataMatches(db, rawContactId, matcher);
1529        if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) {
1530            // We found multiple matches on the name - do not aggregate because of the ambiguity
1531            return -1;
1532        } else if (bestMatch == -1) {
1533            // We haven't found a good match on name, see if we have any matches on phone, email etc
1534            bestMatch = pickBestMatchBasedOnSecondaryData(db, rawContactId, candidates, matcher);
1535            if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) {
1536                return -1;
1537            }
1538        }
1539
1540        return bestMatch;
1541    }
1542
1543
1544    /**
1545     * Picks the best matching contact based on secondary data matches.  The method loads
1546     * structured names for all candidate contacts and recomputes match scores using approximate
1547     * matching.
1548     */
1549    private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db,
1550            long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
1551        List<Long> secondaryContactIds = matcher.prepareSecondaryMatchCandidates(
1552                ContactMatcher.SCORE_THRESHOLD_PRIMARY);
1553        if (secondaryContactIds == null || secondaryContactIds.size() > SECONDARY_HIT_LIMIT) {
1554            return -1;
1555        }
1556
1557        loadNameMatchCandidates(db, rawContactId, candidates, true);
1558
1559        mSb.setLength(0);
1560        mSb.append(RawContacts.CONTACT_ID).append(" IN (");
1561        for (int i = 0; i < secondaryContactIds.size(); i++) {
1562            if (i != 0) {
1563                mSb.append(',');
1564            }
1565            mSb.append(secondaryContactIds.get(i));
1566        }
1567
1568        // We only want to compare structured names to structured names
1569        // at this stage, we need to ignore all other sources of name lookup data.
1570        mSb.append(") AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL);
1571
1572        matchAllCandidates(db, mSb.toString(), candidates, matcher,
1573                ContactMatcher.MATCHING_ALGORITHM_CONSERVATIVE, null);
1574
1575        return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY, false);
1576    }
1577
1578    private interface NameLookupQuery {
1579        String TABLE = Tables.NAME_LOOKUP;
1580
1581        String SELECTION = NameLookupColumns.RAW_CONTACT_ID + "=?";
1582        String SELECTION_STRUCTURED_NAME_BASED =
1583                SELECTION + " AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL;
1584
1585        String[] COLUMNS = new String[] {
1586                NameLookupColumns.NORMALIZED_NAME,
1587                NameLookupColumns.NAME_TYPE
1588        };
1589
1590        int NORMALIZED_NAME = 0;
1591        int NAME_TYPE = 1;
1592    }
1593
1594    private void loadNameMatchCandidates(SQLiteDatabase db, long rawContactId,
1595            MatchCandidateList candidates, boolean structuredNameBased) {
1596        candidates.clear();
1597        mSelectionArgs1[0] = String.valueOf(rawContactId);
1598        Cursor c = db.query(NameLookupQuery.TABLE, NameLookupQuery.COLUMNS,
1599                structuredNameBased
1600                        ? NameLookupQuery.SELECTION_STRUCTURED_NAME_BASED
1601                        : NameLookupQuery.SELECTION,
1602                mSelectionArgs1, null, null, null);
1603        try {
1604            while (c.moveToNext()) {
1605                String normalizedName = c.getString(NameLookupQuery.NORMALIZED_NAME);
1606                int type = c.getInt(NameLookupQuery.NAME_TYPE);
1607                candidates.add(normalizedName, type);
1608            }
1609        } finally {
1610            c.close();
1611        }
1612    }
1613
1614    /**
1615     * Computes scores for contacts that have matching data rows.
1616     */
1617    private long updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId,
1618            ContactMatcher matcher) {
1619
1620        updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
1621        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
1622        long bestMatch = matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_PRIMARY, false);
1623        if (bestMatch != -1) {
1624            return bestMatch;
1625        }
1626
1627        updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
1628        updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
1629
1630        return -1;
1631    }
1632
1633    private interface IdentityLookupMatchQuery {
1634        final String TABLE = Tables.DATA + " dataA"
1635                + " JOIN " + Tables.DATA + " dataB" +
1636                " ON (dataA." + Identity.NAMESPACE + "=dataB." + Identity.NAMESPACE +
1637                " AND dataA." + Identity.IDENTITY + "=dataB." + Identity.IDENTITY + ")"
1638                + " JOIN " + Tables.RAW_CONTACTS +
1639                " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1640                + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1641
1642        final String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1"
1643                + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2"
1644                + " AND dataA." + Identity.NAMESPACE + " NOT NULL"
1645                + " AND dataA." + Identity.IDENTITY + " NOT NULL"
1646                + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2"
1647                + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1648                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1649
1650        final String[] COLUMNS = new String[] {
1651            RawContacts.CONTACT_ID
1652        };
1653
1654        int CONTACT_ID = 0;
1655    }
1656
1657    /**
1658     * Finds contacts with exact identity matches to the the specified raw contact.
1659     */
1660    private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId,
1661            ContactMatcher matcher) {
1662        mSelectionArgs2[0] = String.valueOf(rawContactId);
1663        mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity);
1664        Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS,
1665                IdentityLookupMatchQuery.SELECTION,
1666                mSelectionArgs2, RawContacts.CONTACT_ID, null, null);
1667        try {
1668            while (c.moveToNext()) {
1669                final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID);
1670                matcher.matchIdentity(contactId);
1671            }
1672        } finally {
1673            c.close();
1674        }
1675
1676    }
1677
1678    private interface NameLookupMatchQuery {
1679        String TABLE = Tables.NAME_LOOKUP + " nameA"
1680                + " JOIN " + Tables.NAME_LOOKUP + " nameB" +
1681                " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "="
1682                        + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")"
1683                + " JOIN " + Tables.RAW_CONTACTS +
1684                " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = "
1685                        + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1686
1687        String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?"
1688                + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1689                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1690
1691        String[] COLUMNS = new String[] {
1692            RawContacts.CONTACT_ID,
1693            "nameA." + NameLookupColumns.NORMALIZED_NAME,
1694            "nameA." + NameLookupColumns.NAME_TYPE,
1695            "nameB." + NameLookupColumns.NAME_TYPE,
1696        };
1697
1698        int CONTACT_ID = 0;
1699        int NAME = 1;
1700        int NAME_TYPE_A = 2;
1701        int NAME_TYPE_B = 3;
1702    }
1703
1704    /**
1705     * Finds contacts with names matching the name of the specified raw contact.
1706     */
1707    private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId,
1708            ContactMatcher matcher) {
1709        mSelectionArgs1[0] = String.valueOf(rawContactId);
1710        Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS,
1711                NameLookupMatchQuery.SELECTION,
1712                mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING);
1713        try {
1714            while (c.moveToNext()) {
1715                long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID);
1716                String name = c.getString(NameLookupMatchQuery.NAME);
1717                int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A);
1718                int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B);
1719                matcher.matchName(contactId, nameTypeA, name,
1720                        nameTypeB, name, ContactMatcher.MATCHING_ALGORITHM_EXACT);
1721                if (nameTypeA == NameLookupType.NICKNAME &&
1722                        nameTypeB == NameLookupType.NICKNAME) {
1723                    matcher.updateScoreWithNicknameMatch(contactId);
1724                }
1725            }
1726        } finally {
1727            c.close();
1728        }
1729    }
1730
1731    private interface NameLookupMatchQueryWithParameter {
1732        String TABLE = Tables.NAME_LOOKUP
1733                + " JOIN " + Tables.RAW_CONTACTS +
1734                " ON (" + NameLookupColumns.RAW_CONTACT_ID + " = "
1735                        + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1736
1737        String[] COLUMNS = new String[] {
1738            RawContacts.CONTACT_ID,
1739            NameLookupColumns.NORMALIZED_NAME,
1740            NameLookupColumns.NAME_TYPE,
1741        };
1742
1743        int CONTACT_ID = 0;
1744        int NAME = 1;
1745        int NAME_TYPE = 2;
1746    }
1747
1748    private final class NameLookupSelectionBuilder extends NameLookupBuilder {
1749
1750        private final MatchCandidateList mNameLookupCandidates;
1751
1752        private StringBuilder mSelection = new StringBuilder(
1753                NameLookupColumns.NORMALIZED_NAME + " IN(");
1754
1755
1756        public NameLookupSelectionBuilder(NameSplitter splitter, MatchCandidateList candidates) {
1757            super(splitter);
1758            this.mNameLookupCandidates = candidates;
1759        }
1760
1761        @Override
1762        protected String[] getCommonNicknameClusters(String normalizedName) {
1763            return mCommonNicknameCache.getCommonNicknameClusters(normalizedName);
1764        }
1765
1766        @Override
1767        protected void insertNameLookup(
1768                long rawContactId, long dataId, int lookupType, String string) {
1769            mNameLookupCandidates.add(string, lookupType);
1770            DatabaseUtils.appendEscapedSQLString(mSelection, string);
1771            mSelection.append(',');
1772        }
1773
1774        public boolean isEmpty() {
1775            return mNameLookupCandidates.isEmpty();
1776        }
1777
1778        public String getSelection() {
1779            mSelection.setLength(mSelection.length() - 1);      // Strip last comma
1780            mSelection.append(')');
1781            return mSelection.toString();
1782        }
1783
1784        public int getLookupType(String name) {
1785            for (int i = 0; i < mNameLookupCandidates.mCount; i++) {
1786                if (mNameLookupCandidates.mList.get(i).mName.equals(name)) {
1787                    return mNameLookupCandidates.mList.get(i).mLookupType;
1788                }
1789            }
1790            throw new IllegalStateException();
1791        }
1792    }
1793
1794    /**
1795     * Finds contacts with names matching the specified name.
1796     */
1797    private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query,
1798            MatchCandidateList candidates, ContactMatcher matcher) {
1799        candidates.clear();
1800        NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder(
1801                mNameSplitter, candidates);
1802        builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED);
1803        if (builder.isEmpty()) {
1804            return;
1805        }
1806
1807        Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE,
1808                NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null,
1809                null, PRIMARY_HIT_LIMIT_STRING);
1810        try {
1811            while (c.moveToNext()) {
1812                long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID);
1813                String name = c.getString(NameLookupMatchQueryWithParameter.NAME);
1814                int nameTypeA = builder.getLookupType(name);
1815                int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE);
1816                matcher.matchName(contactId, nameTypeA, name, nameTypeB, name,
1817                        ContactMatcher.MATCHING_ALGORITHM_EXACT);
1818                if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) {
1819                    matcher.updateScoreWithNicknameMatch(contactId);
1820                }
1821            }
1822        } finally {
1823            c.close();
1824        }
1825    }
1826
1827    private interface EmailLookupQuery {
1828        String TABLE = Tables.DATA + " dataA"
1829                + " JOIN " + Tables.DATA + " dataB" +
1830                " ON lower(" + "dataA." + Email.DATA + ")=lower(dataB." + Email.DATA + ")"
1831                + " JOIN " + Tables.RAW_CONTACTS +
1832                " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1833                        + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1834
1835        String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1"
1836                + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2"
1837                + " AND dataA." + Email.DATA + " NOT NULL"
1838                + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2"
1839                + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1840                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1841
1842        String[] COLUMNS = new String[] {
1843            RawContacts.CONTACT_ID
1844        };
1845
1846        int CONTACT_ID = 0;
1847    }
1848
1849    private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId,
1850            ContactMatcher matcher) {
1851        mSelectionArgs2[0] = String.valueOf(rawContactId);
1852        mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail);
1853        Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS,
1854                EmailLookupQuery.SELECTION,
1855                mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
1856        try {
1857            while (c.moveToNext()) {
1858                long contactId = c.getLong(EmailLookupQuery.CONTACT_ID);
1859                matcher.updateScoreWithEmailMatch(contactId);
1860            }
1861        } finally {
1862            c.close();
1863        }
1864    }
1865
1866    private interface PhoneLookupQuery {
1867        String TABLE = Tables.PHONE_LOOKUP + " phoneA"
1868                + " JOIN " + Tables.DATA + " dataA"
1869                + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")"
1870                + " JOIN " + Tables.PHONE_LOOKUP + " phoneB"
1871                + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "="
1872                        + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")"
1873                + " JOIN " + Tables.DATA + " dataB"
1874                + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")"
1875                + " JOIN " + Tables.RAW_CONTACTS
1876                + " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1877                        + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1878
1879        String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?"
1880                + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", "
1881                        + "dataB." + Phone.NUMBER + ",?)"
1882                + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"
1883                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1884
1885        String[] COLUMNS = new String[] {
1886            RawContacts.CONTACT_ID
1887        };
1888
1889        int CONTACT_ID = 0;
1890    }
1891
1892    private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId,
1893            ContactMatcher matcher) {
1894        mSelectionArgs2[0] = String.valueOf(rawContactId);
1895        mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter();
1896        Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS,
1897                PhoneLookupQuery.SELECTION,
1898                mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
1899        try {
1900            while (c.moveToNext()) {
1901                long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID);
1902                matcher.updateScoreWithPhoneNumberMatch(contactId);
1903            }
1904        } finally {
1905            c.close();
1906        }
1907    }
1908
1909    /**
1910     * Loads name lookup rows for approximate name matching and updates match scores based on that
1911     * data.
1912     */
1913    private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
1914            ContactMatcher matcher) {
1915        HashSet<String> firstLetters = new HashSet<String>();
1916        for (int i = 0; i < candidates.mCount; i++) {
1917            final NameMatchCandidate candidate = candidates.mList.get(i);
1918            if (candidate.mName.length() >= 2) {
1919                String firstLetter = candidate.mName.substring(0, 2);
1920                if (!firstLetters.contains(firstLetter)) {
1921                    firstLetters.add(firstLetter);
1922                    final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '"
1923                            + firstLetter + "*') AND "
1924                            + "(" + NameLookupColumns.NAME_TYPE + " IN("
1925                                    + NameLookupType.NAME_COLLATION_KEY + ","
1926                                    + NameLookupType.EMAIL_BASED_NICKNAME + ","
1927                                    + NameLookupType.NICKNAME + ")) AND "
1928                            + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1929                    matchAllCandidates(db, selection, candidates, matcher,
1930                            ContactMatcher.MATCHING_ALGORITHM_APPROXIMATE,
1931                            String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT));
1932                }
1933            }
1934        }
1935    }
1936
1937    private interface ContactNameLookupQuery {
1938        String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS;
1939
1940        String[] COLUMNS = new String[] {
1941                RawContacts.CONTACT_ID,
1942                NameLookupColumns.NORMALIZED_NAME,
1943                NameLookupColumns.NAME_TYPE
1944        };
1945
1946        int CONTACT_ID = 0;
1947        int NORMALIZED_NAME = 1;
1948        int NAME_TYPE = 2;
1949    }
1950
1951    /**
1952     * Loads all candidate rows from the name lookup table and updates match scores based
1953     * on that data.
1954     */
1955    private void matchAllCandidates(SQLiteDatabase db, String selection,
1956            MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit) {
1957        final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS,
1958                selection, null, null, null, null, limit);
1959
1960        try {
1961            while (c.moveToNext()) {
1962                Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID);
1963                String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME);
1964                int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE);
1965
1966                // Note the N^2 complexity of the following fragment. This is not a huge concern
1967                // since the number of candidates is very small and in general secondary hits
1968                // in the absence of primary hits are rare.
1969                for (int i = 0; i < candidates.mCount; i++) {
1970                    NameMatchCandidate candidate = candidates.mList.get(i);
1971                    matcher.matchName(contactId, candidate.mLookupType, candidate.mName,
1972                            nameType, name, algorithm);
1973                }
1974            }
1975        } finally {
1976            c.close();
1977        }
1978    }
1979
1980    private interface RawContactsQuery {
1981        String SQL_FORMAT =
1982                "SELECT "
1983                        + RawContactsColumns.CONCRETE_ID + ","
1984                        + RawContactsColumns.DISPLAY_NAME + ","
1985                        + RawContactsColumns.DISPLAY_NAME_SOURCE + ","
1986                        + AccountsColumns.CONCRETE_ACCOUNT_TYPE + ","
1987                        + AccountsColumns.CONCRETE_ACCOUNT_NAME + ","
1988                        + AccountsColumns.CONCRETE_DATA_SET + ","
1989                        + RawContacts.SOURCE_ID + ","
1990                        + RawContacts.CUSTOM_RINGTONE + ","
1991                        + RawContacts.SEND_TO_VOICEMAIL + ","
1992                        + RawContacts.LAST_TIME_CONTACTED + ","
1993                        + RawContacts.TIMES_CONTACTED + ","
1994                        + RawContacts.STARRED + ","
1995                        + RawContacts.PINNED + ","
1996                        + RawContacts.NAME_VERIFIED + ","
1997                        + DataColumns.CONCRETE_ID + ","
1998                        + DataColumns.CONCRETE_MIMETYPE_ID + ","
1999                        + Data.IS_SUPER_PRIMARY + ","
2000                        + Photo.PHOTO_FILE_ID +
2001                " FROM " + Tables.RAW_CONTACTS +
2002                " JOIN " + Tables.ACCOUNTS + " ON ("
2003                    + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
2004                    + ")" +
2005                " LEFT OUTER JOIN " + Tables.DATA +
2006                " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
2007                        + " AND ((" + DataColumns.MIMETYPE_ID + "=%d"
2008                                + " AND " + Photo.PHOTO + " NOT NULL)"
2009                        + " OR (" + DataColumns.MIMETYPE_ID + "=%d"
2010                                + " AND " + Phone.NUMBER + " NOT NULL)))";
2011
2012        String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT +
2013                " WHERE " + RawContactsColumns.CONCRETE_ID + "=?";
2014
2015        String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT +
2016                " WHERE " + RawContacts.CONTACT_ID + "=?"
2017                + " AND " + RawContacts.DELETED + "=0";
2018
2019        int RAW_CONTACT_ID = 0;
2020        int DISPLAY_NAME = 1;
2021        int DISPLAY_NAME_SOURCE = 2;
2022        int ACCOUNT_TYPE = 3;
2023        int ACCOUNT_NAME = 4;
2024        int DATA_SET = 5;
2025        int SOURCE_ID = 6;
2026        int CUSTOM_RINGTONE = 7;
2027        int SEND_TO_VOICEMAIL = 8;
2028        int LAST_TIME_CONTACTED = 9;
2029        int TIMES_CONTACTED = 10;
2030        int STARRED = 11;
2031        int PINNED = 12;
2032        int NAME_VERIFIED = 13;
2033        int DATA_ID = 14;
2034        int MIMETYPE_ID = 15;
2035        int IS_SUPER_PRIMARY = 16;
2036        int PHOTO_FILE_ID = 17;
2037    }
2038
2039    private interface ContactReplaceSqlStatement {
2040        String UPDATE_SQL =
2041                "UPDATE " + Tables.CONTACTS +
2042                " SET "
2043                        + Contacts.NAME_RAW_CONTACT_ID + "=?, "
2044                        + Contacts.PHOTO_ID + "=?, "
2045                        + Contacts.PHOTO_FILE_ID + "=?, "
2046                        + Contacts.SEND_TO_VOICEMAIL + "=?, "
2047                        + Contacts.CUSTOM_RINGTONE + "=?, "
2048                        + Contacts.LAST_TIME_CONTACTED + "=?, "
2049                        + Contacts.TIMES_CONTACTED + "=?, "
2050                        + Contacts.STARRED + "=?, "
2051                        + Contacts.PINNED + "=?, "
2052                        + Contacts.HAS_PHONE_NUMBER + "=?, "
2053                        + Contacts.LOOKUP_KEY + "=?, "
2054                        + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP + "=? " +
2055                " WHERE " + Contacts._ID + "=?";
2056
2057        String INSERT_SQL =
2058                "INSERT INTO " + Tables.CONTACTS + " ("
2059                        + Contacts.NAME_RAW_CONTACT_ID + ", "
2060                        + Contacts.PHOTO_ID + ", "
2061                        + Contacts.PHOTO_FILE_ID + ", "
2062                        + Contacts.SEND_TO_VOICEMAIL + ", "
2063                        + Contacts.CUSTOM_RINGTONE + ", "
2064                        + Contacts.LAST_TIME_CONTACTED + ", "
2065                        + Contacts.TIMES_CONTACTED + ", "
2066                        + Contacts.STARRED + ", "
2067                        + Contacts.PINNED + ", "
2068                        + Contacts.HAS_PHONE_NUMBER + ", "
2069                        + Contacts.LOOKUP_KEY + ", "
2070                        + Contacts.CONTACT_LAST_UPDATED_TIMESTAMP
2071                        + ") " +
2072                " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)";
2073
2074        int NAME_RAW_CONTACT_ID = 1;
2075        int PHOTO_ID = 2;
2076        int PHOTO_FILE_ID = 3;
2077        int SEND_TO_VOICEMAIL = 4;
2078        int CUSTOM_RINGTONE = 5;
2079        int LAST_TIME_CONTACTED = 6;
2080        int TIMES_CONTACTED = 7;
2081        int STARRED = 8;
2082        int PINNED = 9;
2083        int HAS_PHONE_NUMBER = 10;
2084        int LOOKUP_KEY = 11;
2085        int CONTACT_LAST_UPDATED_TIMESTAMP = 12;
2086        int CONTACT_ID = 13;
2087    }
2088
2089    /**
2090     * Computes aggregate-level data for the specified aggregate contact ID.
2091     */
2092    private void computeAggregateData(SQLiteDatabase db, long contactId,
2093            SQLiteStatement statement) {
2094        mSelectionArgs1[0] = String.valueOf(contactId);
2095        computeAggregateData(db, mRawContactsQueryByContactId, mSelectionArgs1, statement);
2096    }
2097
2098    /**
2099     * Indicates whether the given photo entry and priority gives this photo a higher overall
2100     * priority than the current best photo entry and priority.
2101     */
2102    private boolean hasHigherPhotoPriority(PhotoEntry photoEntry, int priority,
2103            PhotoEntry bestPhotoEntry, int bestPriority) {
2104        int photoComparison = photoEntry.compareTo(bestPhotoEntry);
2105        return photoComparison < 0 || photoComparison == 0 && priority > bestPriority;
2106    }
2107
2108    /**
2109     * Computes aggregate-level data from constituent raw contacts.
2110     */
2111    private void computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs,
2112            SQLiteStatement statement) {
2113        long currentRawContactId = -1;
2114        long bestPhotoId = -1;
2115        long bestPhotoFileId = 0;
2116        PhotoEntry bestPhotoEntry = null;
2117        boolean foundSuperPrimaryPhoto = false;
2118        int photoPriority = -1;
2119        int totalRowCount = 0;
2120        int contactSendToVoicemail = 0;
2121        String contactCustomRingtone = null;
2122        long contactLastTimeContacted = 0;
2123        int contactTimesContacted = 0;
2124        int contactStarred = 0;
2125        int contactPinned = Integer.MAX_VALUE;
2126        int hasPhoneNumber = 0;
2127        StringBuilder lookupKey = new StringBuilder();
2128
2129        mDisplayNameCandidate.clear();
2130
2131        Cursor c = db.rawQuery(sql, sqlArgs);
2132        try {
2133            while (c.moveToNext()) {
2134                long rawContactId = c.getLong(RawContactsQuery.RAW_CONTACT_ID);
2135                if (rawContactId != currentRawContactId) {
2136                    currentRawContactId = rawContactId;
2137                    totalRowCount++;
2138
2139                    // Assemble sub-account.
2140                    String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
2141                    String dataSet = c.getString(RawContactsQuery.DATA_SET);
2142                    String accountWithDataSet = (!TextUtils.isEmpty(dataSet))
2143                            ? accountType + "/" + dataSet
2144                            : accountType;
2145
2146                    // Display name
2147                    String displayName = c.getString(RawContactsQuery.DISPLAY_NAME);
2148                    int displayNameSource = c.getInt(RawContactsQuery.DISPLAY_NAME_SOURCE);
2149                    int nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED);
2150                    processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
2151                            mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet),
2152                            nameVerified != 0);
2153
2154                    // Contact options
2155                    if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) {
2156                        boolean sendToVoicemail =
2157                                (c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0);
2158                        if (sendToVoicemail) {
2159                            contactSendToVoicemail++;
2160                        }
2161                    }
2162
2163                    if (contactCustomRingtone == null
2164                            && !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) {
2165                        contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE);
2166                    }
2167
2168                    long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED);
2169                    if (lastTimeContacted > contactLastTimeContacted) {
2170                        contactLastTimeContacted = lastTimeContacted;
2171                    }
2172
2173                    int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED);
2174                    if (timesContacted > contactTimesContacted) {
2175                        contactTimesContacted = timesContacted;
2176                    }
2177
2178                    if (c.getInt(RawContactsQuery.STARRED) != 0) {
2179                        contactStarred = 1;
2180                    }
2181
2182                    // contactPinned should be the lowest value of its constituent raw contacts,
2183                    // excluding negative integers
2184                    final int rawContactPinned = c.getInt(RawContactsQuery.PINNED);
2185                    if (rawContactPinned > PinnedPositions.UNPINNED) {
2186                        contactPinned = Math.min(contactPinned, rawContactPinned);
2187                    }
2188
2189                    appendLookupKey(
2190                            lookupKey,
2191                            accountWithDataSet,
2192                            c.getString(RawContactsQuery.ACCOUNT_NAME),
2193                            rawContactId,
2194                            c.getString(RawContactsQuery.SOURCE_ID),
2195                            displayName);
2196                }
2197
2198                if (!c.isNull(RawContactsQuery.DATA_ID)) {
2199                    long dataId = c.getLong(RawContactsQuery.DATA_ID);
2200                    long photoFileId = c.getLong(RawContactsQuery.PHOTO_FILE_ID);
2201                    int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID);
2202                    boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0;
2203                    if (mimetypeId == mMimeTypeIdPhoto) {
2204                        if (!foundSuperPrimaryPhoto) {
2205                            // Lookup the metadata for the photo, if available.  Note that data set
2206                            // does not come into play here, since accounts are looked up in the
2207                            // account manager in the priority resolver.
2208                            PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
2209                            String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
2210                            int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
2211                            if (superPrimary || hasHigherPhotoPriority(
2212                                    photoEntry, priority, bestPhotoEntry, photoPriority)) {
2213                                bestPhotoEntry = photoEntry;
2214                                photoPriority = priority;
2215                                bestPhotoId = dataId;
2216                                bestPhotoFileId = photoFileId;
2217                                foundSuperPrimaryPhoto |= superPrimary;
2218                            }
2219                        }
2220                    } else if (mimetypeId == mMimeTypeIdPhone) {
2221                        hasPhoneNumber = 1;
2222                    }
2223                }
2224            }
2225        } finally {
2226            c.close();
2227        }
2228
2229        if (contactPinned == Integer.MAX_VALUE) {
2230            contactPinned = PinnedPositions.UNPINNED;
2231        }
2232
2233        statement.bindLong(ContactReplaceSqlStatement.NAME_RAW_CONTACT_ID,
2234                mDisplayNameCandidate.rawContactId);
2235
2236        if (bestPhotoId != -1) {
2237            statement.bindLong(ContactReplaceSqlStatement.PHOTO_ID, bestPhotoId);
2238        } else {
2239            statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID);
2240        }
2241
2242        if (bestPhotoFileId != 0) {
2243            statement.bindLong(ContactReplaceSqlStatement.PHOTO_FILE_ID, bestPhotoFileId);
2244        } else {
2245            statement.bindNull(ContactReplaceSqlStatement.PHOTO_FILE_ID);
2246        }
2247
2248        statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL,
2249                totalRowCount == contactSendToVoicemail ? 1 : 0);
2250        DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE,
2251                contactCustomRingtone);
2252        statement.bindLong(ContactReplaceSqlStatement.LAST_TIME_CONTACTED,
2253                contactLastTimeContacted);
2254        statement.bindLong(ContactReplaceSqlStatement.TIMES_CONTACTED,
2255                contactTimesContacted);
2256        statement.bindLong(ContactReplaceSqlStatement.STARRED,
2257                contactStarred);
2258        statement.bindLong(ContactReplaceSqlStatement.PINNED,
2259                contactPinned);
2260        statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER,
2261                hasPhoneNumber);
2262        statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY,
2263                Uri.encode(lookupKey.toString()));
2264        statement.bindLong(ContactReplaceSqlStatement.CONTACT_LAST_UPDATED_TIMESTAMP,
2265                Clock.getInstance().currentTimeMillis());
2266    }
2267
2268    /**
2269     * Builds a lookup key using the given data.
2270     */
2271    protected void appendLookupKey(StringBuilder sb, String accountTypeWithDataSet,
2272            String accountName, long rawContactId, String sourceId, String displayName) {
2273        ContactLookupKey.appendToLookupKey(sb, accountTypeWithDataSet, accountName, rawContactId,
2274                sourceId, displayName);
2275    }
2276
2277    /**
2278     * Uses the supplied values to determine if they represent a "better" display name
2279     * for the aggregate contact currently evaluated.  If so, it updates
2280     * {@link #mDisplayNameCandidate} with the new values.
2281     */
2282    private void processDisplayNameCandidate(long rawContactId, String displayName,
2283            int displayNameSource, boolean writableAccount, boolean verified) {
2284
2285        boolean replace = false;
2286        if (mDisplayNameCandidate.rawContactId == -1) {
2287            // No previous values available
2288            replace = true;
2289        } else if (!TextUtils.isEmpty(displayName)) {
2290            if (!mDisplayNameCandidate.verified && verified) {
2291                // A verified name is better than any other name
2292                replace = true;
2293            } else if (mDisplayNameCandidate.verified == verified) {
2294                if (mDisplayNameCandidate.displayNameSource < displayNameSource) {
2295                    // New values come from an superior source, e.g. structured name vs phone number
2296                    replace = true;
2297                } else if (mDisplayNameCandidate.displayNameSource == displayNameSource) {
2298                    if (!mDisplayNameCandidate.writableAccount && writableAccount) {
2299                        replace = true;
2300                    } else if (mDisplayNameCandidate.writableAccount == writableAccount) {
2301                        if (NameNormalizer.compareComplexity(displayName,
2302                                mDisplayNameCandidate.displayName) > 0) {
2303                            // New name is more complex than the previously found one
2304                            replace = true;
2305                        }
2306                    }
2307                }
2308            }
2309        }
2310
2311        if (replace) {
2312            mDisplayNameCandidate.rawContactId = rawContactId;
2313            mDisplayNameCandidate.displayName = displayName;
2314            mDisplayNameCandidate.displayNameSource = displayNameSource;
2315            mDisplayNameCandidate.verified = verified;
2316            mDisplayNameCandidate.writableAccount = writableAccount;
2317        }
2318    }
2319
2320    private interface PhotoIdQuery {
2321        final String[] COLUMNS = new String[] {
2322            AccountsColumns.CONCRETE_ACCOUNT_TYPE,
2323            DataColumns.CONCRETE_ID,
2324            Data.IS_SUPER_PRIMARY,
2325            Photo.PHOTO_FILE_ID,
2326        };
2327
2328        int ACCOUNT_TYPE = 0;
2329        int DATA_ID = 1;
2330        int IS_SUPER_PRIMARY = 2;
2331        int PHOTO_FILE_ID = 3;
2332    }
2333
2334    public void updatePhotoId(SQLiteDatabase db, long rawContactId) {
2335
2336        long contactId = mDbHelper.getContactId(rawContactId);
2337        if (contactId == 0) {
2338            return;
2339        }
2340
2341        long bestPhotoId = -1;
2342        long bestPhotoFileId = 0;
2343        int photoPriority = -1;
2344
2345        long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
2346
2347        String tables = Tables.RAW_CONTACTS
2348                + " JOIN " + Tables.ACCOUNTS + " ON ("
2349                    + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
2350                    + ")"
2351                + " JOIN " + Tables.DATA + " ON("
2352                + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
2353                + " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND "
2354                        + Photo.PHOTO + " NOT NULL))";
2355
2356        mSelectionArgs1[0] = String.valueOf(contactId);
2357        final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS,
2358                RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
2359        try {
2360            PhotoEntry bestPhotoEntry = null;
2361            while (c.moveToNext()) {
2362                long dataId = c.getLong(PhotoIdQuery.DATA_ID);
2363                long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID);
2364                boolean superPrimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0;
2365                PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
2366
2367                // Note that data set does not come into play here, since accounts are looked up in
2368                // the account manager in the priority resolver.
2369                String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE);
2370                int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
2371                if (superPrimary || hasHigherPhotoPriority(
2372                        photoEntry, priority, bestPhotoEntry, photoPriority)) {
2373                    bestPhotoEntry = photoEntry;
2374                    photoPriority = priority;
2375                    bestPhotoId = dataId;
2376                    bestPhotoFileId = photoFileId;
2377                    if (superPrimary) {
2378                        break;
2379                    }
2380                }
2381            }
2382        } finally {
2383            c.close();
2384        }
2385
2386        if (bestPhotoId == -1) {
2387            mPhotoIdUpdate.bindNull(1);
2388        } else {
2389            mPhotoIdUpdate.bindLong(1, bestPhotoId);
2390        }
2391
2392        if (bestPhotoFileId == 0) {
2393            mPhotoIdUpdate.bindNull(2);
2394        } else {
2395            mPhotoIdUpdate.bindLong(2, bestPhotoFileId);
2396        }
2397
2398        mPhotoIdUpdate.bindLong(3, contactId);
2399        mPhotoIdUpdate.execute();
2400    }
2401
2402    private interface PhotoFileQuery {
2403        final String[] COLUMNS = new String[] {
2404                PhotoFiles.HEIGHT,
2405                PhotoFiles.WIDTH,
2406                PhotoFiles.FILESIZE
2407        };
2408
2409        int HEIGHT = 0;
2410        int WIDTH = 1;
2411        int FILESIZE = 2;
2412    }
2413
2414    private class PhotoEntry implements Comparable<PhotoEntry> {
2415        // Pixel count (width * height) for the image.
2416        final int pixelCount;
2417
2418        // File size (in bytes) of the image.  Not populated if the image is a thumbnail.
2419        final int fileSize;
2420
2421        private PhotoEntry(int pixelCount, int fileSize) {
2422            this.pixelCount = pixelCount;
2423            this.fileSize = fileSize;
2424        }
2425
2426        @Override
2427        public int compareTo(PhotoEntry pe) {
2428            if (pe == null) {
2429                return -1;
2430            }
2431            if (pixelCount == pe.pixelCount) {
2432                return pe.fileSize - fileSize;
2433            } else {
2434                return pe.pixelCount - pixelCount;
2435            }
2436        }
2437    }
2438
2439    private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) {
2440        if (photoFileId == 0) {
2441            // Assume standard thumbnail size.  Don't bother getting a file size for priority;
2442            // we should fall back to photo priority resolver if all we have are thumbnails.
2443            int thumbDim = mContactsProvider.getMaxThumbnailDim();
2444            return new PhotoEntry(thumbDim * thumbDim, 0);
2445        } else {
2446            Cursor c = db.query(Tables.PHOTO_FILES, PhotoFileQuery.COLUMNS, PhotoFiles._ID + "=?",
2447                    new String[]{String.valueOf(photoFileId)}, null, null, null);
2448            try {
2449                if (c.getCount() == 1) {
2450                    c.moveToFirst();
2451                    int pixelCount =
2452                            c.getInt(PhotoFileQuery.HEIGHT) * c.getInt(PhotoFileQuery.WIDTH);
2453                    return new PhotoEntry(pixelCount, c.getInt(PhotoFileQuery.FILESIZE));
2454                }
2455            } finally {
2456                c.close();
2457            }
2458        }
2459        return new PhotoEntry(0, 0);
2460    }
2461
2462    private interface DisplayNameQuery {
2463        String[] COLUMNS = new String[] {
2464            RawContacts._ID,
2465            RawContactsColumns.DISPLAY_NAME,
2466            RawContactsColumns.DISPLAY_NAME_SOURCE,
2467            RawContacts.NAME_VERIFIED,
2468            RawContacts.SOURCE_ID,
2469            RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
2470        };
2471
2472        int _ID = 0;
2473        int DISPLAY_NAME = 1;
2474        int DISPLAY_NAME_SOURCE = 2;
2475        int NAME_VERIFIED = 3;
2476        int SOURCE_ID = 4;
2477        int ACCOUNT_TYPE_AND_DATA_SET = 5;
2478    }
2479
2480    public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) {
2481        long contactId = mDbHelper.getContactId(rawContactId);
2482        if (contactId == 0) {
2483            return;
2484        }
2485
2486        updateDisplayNameForContact(db, contactId);
2487    }
2488
2489    public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) {
2490        boolean lookupKeyUpdateNeeded = false;
2491
2492        mDisplayNameCandidate.clear();
2493
2494        mSelectionArgs1[0] = String.valueOf(contactId);
2495        final Cursor c = db.query(Views.RAW_CONTACTS, DisplayNameQuery.COLUMNS,
2496                RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
2497        try {
2498            while (c.moveToNext()) {
2499                long rawContactId = c.getLong(DisplayNameQuery._ID);
2500                String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME);
2501                int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE);
2502                int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED);
2503                String accountTypeAndDataSet = c.getString(
2504                        DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET);
2505                processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
2506                        mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet),
2507                        nameVerified != 0);
2508
2509                // If the raw contact has no source id, the lookup key is based on the display
2510                // name, so the lookup key needs to be updated.
2511                lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID);
2512            }
2513        } finally {
2514            c.close();
2515        }
2516
2517        if (mDisplayNameCandidate.rawContactId != -1) {
2518            mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId);
2519            mDisplayNameUpdate.bindLong(2, contactId);
2520            mDisplayNameUpdate.execute();
2521        }
2522
2523        if (lookupKeyUpdateNeeded) {
2524            updateLookupKeyForContact(db, contactId);
2525        }
2526    }
2527
2528
2529    /**
2530     * Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the
2531     * specified raw contact.
2532     */
2533    public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) {
2534
2535        long contactId = mDbHelper.getContactId(rawContactId);
2536        if (contactId == 0) {
2537            return;
2538        }
2539
2540        final SQLiteStatement hasPhoneNumberUpdate = db.compileStatement(
2541                "UPDATE " + Tables.CONTACTS +
2542                " SET " + Contacts.HAS_PHONE_NUMBER + "="
2543                        + "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)"
2544                        + " FROM " + Tables.DATA_JOIN_RAW_CONTACTS
2545                        + " WHERE " + DataColumns.MIMETYPE_ID + "=?"
2546                                + " AND " + Phone.NUMBER + " NOT NULL"
2547                                + " AND " + RawContacts.CONTACT_ID + "=?)" +
2548                " WHERE " + Contacts._ID + "=?");
2549        try {
2550            hasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE));
2551            hasPhoneNumberUpdate.bindLong(2, contactId);
2552            hasPhoneNumberUpdate.bindLong(3, contactId);
2553            hasPhoneNumberUpdate.execute();
2554        } finally {
2555            hasPhoneNumberUpdate.close();
2556        }
2557    }
2558
2559    private interface LookupKeyQuery {
2560        String TABLE = Views.RAW_CONTACTS;
2561        String[] COLUMNS = new String[] {
2562            RawContacts._ID,
2563            RawContactsColumns.DISPLAY_NAME,
2564            RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
2565            RawContacts.ACCOUNT_NAME,
2566            RawContacts.SOURCE_ID,
2567        };
2568
2569        int ID = 0;
2570        int DISPLAY_NAME = 1;
2571        int ACCOUNT_TYPE_AND_DATA_SET = 2;
2572        int ACCOUNT_NAME = 3;
2573        int SOURCE_ID = 4;
2574    }
2575
2576    public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) {
2577        long contactId = mDbHelper.getContactId(rawContactId);
2578        if (contactId == 0) {
2579            return;
2580        }
2581
2582        updateLookupKeyForContact(db, contactId);
2583    }
2584
2585    private void updateLookupKeyForContact(SQLiteDatabase db, long contactId) {
2586        String lookupKey = computeLookupKeyForContact(db, contactId);
2587
2588        if (lookupKey == null) {
2589            mLookupKeyUpdate.bindNull(1);
2590        } else {
2591            mLookupKeyUpdate.bindString(1, Uri.encode(lookupKey));
2592        }
2593        mLookupKeyUpdate.bindLong(2, contactId);
2594
2595        mLookupKeyUpdate.execute();
2596    }
2597
2598    protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) {
2599        StringBuilder sb = new StringBuilder();
2600        mSelectionArgs1[0] = String.valueOf(contactId);
2601        final Cursor c = db.query(LookupKeyQuery.TABLE, LookupKeyQuery.COLUMNS,
2602                RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID);
2603        try {
2604            while (c.moveToNext()) {
2605                ContactLookupKey.appendToLookupKey(sb,
2606                        c.getString(LookupKeyQuery.ACCOUNT_TYPE_AND_DATA_SET),
2607                        c.getString(LookupKeyQuery.ACCOUNT_NAME),
2608                        c.getLong(LookupKeyQuery.ID),
2609                        c.getString(LookupKeyQuery.SOURCE_ID),
2610                        c.getString(LookupKeyQuery.DISPLAY_NAME));
2611            }
2612        } finally {
2613            c.close();
2614        }
2615        return sb.length() == 0 ? null : sb.toString();
2616    }
2617
2618    /**
2619     * Execute {@link SQLiteStatement} that will update the
2620     * {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}.
2621     */
2622    public void updateStarred(long rawContactId) {
2623        long contactId = mDbHelper.getContactId(rawContactId);
2624        if (contactId == 0) {
2625            return;
2626        }
2627
2628        mStarredUpdate.bindLong(1, contactId);
2629        mStarredUpdate.execute();
2630    }
2631
2632    /**
2633     * Execute {@link SQLiteStatement} that will update the
2634     * {@link Contacts#PINNED} flag for the given {@link RawContacts#_ID}.
2635     */
2636    public void updatePinned(long rawContactId) {
2637        long contactId = mDbHelper.getContactId(rawContactId);
2638        if (contactId == 0) {
2639            return;
2640        }
2641        mPinnedUpdate.bindLong(1, contactId);
2642        mPinnedUpdate.execute();
2643    }
2644
2645    /**
2646     * Finds matching contacts and returns a cursor on those.
2647     */
2648    public Cursor queryAggregationSuggestions(SQLiteQueryBuilder qb,
2649            String[] projection, long contactId, int maxSuggestions, String filter,
2650            ArrayList<AggregationSuggestionParameter> parameters) {
2651        final SQLiteDatabase db = mDbHelper.getReadableDatabase();
2652        db.beginTransaction();
2653        try {
2654            List<MatchScore> bestMatches = findMatchingContacts(db, contactId, parameters);
2655            return queryMatchingContacts(qb, db, projection, bestMatches, maxSuggestions, filter);
2656        } finally {
2657            db.endTransaction();
2658        }
2659    }
2660
2661    private interface ContactIdQuery {
2662        String[] COLUMNS = new String[] {
2663            Contacts._ID
2664        };
2665
2666        int _ID = 0;
2667    }
2668
2669    /**
2670     * Loads contacts with specified IDs and returns them in the order of IDs in the
2671     * supplied list.
2672     */
2673    private Cursor queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db,
2674            String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter) {
2675        StringBuilder sb = new StringBuilder();
2676        sb.append(Contacts._ID);
2677        sb.append(" IN (");
2678        for (int i = 0; i < bestMatches.size(); i++) {
2679            MatchScore matchScore = bestMatches.get(i);
2680            if (i != 0) {
2681                sb.append(",");
2682            }
2683            sb.append(matchScore.getContactId());
2684        }
2685        sb.append(")");
2686
2687        if (!TextUtils.isEmpty(filter)) {
2688            sb.append(" AND " + Contacts._ID + " IN ");
2689            mContactsProvider.appendContactFilterAsNestedQuery(sb, filter);
2690        }
2691
2692        // Run a query and find ids of best matching contacts satisfying the filter (if any)
2693        HashSet<Long> foundIds = new HashSet<Long>();
2694        Cursor cursor = db.query(qb.getTables(), ContactIdQuery.COLUMNS, sb.toString(),
2695                null, null, null, null);
2696        try {
2697            while(cursor.moveToNext()) {
2698                foundIds.add(cursor.getLong(ContactIdQuery._ID));
2699            }
2700        } finally {
2701            cursor.close();
2702        }
2703
2704        // Exclude all contacts that did not match the filter
2705        Iterator<MatchScore> iter = bestMatches.iterator();
2706        while (iter.hasNext()) {
2707            long id = iter.next().getContactId();
2708            if (!foundIds.contains(id)) {
2709                iter.remove();
2710            }
2711        }
2712
2713        // Limit the number of returned suggestions
2714        final List<MatchScore> limitedMatches;
2715        if (bestMatches.size() > maxSuggestions) {
2716            limitedMatches = bestMatches.subList(0, maxSuggestions);
2717        } else {
2718            limitedMatches = bestMatches;
2719        }
2720
2721        // Build an in-clause with the remaining contact IDs
2722        sb.setLength(0);
2723        sb.append(Contacts._ID);
2724        sb.append(" IN (");
2725        for (int i = 0; i < limitedMatches.size(); i++) {
2726            MatchScore matchScore = limitedMatches.get(i);
2727            if (i != 0) {
2728                sb.append(",");
2729            }
2730            sb.append(matchScore.getContactId());
2731        }
2732        sb.append(")");
2733
2734        // Run the final query with the required projection and contact IDs found by the first query
2735        cursor = qb.query(db, projection, sb.toString(), null, null, null, Contacts._ID);
2736
2737        // Build a sorted list of discovered IDs
2738        ArrayList<Long> sortedContactIds = new ArrayList<Long>(limitedMatches.size());
2739        for (MatchScore matchScore : limitedMatches) {
2740            sortedContactIds.add(matchScore.getContactId());
2741        }
2742
2743        Collections.sort(sortedContactIds);
2744
2745        // Map cursor indexes according to the descending order of match scores
2746        int[] positionMap = new int[limitedMatches.size()];
2747        for (int i = 0; i < positionMap.length; i++) {
2748            long id = limitedMatches.get(i).getContactId();
2749            positionMap[i] = sortedContactIds.indexOf(id);
2750        }
2751
2752        return new ReorderingCursorWrapper(cursor, positionMap);
2753    }
2754
2755    /**
2756     * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
2757     * descending order of match score.
2758     * @param parameters
2759     */
2760    private List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId,
2761            ArrayList<AggregationSuggestionParameter> parameters) {
2762
2763        MatchCandidateList candidates = new MatchCandidateList();
2764        ContactMatcher matcher = new ContactMatcher();
2765
2766        // Don't aggregate a contact with itself
2767        matcher.keepOut(contactId);
2768
2769        if (parameters == null || parameters.size() == 0) {
2770            final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
2771                    RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
2772            try {
2773                while (c.moveToNext()) {
2774                    long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID);
2775                    updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates,
2776                            matcher);
2777                }
2778            } finally {
2779                c.close();
2780            }
2781        } else {
2782            updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates,
2783                    matcher, parameters);
2784        }
2785
2786        return matcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SUGGEST);
2787    }
2788
2789    /**
2790     * Computes scores for contacts that have matching data rows.
2791     */
2792    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
2793            long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
2794
2795        updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
2796        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
2797        updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
2798        updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
2799        loadNameMatchCandidates(db, rawContactId, candidates, false);
2800        lookupApproximateNameMatches(db, candidates, matcher);
2801    }
2802
2803    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
2804            MatchCandidateList candidates, ContactMatcher matcher,
2805            ArrayList<AggregationSuggestionParameter> parameters) {
2806        for (AggregationSuggestionParameter parameter : parameters) {
2807            if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) {
2808                updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher);
2809            }
2810
2811            // TODO: add support for other parameter kinds
2812        }
2813    }
2814}
2815