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