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