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