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