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