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