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