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