ContactAggregator.java revision 0992b9d4969ed0eee6e879db94292b635229e2b7
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 + ")";
1640                    matchAllCandidates(db, selection, candidates, matcher,
1641                            ContactMatcher.MATCHING_ALGORITHM_APPROXIMATE,
1642                            String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT));
1643                }
1644            }
1645        }
1646    }
1647
1648    private interface ContactNameLookupQuery {
1649        String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS;
1650
1651        String[] COLUMNS = new String[] {
1652                RawContacts.CONTACT_ID,
1653                NameLookupColumns.NORMALIZED_NAME,
1654                NameLookupColumns.NAME_TYPE
1655        };
1656
1657        int CONTACT_ID = 0;
1658        int NORMALIZED_NAME = 1;
1659        int NAME_TYPE = 2;
1660    }
1661
1662    /**
1663     * Loads all candidate rows from the name lookup table and updates match scores based
1664     * on that data.
1665     */
1666    private void matchAllCandidates(SQLiteDatabase db, String selection,
1667            MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit) {
1668        final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS,
1669                selection, null, null, null, null, limit);
1670
1671        try {
1672            while (c.moveToNext()) {
1673                Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID);
1674                String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME);
1675                int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE);
1676
1677                // Note the N^2 complexity of the following fragment. This is not a huge concern
1678                // since the number of candidates is very small and in general secondary hits
1679                // in the absence of primary hits are rare.
1680                for (int i = 0; i < candidates.mCount; i++) {
1681                    NameMatchCandidate candidate = candidates.mList.get(i);
1682                    matcher.matchName(contactId, candidate.mLookupType, candidate.mName,
1683                            nameType, name, algorithm);
1684                }
1685            }
1686        } finally {
1687            c.close();
1688        }
1689    }
1690
1691    private interface RawContactsQuery {
1692        String SQL_FORMAT =
1693                "SELECT "
1694                        + RawContactsColumns.CONCRETE_ID + ","
1695                        + RawContactsColumns.DISPLAY_NAME + ","
1696                        + RawContactsColumns.DISPLAY_NAME_SOURCE + ","
1697                        + AccountsColumns.CONCRETE_ACCOUNT_TYPE + ","
1698                        + AccountsColumns.CONCRETE_ACCOUNT_NAME + ","
1699                        + AccountsColumns.CONCRETE_DATA_SET + ","
1700                        + RawContacts.SOURCE_ID + ","
1701                        + RawContacts.CUSTOM_RINGTONE + ","
1702                        + RawContacts.SEND_TO_VOICEMAIL + ","
1703                        + RawContacts.LAST_TIME_CONTACTED + ","
1704                        + RawContacts.TIMES_CONTACTED + ","
1705                        + RawContacts.STARRED + ","
1706                        + RawContacts.NAME_VERIFIED + ","
1707                        + DataColumns.CONCRETE_ID + ","
1708                        + DataColumns.CONCRETE_MIMETYPE_ID + ","
1709                        + Data.IS_SUPER_PRIMARY + ","
1710                        + Photo.PHOTO_FILE_ID +
1711                " FROM " + Tables.RAW_CONTACTS +
1712                " JOIN " + Tables.ACCOUNTS + " ON ("
1713                    + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
1714                    + ")" +
1715                " LEFT OUTER JOIN " + Tables.DATA +
1716                " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
1717                        + " AND ((" + DataColumns.MIMETYPE_ID + "=%d"
1718                                + " AND " + Photo.PHOTO + " NOT NULL)"
1719                        + " OR (" + DataColumns.MIMETYPE_ID + "=%d"
1720                                + " AND " + Phone.NUMBER + " NOT NULL)))";
1721
1722        String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT +
1723                " WHERE " + RawContactsColumns.CONCRETE_ID + "=?";
1724
1725        String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT +
1726                " WHERE " + RawContacts.CONTACT_ID + "=?"
1727                + " AND " + RawContacts.DELETED + "=0";
1728
1729        int RAW_CONTACT_ID = 0;
1730        int DISPLAY_NAME = 1;
1731        int DISPLAY_NAME_SOURCE = 2;
1732        int ACCOUNT_TYPE = 3;
1733        int ACCOUNT_NAME = 4;
1734        int DATA_SET = 5;
1735        int SOURCE_ID = 6;
1736        int CUSTOM_RINGTONE = 7;
1737        int SEND_TO_VOICEMAIL = 8;
1738        int LAST_TIME_CONTACTED = 9;
1739        int TIMES_CONTACTED = 10;
1740        int STARRED = 11;
1741        int NAME_VERIFIED = 12;
1742        int DATA_ID = 13;
1743        int MIMETYPE_ID = 14;
1744        int IS_SUPER_PRIMARY = 15;
1745        int PHOTO_FILE_ID = 16;
1746    }
1747
1748    private interface ContactReplaceSqlStatement {
1749        String UPDATE_SQL =
1750                "UPDATE " + Tables.CONTACTS +
1751                " SET "
1752                        + Contacts.NAME_RAW_CONTACT_ID + "=?, "
1753                        + Contacts.PHOTO_ID + "=?, "
1754                        + Contacts.PHOTO_FILE_ID + "=?, "
1755                        + Contacts.SEND_TO_VOICEMAIL + "=?, "
1756                        + Contacts.CUSTOM_RINGTONE + "=?, "
1757                        + Contacts.LAST_TIME_CONTACTED + "=?, "
1758                        + Contacts.TIMES_CONTACTED + "=?, "
1759                        + Contacts.STARRED + "=?, "
1760                        + Contacts.HAS_PHONE_NUMBER + "=?, "
1761                        + Contacts.LOOKUP_KEY + "=? " +
1762                " WHERE " + Contacts._ID + "=?";
1763
1764        String INSERT_SQL =
1765                "INSERT INTO " + Tables.CONTACTS + " ("
1766                        + Contacts.NAME_RAW_CONTACT_ID + ", "
1767                        + Contacts.PHOTO_ID + ", "
1768                        + Contacts.PHOTO_FILE_ID + ", "
1769                        + Contacts.SEND_TO_VOICEMAIL + ", "
1770                        + Contacts.CUSTOM_RINGTONE + ", "
1771                        + Contacts.LAST_TIME_CONTACTED + ", "
1772                        + Contacts.TIMES_CONTACTED + ", "
1773                        + Contacts.STARRED + ", "
1774                        + Contacts.HAS_PHONE_NUMBER + ", "
1775                        + Contacts.LOOKUP_KEY + ") " +
1776                " VALUES (?,?,?,?,?,?,?,?,?,?)";
1777
1778        int NAME_RAW_CONTACT_ID = 1;
1779        int PHOTO_ID = 2;
1780        int PHOTO_FILE_ID = 3;
1781        int SEND_TO_VOICEMAIL = 4;
1782        int CUSTOM_RINGTONE = 5;
1783        int LAST_TIME_CONTACTED = 6;
1784        int TIMES_CONTACTED = 7;
1785        int STARRED = 8;
1786        int HAS_PHONE_NUMBER = 9;
1787        int LOOKUP_KEY = 10;
1788        int CONTACT_ID = 11;
1789    }
1790
1791    /**
1792     * Computes aggregate-level data for the specified aggregate contact ID.
1793     */
1794    private void computeAggregateData(SQLiteDatabase db, long contactId,
1795            SQLiteStatement statement) {
1796        mSelectionArgs1[0] = String.valueOf(contactId);
1797        computeAggregateData(db, mRawContactsQueryByContactId, mSelectionArgs1, statement);
1798    }
1799
1800    /**
1801     * Indicates whether the given photo entry and priority gives this photo a higher overall
1802     * priority than the current best photo entry and priority.
1803     */
1804    private boolean hasHigherPhotoPriority(PhotoEntry photoEntry, int priority,
1805            PhotoEntry bestPhotoEntry, int bestPriority) {
1806        int photoComparison = photoEntry.compareTo(bestPhotoEntry);
1807        return photoComparison < 0 || photoComparison == 0 && priority > bestPriority;
1808    }
1809
1810    /**
1811     * Computes aggregate-level data from constituent raw contacts.
1812     */
1813    private void computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs,
1814            SQLiteStatement statement) {
1815        long currentRawContactId = -1;
1816        long bestPhotoId = -1;
1817        long bestPhotoFileId = 0;
1818        PhotoEntry bestPhotoEntry = null;
1819        boolean foundSuperPrimaryPhoto = false;
1820        int photoPriority = -1;
1821        int totalRowCount = 0;
1822        int contactSendToVoicemail = 0;
1823        String contactCustomRingtone = null;
1824        long contactLastTimeContacted = 0;
1825        int contactTimesContacted = 0;
1826        int contactStarred = 0;
1827        int hasPhoneNumber = 0;
1828        StringBuilder lookupKey = new StringBuilder();
1829
1830        mDisplayNameCandidate.clear();
1831
1832        Cursor c = db.rawQuery(sql, sqlArgs);
1833        try {
1834            while (c.moveToNext()) {
1835                long rawContactId = c.getLong(RawContactsQuery.RAW_CONTACT_ID);
1836                if (rawContactId != currentRawContactId) {
1837                    currentRawContactId = rawContactId;
1838                    totalRowCount++;
1839
1840                    // Assemble sub-account.
1841                    String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
1842                    String dataSet = c.getString(RawContactsQuery.DATA_SET);
1843                    String accountWithDataSet = (!TextUtils.isEmpty(dataSet))
1844                            ? accountType + "/" + dataSet
1845                            : accountType;
1846
1847                    // Display name
1848                    String displayName = c.getString(RawContactsQuery.DISPLAY_NAME);
1849                    int displayNameSource = c.getInt(RawContactsQuery.DISPLAY_NAME_SOURCE);
1850                    int nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED);
1851                    processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
1852                            mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet),
1853                            nameVerified != 0);
1854
1855                    // Contact options
1856                    if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) {
1857                        boolean sendToVoicemail =
1858                                (c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0);
1859                        if (sendToVoicemail) {
1860                            contactSendToVoicemail++;
1861                        }
1862                    }
1863
1864                    if (contactCustomRingtone == null
1865                            && !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) {
1866                        contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE);
1867                    }
1868
1869                    long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED);
1870                    if (lastTimeContacted > contactLastTimeContacted) {
1871                        contactLastTimeContacted = lastTimeContacted;
1872                    }
1873
1874                    int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED);
1875                    if (timesContacted > contactTimesContacted) {
1876                        contactTimesContacted = timesContacted;
1877                    }
1878
1879                    if (c.getInt(RawContactsQuery.STARRED) != 0) {
1880                        contactStarred = 1;
1881                    }
1882
1883                    appendLookupKey(
1884                            lookupKey,
1885                            accountWithDataSet,
1886                            c.getString(RawContactsQuery.ACCOUNT_NAME),
1887                            rawContactId,
1888                            c.getString(RawContactsQuery.SOURCE_ID),
1889                            displayName);
1890                }
1891
1892                if (!c.isNull(RawContactsQuery.DATA_ID)) {
1893                    long dataId = c.getLong(RawContactsQuery.DATA_ID);
1894                    long photoFileId = c.getLong(RawContactsQuery.PHOTO_FILE_ID);
1895                    int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID);
1896                    boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0;
1897                    if (mimetypeId == mMimeTypeIdPhoto) {
1898                        if (!foundSuperPrimaryPhoto) {
1899                            // Lookup the metadata for the photo, if available.  Note that data set
1900                            // does not come into play here, since accounts are looked up in the
1901                            // account manager in the priority resolver.
1902                            PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
1903                            String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE);
1904                            int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
1905                            if (superPrimary || hasHigherPhotoPriority(
1906                                    photoEntry, priority, bestPhotoEntry, photoPriority)) {
1907                                bestPhotoEntry = photoEntry;
1908                                photoPriority = priority;
1909                                bestPhotoId = dataId;
1910                                bestPhotoFileId = photoFileId;
1911                                foundSuperPrimaryPhoto |= superPrimary;
1912                            }
1913                        }
1914                    } else if (mimetypeId == mMimeTypeIdPhone) {
1915                        hasPhoneNumber = 1;
1916                    }
1917                }
1918            }
1919        } finally {
1920            c.close();
1921        }
1922
1923        statement.bindLong(ContactReplaceSqlStatement.NAME_RAW_CONTACT_ID,
1924                mDisplayNameCandidate.rawContactId);
1925
1926        if (bestPhotoId != -1) {
1927            statement.bindLong(ContactReplaceSqlStatement.PHOTO_ID, bestPhotoId);
1928        } else {
1929            statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID);
1930        }
1931
1932        if (bestPhotoFileId != 0) {
1933            statement.bindLong(ContactReplaceSqlStatement.PHOTO_FILE_ID, bestPhotoFileId);
1934        } else {
1935            statement.bindNull(ContactReplaceSqlStatement.PHOTO_FILE_ID);
1936        }
1937
1938        statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL,
1939                totalRowCount == contactSendToVoicemail ? 1 : 0);
1940        DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE,
1941                contactCustomRingtone);
1942        statement.bindLong(ContactReplaceSqlStatement.LAST_TIME_CONTACTED,
1943                contactLastTimeContacted);
1944        statement.bindLong(ContactReplaceSqlStatement.TIMES_CONTACTED,
1945                contactTimesContacted);
1946        statement.bindLong(ContactReplaceSqlStatement.STARRED,
1947                contactStarred);
1948        statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER,
1949                hasPhoneNumber);
1950        statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY,
1951                Uri.encode(lookupKey.toString()));
1952    }
1953
1954    /**
1955     * Builds a lookup key using the given data.
1956     */
1957    protected void appendLookupKey(StringBuilder sb, String accountTypeWithDataSet,
1958            String accountName, long rawContactId, String sourceId, String displayName) {
1959        ContactLookupKey.appendToLookupKey(sb, accountTypeWithDataSet, accountName, rawContactId,
1960                sourceId, displayName);
1961    }
1962
1963    /**
1964     * Uses the supplied values to determine if they represent a "better" display name
1965     * for the aggregate contact currently evaluated.  If so, it updates
1966     * {@link #mDisplayNameCandidate} with the new values.
1967     */
1968    private void processDisplayNameCandidate(long rawContactId, String displayName,
1969            int displayNameSource, boolean writableAccount, boolean verified) {
1970
1971        boolean replace = false;
1972        if (mDisplayNameCandidate.rawContactId == -1) {
1973            // No previous values available
1974            replace = true;
1975        } else if (!TextUtils.isEmpty(displayName)) {
1976            if (!mDisplayNameCandidate.verified && verified) {
1977                // A verified name is better than any other name
1978                replace = true;
1979            } else if (mDisplayNameCandidate.verified == verified) {
1980                if (mDisplayNameCandidate.displayNameSource < displayNameSource) {
1981                    // New values come from an superior source, e.g. structured name vs phone number
1982                    replace = true;
1983                } else if (mDisplayNameCandidate.displayNameSource == displayNameSource) {
1984                    if (!mDisplayNameCandidate.writableAccount && writableAccount) {
1985                        replace = true;
1986                    } else if (mDisplayNameCandidate.writableAccount == writableAccount) {
1987                        if (NameNormalizer.compareComplexity(displayName,
1988                                mDisplayNameCandidate.displayName) > 0) {
1989                            // New name is more complex than the previously found one
1990                            replace = true;
1991                        }
1992                    }
1993                }
1994            }
1995        }
1996
1997        if (replace) {
1998            mDisplayNameCandidate.rawContactId = rawContactId;
1999            mDisplayNameCandidate.displayName = displayName;
2000            mDisplayNameCandidate.displayNameSource = displayNameSource;
2001            mDisplayNameCandidate.verified = verified;
2002            mDisplayNameCandidate.writableAccount = writableAccount;
2003        }
2004    }
2005
2006    private interface PhotoIdQuery {
2007        final String[] COLUMNS = new String[] {
2008            AccountsColumns.CONCRETE_ACCOUNT_TYPE,
2009            DataColumns.CONCRETE_ID,
2010            Data.IS_SUPER_PRIMARY,
2011            Photo.PHOTO_FILE_ID,
2012        };
2013
2014        int ACCOUNT_TYPE = 0;
2015        int DATA_ID = 1;
2016        int IS_SUPER_PRIMARY = 2;
2017        int PHOTO_FILE_ID = 3;
2018    }
2019
2020    public void updatePhotoId(SQLiteDatabase db, long rawContactId) {
2021
2022        long contactId = mDbHelper.getContactId(rawContactId);
2023        if (contactId == 0) {
2024            return;
2025        }
2026
2027        long bestPhotoId = -1;
2028        long bestPhotoFileId = 0;
2029        int photoPriority = -1;
2030
2031        long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE);
2032
2033        String tables = Tables.RAW_CONTACTS
2034                + " JOIN " + Tables.ACCOUNTS + " ON ("
2035                    + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID
2036                    + ")"
2037                + " JOIN " + Tables.DATA + " ON("
2038                + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID
2039                + " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND "
2040                        + Photo.PHOTO + " NOT NULL))";
2041
2042        mSelectionArgs1[0] = String.valueOf(contactId);
2043        final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS,
2044                RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
2045        try {
2046            PhotoEntry bestPhotoEntry = null;
2047            while (c.moveToNext()) {
2048                long dataId = c.getLong(PhotoIdQuery.DATA_ID);
2049                long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID);
2050                boolean superPrimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0;
2051                PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId);
2052
2053                // Note that data set does not come into play here, since accounts are looked up in
2054                // the account manager in the priority resolver.
2055                String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE);
2056                int priority = mPhotoPriorityResolver.getPhotoPriority(accountType);
2057                if (superPrimary || hasHigherPhotoPriority(
2058                        photoEntry, priority, bestPhotoEntry, photoPriority)) {
2059                    bestPhotoEntry = photoEntry;
2060                    photoPriority = priority;
2061                    bestPhotoId = dataId;
2062                    bestPhotoFileId = photoFileId;
2063                    if (superPrimary) {
2064                        break;
2065                    }
2066                }
2067            }
2068        } finally {
2069            c.close();
2070        }
2071
2072        if (bestPhotoId == -1) {
2073            mPhotoIdUpdate.bindNull(1);
2074        } else {
2075            mPhotoIdUpdate.bindLong(1, bestPhotoId);
2076        }
2077
2078        if (bestPhotoFileId == 0) {
2079            mPhotoIdUpdate.bindNull(2);
2080        } else {
2081            mPhotoIdUpdate.bindLong(2, bestPhotoFileId);
2082        }
2083
2084        mPhotoIdUpdate.bindLong(3, contactId);
2085        mPhotoIdUpdate.execute();
2086    }
2087
2088    private interface PhotoFileQuery {
2089        final String[] COLUMNS = new String[] {
2090                PhotoFiles.HEIGHT,
2091                PhotoFiles.WIDTH,
2092                PhotoFiles.FILESIZE
2093        };
2094
2095        int HEIGHT = 0;
2096        int WIDTH = 1;
2097        int FILESIZE = 2;
2098    }
2099
2100    private class PhotoEntry implements Comparable<PhotoEntry> {
2101        // Pixel count (width * height) for the image.
2102        final int pixelCount;
2103
2104        // File size (in bytes) of the image.  Not populated if the image is a thumbnail.
2105        final int fileSize;
2106
2107        private PhotoEntry(int pixelCount, int fileSize) {
2108            this.pixelCount = pixelCount;
2109            this.fileSize = fileSize;
2110        }
2111
2112        @Override
2113        public int compareTo(PhotoEntry pe) {
2114            if (pe == null) {
2115                return -1;
2116            }
2117            if (pixelCount == pe.pixelCount) {
2118                return pe.fileSize - fileSize;
2119            } else {
2120                return pe.pixelCount - pixelCount;
2121            }
2122        }
2123    }
2124
2125    private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) {
2126        if (photoFileId == 0) {
2127            // Assume standard thumbnail size.  Don't bother getting a file size for priority;
2128            // we should fall back to photo priority resolver if all we have are thumbnails.
2129            int thumbDim = mContactsProvider.getMaxThumbnailPhotoDim();
2130            return new PhotoEntry(thumbDim * thumbDim, 0);
2131        } else {
2132            Cursor c = db.query(Tables.PHOTO_FILES, PhotoFileQuery.COLUMNS, PhotoFiles._ID + "=?",
2133                    new String[]{String.valueOf(photoFileId)}, null, null, null);
2134            try {
2135                if (c.getCount() == 1) {
2136                    c.moveToFirst();
2137                    int pixelCount =
2138                            c.getInt(PhotoFileQuery.HEIGHT) * c.getInt(PhotoFileQuery.WIDTH);
2139                    return new PhotoEntry(pixelCount, c.getInt(PhotoFileQuery.FILESIZE));
2140                }
2141            } finally {
2142                c.close();
2143            }
2144        }
2145        return new PhotoEntry(0, 0);
2146    }
2147
2148    private interface DisplayNameQuery {
2149        String[] COLUMNS = new String[] {
2150            RawContacts._ID,
2151            RawContactsColumns.DISPLAY_NAME,
2152            RawContactsColumns.DISPLAY_NAME_SOURCE,
2153            RawContacts.NAME_VERIFIED,
2154            RawContacts.SOURCE_ID,
2155            RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
2156        };
2157
2158        int _ID = 0;
2159        int DISPLAY_NAME = 1;
2160        int DISPLAY_NAME_SOURCE = 2;
2161        int NAME_VERIFIED = 3;
2162        int SOURCE_ID = 4;
2163        int ACCOUNT_TYPE_AND_DATA_SET = 5;
2164    }
2165
2166    public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) {
2167        long contactId = mDbHelper.getContactId(rawContactId);
2168        if (contactId == 0) {
2169            return;
2170        }
2171
2172        updateDisplayNameForContact(db, contactId);
2173    }
2174
2175    public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) {
2176        boolean lookupKeyUpdateNeeded = false;
2177
2178        mDisplayNameCandidate.clear();
2179
2180        mSelectionArgs1[0] = String.valueOf(contactId);
2181        final Cursor c = db.query(Views.RAW_CONTACTS, DisplayNameQuery.COLUMNS,
2182                RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null);
2183        try {
2184            while (c.moveToNext()) {
2185                long rawContactId = c.getLong(DisplayNameQuery._ID);
2186                String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME);
2187                int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE);
2188                int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED);
2189                String accountTypeAndDataSet = c.getString(
2190                        DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET);
2191                processDisplayNameCandidate(rawContactId, displayName, displayNameSource,
2192                        mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet),
2193                        nameVerified != 0);
2194
2195                // If the raw contact has no source id, the lookup key is based on the display
2196                // name, so the lookup key needs to be updated.
2197                lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID);
2198            }
2199        } finally {
2200            c.close();
2201        }
2202
2203        if (mDisplayNameCandidate.rawContactId != -1) {
2204            mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId);
2205            mDisplayNameUpdate.bindLong(2, contactId);
2206            mDisplayNameUpdate.execute();
2207        }
2208
2209        if (lookupKeyUpdateNeeded) {
2210            updateLookupKeyForContact(db, contactId);
2211        }
2212    }
2213
2214
2215    /**
2216     * Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the
2217     * specified raw contact.
2218     */
2219    public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) {
2220
2221        long contactId = mDbHelper.getContactId(rawContactId);
2222        if (contactId == 0) {
2223            return;
2224        }
2225
2226        final SQLiteStatement hasPhoneNumberUpdate = db.compileStatement(
2227                "UPDATE " + Tables.CONTACTS +
2228                " SET " + Contacts.HAS_PHONE_NUMBER + "="
2229                        + "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)"
2230                        + " FROM " + Tables.DATA_JOIN_RAW_CONTACTS
2231                        + " WHERE " + DataColumns.MIMETYPE_ID + "=?"
2232                                + " AND " + Phone.NUMBER + " NOT NULL"
2233                                + " AND " + RawContacts.CONTACT_ID + "=?)" +
2234                " WHERE " + Contacts._ID + "=?");
2235        try {
2236            hasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE));
2237            hasPhoneNumberUpdate.bindLong(2, contactId);
2238            hasPhoneNumberUpdate.bindLong(3, contactId);
2239            hasPhoneNumberUpdate.execute();
2240        } finally {
2241            hasPhoneNumberUpdate.close();
2242        }
2243    }
2244
2245    private interface LookupKeyQuery {
2246        String TABLE = Views.RAW_CONTACTS;
2247        String[] COLUMNS = new String[] {
2248            RawContacts._ID,
2249            RawContactsColumns.DISPLAY_NAME,
2250            RawContacts.ACCOUNT_TYPE_AND_DATA_SET,
2251            RawContacts.ACCOUNT_NAME,
2252            RawContacts.SOURCE_ID,
2253        };
2254
2255        int ID = 0;
2256        int DISPLAY_NAME = 1;
2257        int ACCOUNT_TYPE_AND_DATA_SET = 2;
2258        int ACCOUNT_NAME = 3;
2259        int SOURCE_ID = 4;
2260    }
2261
2262    public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) {
2263        long contactId = mDbHelper.getContactId(rawContactId);
2264        if (contactId == 0) {
2265            return;
2266        }
2267
2268        updateLookupKeyForContact(db, contactId);
2269    }
2270
2271    private void updateLookupKeyForContact(SQLiteDatabase db, long contactId) {
2272        String lookupKey = computeLookupKeyForContact(db, contactId);
2273
2274        if (lookupKey == null) {
2275            mLookupKeyUpdate.bindNull(1);
2276        } else {
2277            mLookupKeyUpdate.bindString(1, Uri.encode(lookupKey));
2278        }
2279        mLookupKeyUpdate.bindLong(2, contactId);
2280
2281        mLookupKeyUpdate.execute();
2282    }
2283
2284    protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) {
2285        StringBuilder sb = new StringBuilder();
2286        mSelectionArgs1[0] = String.valueOf(contactId);
2287        final Cursor c = db.query(LookupKeyQuery.TABLE, LookupKeyQuery.COLUMNS,
2288                RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID);
2289        try {
2290            while (c.moveToNext()) {
2291                ContactLookupKey.appendToLookupKey(sb,
2292                        c.getString(LookupKeyQuery.ACCOUNT_TYPE_AND_DATA_SET),
2293                        c.getString(LookupKeyQuery.ACCOUNT_NAME),
2294                        c.getLong(LookupKeyQuery.ID),
2295                        c.getString(LookupKeyQuery.SOURCE_ID),
2296                        c.getString(LookupKeyQuery.DISPLAY_NAME));
2297            }
2298        } finally {
2299            c.close();
2300        }
2301        return sb.length() == 0 ? null : sb.toString();
2302    }
2303
2304    /**
2305     * Execute {@link SQLiteStatement} that will update the
2306     * {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}.
2307     */
2308    public void updateStarred(long rawContactId) {
2309        long contactId = mDbHelper.getContactId(rawContactId);
2310        if (contactId == 0) {
2311            return;
2312        }
2313
2314        mStarredUpdate.bindLong(1, contactId);
2315        mStarredUpdate.execute();
2316    }
2317
2318    /**
2319     * Finds matching contacts and returns a cursor on those.
2320     */
2321    public Cursor queryAggregationSuggestions(SQLiteQueryBuilder qb,
2322            String[] projection, long contactId, int maxSuggestions, String filter,
2323            ArrayList<AggregationSuggestionParameter> parameters) {
2324        final SQLiteDatabase db = mDbHelper.getReadableDatabase();
2325        db.beginTransaction();
2326        try {
2327            List<MatchScore> bestMatches = findMatchingContacts(db, contactId, parameters);
2328            return queryMatchingContacts(qb, db, projection, bestMatches, maxSuggestions, filter);
2329        } finally {
2330            db.endTransaction();
2331        }
2332    }
2333
2334    private interface ContactIdQuery {
2335        String[] COLUMNS = new String[] {
2336            Contacts._ID
2337        };
2338
2339        int _ID = 0;
2340    }
2341
2342    /**
2343     * Loads contacts with specified IDs and returns them in the order of IDs in the
2344     * supplied list.
2345     */
2346    private Cursor queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db,
2347            String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter) {
2348        StringBuilder sb = new StringBuilder();
2349        sb.append(Contacts._ID);
2350        sb.append(" IN (");
2351        for (int i = 0; i < bestMatches.size(); i++) {
2352            MatchScore matchScore = bestMatches.get(i);
2353            if (i != 0) {
2354                sb.append(",");
2355            }
2356            sb.append(matchScore.getContactId());
2357        }
2358        sb.append(")");
2359
2360        if (!TextUtils.isEmpty(filter)) {
2361            sb.append(" AND " + Contacts._ID + " IN ");
2362            mContactsProvider.appendContactFilterAsNestedQuery(sb, filter);
2363        }
2364
2365        // Run a query and find ids of best matching contacts satisfying the filter (if any)
2366        HashSet<Long> foundIds = new HashSet<Long>();
2367        Cursor cursor = db.query(qb.getTables(), ContactIdQuery.COLUMNS, sb.toString(),
2368                null, null, null, null);
2369        try {
2370            while(cursor.moveToNext()) {
2371                foundIds.add(cursor.getLong(ContactIdQuery._ID));
2372            }
2373        } finally {
2374            cursor.close();
2375        }
2376
2377        // Exclude all contacts that did not match the filter
2378        Iterator<MatchScore> iter = bestMatches.iterator();
2379        while (iter.hasNext()) {
2380            long id = iter.next().getContactId();
2381            if (!foundIds.contains(id)) {
2382                iter.remove();
2383            }
2384        }
2385
2386        // Limit the number of returned suggestions
2387        final List<MatchScore> limitedMatches;
2388        if (bestMatches.size() > maxSuggestions) {
2389            limitedMatches = bestMatches.subList(0, maxSuggestions);
2390        } else {
2391            limitedMatches = bestMatches;
2392        }
2393
2394        // Build an in-clause with the remaining contact IDs
2395        sb.setLength(0);
2396        sb.append(Contacts._ID);
2397        sb.append(" IN (");
2398        for (int i = 0; i < limitedMatches.size(); i++) {
2399            MatchScore matchScore = limitedMatches.get(i);
2400            if (i != 0) {
2401                sb.append(",");
2402            }
2403            sb.append(matchScore.getContactId());
2404        }
2405        sb.append(")");
2406
2407        // Run the final query with the required projection and contact IDs found by the first query
2408        cursor = qb.query(db, projection, sb.toString(), null, null, null, Contacts._ID);
2409
2410        // Build a sorted list of discovered IDs
2411        ArrayList<Long> sortedContactIds = new ArrayList<Long>(limitedMatches.size());
2412        for (MatchScore matchScore : limitedMatches) {
2413            sortedContactIds.add(matchScore.getContactId());
2414        }
2415
2416        Collections.sort(sortedContactIds);
2417
2418        // Map cursor indexes according to the descending order of match scores
2419        int[] positionMap = new int[limitedMatches.size()];
2420        for (int i = 0; i < positionMap.length; i++) {
2421            long id = limitedMatches.get(i).getContactId();
2422            positionMap[i] = sortedContactIds.indexOf(id);
2423        }
2424
2425        return new ReorderingCursorWrapper(cursor, positionMap);
2426    }
2427
2428    /**
2429     * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
2430     * descending order of match score.
2431     * @param parameters
2432     */
2433    private List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId,
2434            ArrayList<AggregationSuggestionParameter> parameters) {
2435
2436        MatchCandidateList candidates = new MatchCandidateList();
2437        ContactMatcher matcher = new ContactMatcher();
2438
2439        // Don't aggregate a contact with itself
2440        matcher.keepOut(contactId);
2441
2442        if (parameters == null || parameters.size() == 0) {
2443            final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
2444                    RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
2445            try {
2446                while (c.moveToNext()) {
2447                    long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID);
2448                    updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates,
2449                            matcher);
2450                }
2451            } finally {
2452                c.close();
2453            }
2454        } else {
2455            updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates,
2456                    matcher, parameters);
2457        }
2458
2459        return matcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SUGGEST);
2460    }
2461
2462    /**
2463     * Computes scores for contacts that have matching data rows.
2464     */
2465    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
2466            long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) {
2467
2468        updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
2469        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
2470        updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
2471        updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
2472        loadNameMatchCandidates(db, rawContactId, candidates, false);
2473        lookupApproximateNameMatches(db, candidates, matcher);
2474    }
2475
2476    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
2477            MatchCandidateList candidates, ContactMatcher matcher,
2478            ArrayList<AggregationSuggestionParameter> parameters) {
2479        for (AggregationSuggestionParameter parameter : parameters) {
2480            if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) {
2481                updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher);
2482            }
2483
2484            // TODO: add support for other parameter kinds
2485        }
2486    }
2487}
2488