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