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