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