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