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