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