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