ContactAggregator2.java revision 3a83f4c60fbe7eb2ee31186d0675dcfbac3ee6b5
1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License
15 */
16
17package com.android.providers.contacts.aggregation;
18
19import android.database.Cursor;
20import android.database.sqlite.SQLiteDatabase;
21import android.provider.ContactsContract.AggregationExceptions;
22import android.provider.ContactsContract.Contacts.AggregationSuggestions;
23import android.provider.ContactsContract.Data;
24import android.provider.ContactsContract.FullNameStyle;
25import android.provider.ContactsContract.PhotoFiles;
26import android.provider.ContactsContract.RawContacts;
27import android.text.TextUtils;
28import android.util.Log;
29import com.android.providers.contacts.ContactsDatabaseHelper;
30import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns;
31import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns;
32import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType;
33import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns;
34import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
35import com.android.providers.contacts.ContactsProvider2;
36import com.android.providers.contacts.NameSplitter;
37import com.android.providers.contacts.PhotoPriorityResolver;
38import com.android.providers.contacts.TransactionContext;
39import com.android.providers.contacts.aggregation.util.CommonNicknameCache;
40import com.android.providers.contacts.aggregation.util.ContactAggregatorHelper;
41import com.android.providers.contacts.aggregation.util.MatchScore;
42import com.android.providers.contacts.aggregation.util.RawContactMatcher;
43import com.android.providers.contacts.aggregation.util.RawContactMatchingCandidates;
44import com.android.providers.contacts.database.ContactsTableUtil;
45import com.google.android.collect.Sets;
46
47import java.util.ArrayList;
48import java.util.HashSet;
49import java.util.List;
50import java.util.Map;
51import java.util.Set;
52
53import static com.android.providers.contacts.aggregation.util.RawContactMatcher
54        .SCORE_THRESHOLD_PRIMARY;
55import static com.android.providers.contacts.aggregation.util.RawContactMatcher
56        .SCORE_THRESHOLD_SECONDARY;
57import static com.android.providers.contacts.aggregation.util.RawContactMatcher
58        .SCORE_THRESHOLD_SUGGEST;
59
60/**
61 * ContactAggregator2 deals with aggregating contact information with sufficient matching data
62 * points. E.g., two John Doe contacts with same phone numbers are presumed to be the same
63 * person unless the user declares otherwise.
64 */
65public class ContactAggregator2 extends AbstractContactAggregator {
66
67    // Possible operation types for contacts aggregation.
68    private static final int CREATE_NEW_CONTACT = 1;
69    private static final int KEEP_INTACT = 0;
70    private static final int RE_AGGREGATE = -1;
71
72    private final RawContactMatcher mMatcher = new RawContactMatcher();
73
74    /**
75     * Constructor.
76     */
77    public ContactAggregator2(ContactsProvider2 contactsProvider,
78            ContactsDatabaseHelper contactsDatabaseHelper,
79            PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter,
80            CommonNicknameCache commonNicknameCache) {
81        super(contactsProvider, contactsDatabaseHelper, photoPriorityResolver, nameSplitter,
82                commonNicknameCache);
83    }
84
85    private static class RawContactIdAndAggregationModeQuery {
86        public static final String TABLE = Tables.RAW_CONTACTS;
87
88        public static final String[] COLUMNS = { RawContacts._ID, RawContacts.AGGREGATION_MODE };
89
90        public static final String SELECTION = RawContacts.CONTACT_ID + "=?";
91
92        public static final int _ID = 0;
93        public static final int AGGREGATION_MODE = 1;
94    }
95
96    /**
97     * Given a specific raw contact, finds all matching raw contacts and re-aggregate them
98     * based on the matching connectivity.
99     */
100     synchronized void aggregateContact(TransactionContext txContext, SQLiteDatabase db,
101             long rawContactId, long accountId, long currentContactId,
102             MatchCandidateList candidates) {
103
104         if (VERBOSE_LOGGING) {
105            Log.v(TAG, "aggregateContact: rid=" + rawContactId + " cid=" + currentContactId);
106        }
107
108        int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT;
109
110        Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId);
111        if (aggModeObject != null) {
112            aggregationMode = aggModeObject;
113        }
114
115        RawContactMatcher matcher = new RawContactMatcher();
116        RawContactMatchingCandidates matchingCandidates = new RawContactMatchingCandidates();
117        if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
118            // If this is a newly inserted contact or a visible contact, look for
119            // data matches.
120            if (currentContactId == 0
121                    || mDbHelper.isContactInDefaultDirectory(db, currentContactId)) {
122                // Find the set of matching candidates
123                matchingCandidates = findRawContactMatchingCandidates(db, rawContactId, candidates,
124                        matcher);
125            }
126        } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) {
127            return;
128        }
129
130        // # of raw_contacts in the [currentContactId] contact excluding the [rawContactId]
131        // raw_contact.
132        long currentContactContentsCount = 0;
133
134        if (currentContactId != 0) {
135            mRawContactCountQuery.bindLong(1, currentContactId);
136            mRawContactCountQuery.bindLong(2, rawContactId);
137            currentContactContentsCount = mRawContactCountQuery.simpleQueryForLong();
138        }
139
140        // Set aggregation operation, i.e., re-aggregate, keep intact, or create new contact based
141        // on the number of matching candidates and the number of raw_contacts in the
142        // [currentContactId] excluding the [rawContactId].
143        final int operation;
144        final int candidatesCount = matchingCandidates.getCount();
145        if (candidatesCount >= AGGREGATION_CONTACT_SIZE_LIMIT) {
146            operation = KEEP_INTACT;
147            if (VERBOSE_LOGGING) {
148                Log.v(TAG, "Too many matching raw contacts (" + candidatesCount
149                        + ") are found, so skip aggregation");
150            }
151        } else if (candidatesCount > 0) {
152            operation = RE_AGGREGATE;
153        } else {
154            // When there is no matching raw contact found, if there are no other raw contacts in
155            // the current aggregate, we might as well reuse it. Also, if the aggregation mode is
156            // SUSPENDED, we must reuse the same aggregate.
157            if (currentContactId != 0
158                    && (currentContactContentsCount == 0
159                    || aggregationMode == RawContacts.AGGREGATION_MODE_SUSPENDED)) {
160                operation = KEEP_INTACT;
161            } else {
162                operation = CREATE_NEW_CONTACT;
163            }
164        }
165
166        if (operation == KEEP_INTACT) {
167            // Aggregation unchanged
168            if (VERBOSE_LOGGING) {
169                Log.v(TAG, "Aggregation unchanged");
170            }
171            markAggregated(rawContactId);
172        } else if (operation == CREATE_NEW_CONTACT) {
173            // create new contact for [rawContactId]
174            if (VERBOSE_LOGGING) {
175                Log.v(TAG, "create new contact for rid=" + rawContactId);
176            }
177            createContactForRawContacts(db, txContext, Sets.newHashSet(rawContactId), null);
178            if (currentContactContentsCount > 0) {
179                updateAggregateData(txContext, currentContactId);
180            }
181        } else {
182            // re-aggregate
183            if (VERBOSE_LOGGING) {
184                Log.v(TAG, "Re-aggregating rids=" + rawContactId + ","
185                        + TextUtils.join(",", matchingCandidates.getRawContactIdSet()));
186            }
187            reAggregateRawContacts(txContext, db, currentContactId, rawContactId, accountId,
188                    currentContactContentsCount, matchingCandidates);
189        }
190    }
191
192    /**
193     * Find the set of matching raw contacts for given rawContactId. Add all the raw contact
194     * candidates with matching scores > threshold to RawContactMatchingCandidates. Keep doing
195     * this for every raw contact in RawContactMatchingCandidates until is it not changing.
196     */
197    private RawContactMatchingCandidates findRawContactMatchingCandidates(SQLiteDatabase db, long
198            rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) {
199        updateMatchScores(db, rawContactId, candidates,
200                matcher);
201        final RawContactMatchingCandidates matchingCandidates = new RawContactMatchingCandidates(
202                matcher.pickBestMatches());
203        Set<Long> newIds = new HashSet<>();
204        newIds.addAll(matchingCandidates.getRawContactIdSet());
205        // Keep doing the following until no new raw contact candidate is found.
206        // TODO: may need to cache the matching score to improve performance.
207        while (!newIds.isEmpty()) {
208            final Set<Long> tmpIdSet = new HashSet<>();
209            for (long rId : newIds) {
210                final RawContactMatcher rMatcher = new RawContactMatcher();
211                updateMatchScores(db, rId, new MatchCandidateList(),
212                        rMatcher);
213                List<MatchScore> newMatches = rMatcher.pickBestMatches();
214                for (MatchScore newMatch : newMatches) {
215                    final long newRawContactId = newMatch.getRawContactId();
216                    if (!matchingCandidates.getRawContactIdSet().contains(newRawContactId)) {
217                        tmpIdSet.add(newRawContactId);
218                        matchingCandidates.add(newMatch);
219                    }
220                }
221            }
222            newIds.clear();
223            newIds.addAll(tmpIdSet);
224        }
225        return matchingCandidates;
226    }
227
228    /**
229     * Find out which mime-types are shared by more than one contacts for {@code rawContactIds}.
230     * Clear the is_super_primary settings for these mime-types.
231     * {@code rawContactIds} should be a comma separated ID list.
232     */
233     private void clearSuperPrimarySetting(SQLiteDatabase db, String rawContactIds) {
234        final String sql =
235                "SELECT " + DataColumns.MIMETYPE_ID + ", count(1) c  FROM " +
236                        Tables.DATA +" WHERE " + Data.IS_SUPER_PRIMARY + " = 1 AND " +
237                        Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ") group by " +
238                        DataColumns.MIMETYPE_ID + " HAVING c > 1";
239
240        // Find out which mime-types exist with is_super_primary=true on more then one contacts.
241        int index = 0;
242        final StringBuilder mimeTypeCondition = new StringBuilder();
243        mimeTypeCondition.append(" AND " + DataColumns.MIMETYPE_ID + " IN (");
244
245        final Cursor c = db.rawQuery(sql, null);
246        try {
247            c.moveToPosition(-1);
248            while (c.moveToNext()) {
249                if (index > 0) {
250                    mimeTypeCondition.append(',');
251                }
252                mimeTypeCondition.append(c.getLong((0)));
253                index++;
254            }
255        } finally {
256            c.close();
257        }
258
259        if (index == 0) {
260            return;
261        }
262
263        // Clear is_super_primary setting for all the mime-types with is_super_primary=true
264        // in both raw contact of rawContactId and raw contacts of contactId
265        String superPrimaryUpdateSql = "UPDATE " + Tables.DATA +
266                " SET " + Data.IS_SUPER_PRIMARY + "=0" +
267                " WHERE " +  Data.RAW_CONTACT_ID +
268                " IN (" + rawContactIds + ")";
269
270        mimeTypeCondition.append(')');
271        superPrimaryUpdateSql += mimeTypeCondition.toString();
272        db.execSQL(superPrimaryUpdateSql);
273    }
274
275    private String buildExceptionMatchingSql(String rawContactIdSet1, String rawContactIdSet2,
276            int aggregationType, boolean countOnly) {
277        final String idPairSelection =  "SELECT " + AggregationExceptions.RAW_CONTACT_ID1 + ", " +
278                AggregationExceptions.RAW_CONTACT_ID2;
279        final String sql =
280                " FROM " + Tables.AGGREGATION_EXCEPTIONS +
281                " WHERE " + AggregationExceptions.RAW_CONTACT_ID1 + " IN (" +
282                        rawContactIdSet1 + ")" +
283                " AND " + AggregationExceptions.RAW_CONTACT_ID2 + " IN (" + rawContactIdSet2 + ")" +
284                " AND " + AggregationExceptions.TYPE + "=" + aggregationType;
285        return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql :
286                idPairSelection + sql;
287    }
288
289    /**
290     * Re-aggregate rawContact of {@code rawContactId} and all the raw contacts of
291     * {@code matchingCandidates} into connected components. This only happens when a given
292     * raw contacts cannot be joined with its best matching contacts directly.
293     *
294     *  Two raw contacts are considered connected if they share at least one email address, phone
295     *  number or identity. Create new contact for each connected component except the very first
296     *  one that doesn't contain rawContactId of {@code rawContactId}.
297     */
298    private void reAggregateRawContacts(TransactionContext txContext, SQLiteDatabase db,
299            long currentCidForRawContact, long rawContactId, long accountId,
300            long currentContactContentsCount, RawContactMatchingCandidates matchingCandidates) {
301        // Find the connected component based on the aggregation exceptions or
302        // identity/email/phone matching for all the raw contacts of [contactId] and the give
303        // raw contact.
304        final Set<Long> allIds = new HashSet<>();
305        allIds.add(rawContactId);
306        allIds.addAll(matchingCandidates.getRawContactIdSet());
307        final Set<Set<Long>> connectedRawContactSets = findConnectedRawContacts(db, allIds);
308
309        final Map<Long, Long> rawContactsToAccounts = matchingCandidates.getRawContactToAccount();
310        rawContactsToAccounts.put(rawContactId, accountId);
311        ContactAggregatorHelper.mergeComponentsWithDisjointAccounts(connectedRawContactSets,
312                rawContactsToAccounts);
313        breakComponentsByExceptions(db, connectedRawContactSets);
314
315        // Create new contact for each connected component. Use the first reusable contactId if
316        // possible. If no reusable contactId found, create new contact for the connected component.
317        // Update aggregate data for all the contactIds touched by this connected component,
318        for (Set<Long> connectedRawContactIds : connectedRawContactSets) {
319            Long contactId = null;
320            Set<Long> cidsNeedToBeUpdated = new HashSet<>();
321            if (connectedRawContactIds.contains(rawContactId)) {
322                // If there is no other raw contacts aggregated with the given raw contact currently
323                // or all the raw contacts in [currentCidForRawContact] are still in the same
324                // connected component, we might as well reuse it.
325                if (currentCidForRawContact != 0 &&
326                        (currentContactContentsCount == 0) ||
327                        canBeReused(db, currentCidForRawContact, connectedRawContactIds)) {
328                    contactId = currentCidForRawContact;
329                    for (Long connectedRawContactId : connectedRawContactIds) {
330                        Long cid = matchingCandidates.getContactId(connectedRawContactId);
331                        if (cid != null && cid != contactId) {
332                            cidsNeedToBeUpdated.add(cid);
333                        }
334                    }
335                } else if (currentCidForRawContact != 0){
336                    cidsNeedToBeUpdated.add(currentCidForRawContact);
337                }
338            } else {
339                boolean foundContactId = false;
340                for (Long connectedRawContactId : connectedRawContactIds) {
341                    Long currentContactId = matchingCandidates.getContactId(connectedRawContactId);
342                    if (!foundContactId && currentContactId != null &&
343                            canBeReused(db, currentContactId, connectedRawContactIds)) {
344                        contactId = currentContactId;
345                        foundContactId = true;
346                    } else {
347                        cidsNeedToBeUpdated.add(currentContactId);
348                    }
349                }
350            }
351            clearSuperPrimarySetting(db, TextUtils.join(",", connectedRawContactIds));
352            createContactForRawContacts(db, txContext, connectedRawContactIds, contactId);
353
354            for (Long cid : cidsNeedToBeUpdated) {
355                long currentRcCount = 0;
356                if (cid != 0) {
357                    mRawContactCountQuery.bindLong(1, cid);
358                    mRawContactCountQuery.bindLong(2, 0);
359                    currentRcCount = mRawContactCountQuery.simpleQueryForLong();
360                }
361
362                if (currentRcCount == 0) {
363                    // Delete a contact if it doesn't contain anything
364                    ContactsTableUtil.deleteContact(db, cid);
365                    mAggregatedPresenceDelete.bindLong(1, cid);
366                    mAggregatedPresenceDelete.execute();
367                } else {
368                    updateAggregateData(txContext, cid);
369                }
370            }
371        }
372    }
373
374    /**
375     * Check if contactId can be reused as the contact Id for new aggregation of all the
376     * connectedRawContactIds. If connectedRawContactIds set contains all the raw contacts
377     * currently aggregated under contactId, return true; Otherwise, return false.
378     */
379    private boolean canBeReused(SQLiteDatabase db, Long contactId,
380            Set<Long> connectedRawContactIds) {
381        final String sql = "SELECT " + RawContactsColumns.CONCRETE_ID + " FROM " +
382                Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "=? AND " +
383                RawContacts.DELETED + "=0";
384        mSelectionArgs1[0] = String.valueOf(contactId);
385        final Cursor cursor = db.rawQuery(sql, mSelectionArgs1);
386        try {
387            cursor.moveToPosition(-1);
388            while (cursor.moveToNext()) {
389                if (!connectedRawContactIds.contains(cursor.getLong(0))) {
390                    return false;
391                }
392            }
393        } finally {
394            cursor.close();
395        }
396        return true;
397    }
398
399    /**
400     * Separate all the raw_contacts which has "SEPARATE" aggregation exception to another
401     * raw_contacts in the same component.
402     */
403    private void breakComponentsByExceptions(SQLiteDatabase db,
404            Set<Set<Long>> connectedRawContacts) {
405        final Set<Set<Long>> tmpSets = new HashSet<>(connectedRawContacts);
406        for (Set<Long> component : tmpSets) {
407            final String rawContacts = TextUtils.join(",", component);
408            // If "SEPARATE" exception is found inside an connected component [component],
409            // remove the [component] from [connectedRawContacts], and create a new connected
410            // component for each raw contact of [component] and add to [connectedRawContacts].
411            if (isFirstColumnGreaterThanZero(db, buildExceptionMatchingSql(rawContacts, rawContacts,
412                    AggregationExceptions.TYPE_KEEP_SEPARATE, /* countOnly =*/true))) {
413                connectedRawContacts.remove(component);
414                for (Long rId : component) {
415                    final Set<Long> s= new HashSet<>();
416                    s.add(rId);
417                    connectedRawContacts.add(s);
418                }
419            }
420        }
421    }
422
423    /**
424     * Ensures that automatic aggregation rules are followed after a contact
425     * becomes visible or invisible. Specifically, consider this case: there are
426     * three contacts named Foo. Two of them come from account A1 and one comes
427     * from account A2. The aggregation rules say that in this case none of the
428     * three Foo's should be aggregated: two of them are in the same account, so
429     * they don't get aggregated; the third has two affinities, so it does not
430     * join either of them.
431     * <p>
432     * Consider what happens if one of the "Foo"s from account A1 becomes
433     * invisible. Nothing stands in the way of aggregating the other two
434     * anymore, so they should get joined.
435     * <p>
436     * What if the invisible "Foo" becomes visible after that? We should split the
437     * aggregate between the other two.
438     */
439    public void updateAggregationAfterVisibilityChange(long contactId) {
440        SQLiteDatabase db = mDbHelper.getWritableDatabase();
441        boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId);
442        if (visible) {
443            markContactForAggregation(db, contactId);
444        } else {
445            // Find all contacts that _could be_ aggregated with this one and
446            // rerun aggregation for all of them
447            mSelectionArgs1[0] = String.valueOf(contactId);
448            Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
449                    RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null);
450            try {
451                while (cursor.moveToNext()) {
452                    long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID);
453                    mMatcher.clear();
454
455                    updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher);
456                    updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher);
457                    List<MatchScore> bestMatches =
458                            mMatcher.pickBestMatches(SCORE_THRESHOLD_PRIMARY);
459                    for (MatchScore matchScore : bestMatches) {
460                        markContactForAggregation(db, matchScore.getContactId());
461                    }
462
463                    mMatcher.clear();
464                    updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher);
465                    updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher);
466                    bestMatches =
467                            mMatcher.pickBestMatches(SCORE_THRESHOLD_SECONDARY);
468                    for (MatchScore matchScore : bestMatches) {
469                        markContactForAggregation(db, matchScore.getContactId());
470                    }
471                }
472            } finally {
473                cursor.close();
474            }
475        }
476    }
477
478    /**
479     * Computes match scores based on exceptions entered by the user: always match and never match.
480     */
481    private void updateMatchScoresBasedOnExceptions(SQLiteDatabase db, long rawContactId,
482            RawContactMatcher matcher) {
483        if (!mAggregationExceptionIdsValid) {
484            prefetchAggregationExceptionIds(db);
485        }
486
487        // If there are no aggregation exceptions involving this raw contact, there is no need to
488        // run a query and we can just return -1, which stands for "nothing found"
489        if (!mAggregationExceptionIds.contains(rawContactId)) {
490            return;
491        }
492
493        final Cursor c = db.query(AggregateExceptionQuery.TABLE,
494                AggregateExceptionQuery.COLUMNS,
495                AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId
496                        + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId,
497                null, null, null, null);
498
499        try {
500            while (c.moveToNext()) {
501                int type = c.getInt(AggregateExceptionQuery.TYPE);
502                long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
503                long contactId = -1;
504                long rId = -1;
505                long accountId = -1;
506                if (rawContactId == rawContactId1) {
507                    if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_2) == 0
508                            && !c.isNull(AggregateExceptionQuery.RAW_CONTACT_ID2)) {
509                        rId = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID2);
510                        contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2);
511                        accountId = c.getLong(AggregateExceptionQuery.ACCOUNT_ID2);
512                    }
513                } else {
514                    if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_1) == 0
515                            && !c.isNull(AggregateExceptionQuery.RAW_CONTACT_ID1)) {
516                        rId = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
517                        contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1);
518                        accountId = c.getLong(AggregateExceptionQuery.ACCOUNT_ID1);
519                    }
520                }
521                if (rId != -1) {
522                    if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) {
523                        matcher.keepIn(rId, contactId, accountId);
524                    } else {
525                        matcher.keepOut(rId, contactId, accountId);
526                    }
527                }
528            }
529        } finally {
530            c.close();
531        }
532    }
533
534    /**
535     * Finds contacts with exact identity matches to the the specified raw contact.
536     */
537    private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId,
538            RawContactMatcher matcher) {
539        mSelectionArgs2[0] = String.valueOf(rawContactId);
540        mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity);
541        Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS,
542                IdentityLookupMatchQuery.SELECTION,
543                mSelectionArgs2, RawContacts.CONTACT_ID, null, null);
544        try {
545            while (c.moveToNext()) {
546                final long rId = c.getLong(IdentityLookupMatchQuery.RAW_CONTACT_ID);
547                final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID);
548                final long accountId = c.getLong(IdentityLookupMatchQuery.ACCOUNT_ID);
549                matcher.matchIdentity(rId, contactId, accountId);
550            }
551        } finally {
552            c.close();
553        }
554    }
555
556    /**
557     * Finds contacts with names matching the name of the specified raw contact.
558     */
559    private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId,
560            RawContactMatcher matcher) {
561        mSelectionArgs1[0] = String.valueOf(rawContactId);
562        Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS,
563                NameLookupMatchQuery.SELECTION,
564                mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING);
565        try {
566            while (c.moveToNext()) {
567                long rId =  c.getLong(NameLookupMatchQuery.RAW_CONTACT_ID);
568                long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID);
569                long accountId = c.getLong(NameLookupMatchQuery.ACCOUNT_ID);
570                String name = c.getString(NameLookupMatchQuery.NAME);
571                int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A);
572                int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B);
573                matcher.matchName(rId, contactId, accountId, nameTypeA, name,
574                        nameTypeB, name, RawContactMatcher.MATCHING_ALGORITHM_EXACT);
575                if (nameTypeA == NameLookupType.NICKNAME &&
576                        nameTypeB == NameLookupType.NICKNAME) {
577                    matcher.updateScoreWithNicknameMatch(rId, contactId, accountId);
578                }
579            }
580        } finally {
581            c.close();
582        }
583    }
584
585    private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId,
586            RawContactMatcher matcher) {
587        mSelectionArgs2[0] = String.valueOf(rawContactId);
588        mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail);
589        Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS,
590                EmailLookupQuery.SELECTION,
591                mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
592        try {
593            while (c.moveToNext()) {
594                long rId = c.getLong(EmailLookupQuery.RAW_CONTACT_ID);
595                long contactId = c.getLong(EmailLookupQuery.CONTACT_ID);
596                long accountId = c.getLong(EmailLookupQuery.ACCOUNT_ID);
597                matcher.updateScoreWithEmailMatch(rId, contactId, accountId);
598            }
599        } finally {
600            c.close();
601        }
602    }
603
604    /**
605     * Finds contacts with names matching the specified name.
606     */
607    private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query,
608            MatchCandidateList candidates, RawContactMatcher matcher) {
609        candidates.clear();
610        NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder(
611                mNameSplitter, candidates);
612        builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED);
613        if (builder.isEmpty()) {
614            return;
615        }
616
617        Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE,
618                NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null,
619                null, PRIMARY_HIT_LIMIT_STRING);
620        try {
621            while (c.moveToNext()) {
622                long rId = c.getLong(NameLookupMatchQueryWithParameter.RAW_CONTACT_ID);
623                long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID);
624                long accountId = c.getLong(NameLookupMatchQueryWithParameter.ACCOUNT_ID);
625                String name = c.getString(NameLookupMatchQueryWithParameter.NAME);
626                int nameTypeA = builder.getLookupType(name);
627                int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE);
628                matcher.matchName(rId, contactId, accountId, nameTypeA, name, nameTypeB, name,
629                        RawContactMatcher.MATCHING_ALGORITHM_EXACT);
630                if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) {
631                    matcher.updateScoreWithNicknameMatch(rId, contactId, accountId);
632                }
633            }
634        } finally {
635            c.close();
636        }
637    }
638
639    private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId,
640            RawContactMatcher matcher) {
641        mSelectionArgs2[0] = String.valueOf(rawContactId);
642        mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter();
643        Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS,
644                PhoneLookupQuery.SELECTION,
645                mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
646        try {
647            while (c.moveToNext()) {
648                long rId = c.getLong(PhoneLookupQuery.RAW_CONTACT_ID);
649                long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID);
650                long accountId = c.getLong(PhoneLookupQuery.ACCOUNT_ID);
651                matcher.updateScoreWithPhoneNumberMatch(rId, contactId, accountId);
652            }
653        } finally {
654            c.close();
655        }
656    }
657
658    /**
659     * Loads name lookup rows for approximate name matching and updates match scores based on that
660     * data.
661     */
662    private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
663            RawContactMatcher matcher) {
664        HashSet<String> firstLetters = new HashSet<>();
665        for (int i = 0; i < candidates.mCount; i++) {
666            final NameMatchCandidate candidate = candidates.mList.get(i);
667            if (candidate.mName.length() >= 2) {
668                String firstLetter = candidate.mName.substring(0, 2);
669                if (!firstLetters.contains(firstLetter)) {
670                    firstLetters.add(firstLetter);
671                    final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '"
672                            + firstLetter + "*') AND "
673                            + "(" + NameLookupColumns.NAME_TYPE + " IN("
674                                    + NameLookupType.NAME_COLLATION_KEY + ","
675                                    + NameLookupType.EMAIL_BASED_NICKNAME + ","
676                                    + NameLookupType.NICKNAME + ")) AND "
677                            + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
678                    matchAllCandidates(db, selection, candidates, matcher,
679                            RawContactMatcher.MATCHING_ALGORITHM_APPROXIMATE,
680                            String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT));
681                }
682            }
683        }
684    }
685
686    private interface ContactNameLookupQuery {
687        String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS;
688
689        String[] COLUMNS = new String[] {
690                RawContacts._ID,
691                RawContacts.CONTACT_ID,
692                RawContactsColumns.ACCOUNT_ID,
693                NameLookupColumns.NORMALIZED_NAME,
694                NameLookupColumns.NAME_TYPE
695        };
696
697        int RAW_CONTACT_ID = 0;
698        int CONTACT_ID = 1;
699        int ACCOUNT_ID = 2;
700        int NORMALIZED_NAME = 3;
701        int NAME_TYPE = 4;
702    }
703
704    /**
705     * Loads all candidate rows from the name lookup table and updates match scores based
706     * on that data.
707     */
708    private void matchAllCandidates(SQLiteDatabase db, String selection,
709            MatchCandidateList candidates, RawContactMatcher matcher, int algorithm, String limit) {
710        final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS,
711                selection, null, null, null, null, limit);
712
713        try {
714            while (c.moveToNext()) {
715                Long rawContactId = c.getLong(ContactNameLookupQuery.RAW_CONTACT_ID);
716                Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID);
717                Long accountId = c.getLong(ContactNameLookupQuery.ACCOUNT_ID);
718                String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME);
719                int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE);
720
721                // Note the N^2 complexity of the following fragment. This is not a huge concern
722                // since the number of candidates is very small and in general secondary hits
723                // in the absence of primary hits are rare.
724                for (int i = 0; i < candidates.mCount; i++) {
725                    NameMatchCandidate candidate = candidates.mList.get(i);
726                    matcher.matchName(rawContactId, contactId, accountId, candidate.mLookupType,
727                            candidate.mName, nameType, name, algorithm);
728                }
729            }
730        } finally {
731            c.close();
732        }
733    }
734
735    private interface PhotoFileQuery {
736        final String[] COLUMNS = new String[] {
737                PhotoFiles.HEIGHT,
738                PhotoFiles.WIDTH,
739                PhotoFiles.FILESIZE
740        };
741
742        int HEIGHT = 0;
743        int WIDTH = 1;
744        int FILESIZE = 2;
745    }
746
747    private class PhotoEntry implements Comparable<PhotoEntry> {
748        // Pixel count (width * height) for the image.
749        final int pixelCount;
750
751        // File size (in bytes) of the image.  Not populated if the image is a thumbnail.
752        final int fileSize;
753
754        private PhotoEntry(int pixelCount, int fileSize) {
755            this.pixelCount = pixelCount;
756            this.fileSize = fileSize;
757        }
758
759        @Override
760        public int compareTo(PhotoEntry pe) {
761            if (pe == null) {
762                return -1;
763            }
764            if (pixelCount == pe.pixelCount) {
765                return pe.fileSize - fileSize;
766            } else {
767                return pe.pixelCount - pixelCount;
768            }
769        }
770    }
771
772    private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) {
773        if (photoFileId == 0) {
774            // Assume standard thumbnail size.  Don't bother getting a file size for priority;
775            // we should fall back to photo priority resolver if all we have are thumbnails.
776            int thumbDim = mContactsProvider.getMaxThumbnailDim();
777            return new PhotoEntry(thumbDim * thumbDim, 0);
778        } else {
779            Cursor c = db.query(Tables.PHOTO_FILES, PhotoFileQuery.COLUMNS, PhotoFiles._ID + "=?",
780                    new String[]{String.valueOf(photoFileId)}, null, null, null);
781            try {
782                if (c.getCount() == 1) {
783                    c.moveToFirst();
784                    int pixelCount =
785                            c.getInt(PhotoFileQuery.HEIGHT) * c.getInt(PhotoFileQuery.WIDTH);
786                    return new PhotoEntry(pixelCount, c.getInt(PhotoFileQuery.FILESIZE));
787                }
788            } finally {
789                c.close();
790            }
791        }
792        return new PhotoEntry(0, 0);
793    }
794    /**
795     * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
796     * descending order of match score.
797     * @param parameters
798     */
799    protected List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId,
800            ArrayList<AggregationSuggestionParameter> parameters) {
801
802        MatchCandidateList candidates = new MatchCandidateList();
803        RawContactMatcher matcher = new RawContactMatcher();
804
805        if (parameters == null || parameters.size() == 0) {
806            final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
807                    RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
808            try {
809                while (c.moveToNext()) {
810                    long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID);
811                    long accountId = c.getLong(RawContactIdQuery.ACCOUNT_ID);
812                    // Don't aggregate a contact with its own raw contacts.
813                    matcher.keepOut(rawContactId, contactId, accountId);
814                    updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates,
815                            matcher);
816                }
817            } finally {
818                c.close();
819            }
820        } else {
821            updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates,
822                    matcher, parameters);
823        }
824
825        return matcher.pickBestMatches(SCORE_THRESHOLD_SUGGEST);
826    }
827
828    /**
829     * Computes suggestion scores for contacts that have matching data rows.
830     * Aggregation suggestion doesn't consider aggregation exceptions, but is purely based on the
831     * raw contacts information.
832     */
833    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
834            long rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) {
835
836        updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
837        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
838        updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
839        updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
840        loadNameMatchCandidates(db, rawContactId, candidates, false);
841        lookupApproximateNameMatches(db, candidates, matcher);
842    }
843
844    /**
845     * Computes scores for contacts that have matching data rows.
846     */
847    private void updateMatchScores(SQLiteDatabase db, long rawContactId,
848            MatchCandidateList candidates, RawContactMatcher matcher) {
849        updateMatchScoresBasedOnExceptions(db, rawContactId, matcher);
850        updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
851        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
852        updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
853        updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
854        updateMatchScoresBasedOnSecondaryData(db, rawContactId, candidates, matcher);
855    }
856
857    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
858            MatchCandidateList candidates, RawContactMatcher matcher,
859            ArrayList<AggregationSuggestionParameter> parameters) {
860        for (AggregationSuggestionParameter parameter : parameters) {
861            if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) {
862                updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher);
863            }
864
865            // TODO: add support for other parameter kinds
866        }
867    }
868
869    /**
870     * Update scores for matches with secondary data matching but insufficient primary scores.
871     * This method loads structured names for all candidate contacts and recomputes match scores
872     * using approximate matching.
873     */
874    private void updateMatchScoresBasedOnSecondaryData(SQLiteDatabase db,
875            long rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) {
876        final List<Long> secondaryRawContactIds = matcher.prepareSecondaryMatchCandidates();
877        if (secondaryRawContactIds == null || secondaryRawContactIds.size() > SECONDARY_HIT_LIMIT) {
878            return;
879        }
880
881        loadNameMatchCandidates(db, rawContactId, candidates, true);
882
883        mSb.setLength(0);
884        mSb.append(RawContacts._ID).append(" IN (");
885        for (int i = 0; i < secondaryRawContactIds.size(); i++) {
886            if (i != 0) {
887                mSb.append(',');
888            }
889            mSb.append(secondaryRawContactIds.get(i));
890        }
891
892        // We only want to compare structured names to structured names
893        // at this stage, we need to ignore all other sources of name lookup data.
894        mSb.append(") AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL);
895
896        matchAllCandidates(db, mSb.toString(), candidates, matcher,
897                RawContactMatcher.MATCHING_ALGORITHM_CONSERVATIVE, null);
898    }
899}
900