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