ContactAggregator.java revision 89f033e186043aeacaf16f73df44f2e0e6a106fa
1/* 2 * Copyright (C) 2009 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17package com.android.providers.contacts.aggregation; 18 19import com.android.providers.contacts.ContactLookupKey; 20import com.android.providers.contacts.ContactsDatabaseHelper; 21import com.android.providers.contacts.ContactsDatabaseHelper.AccountsColumns; 22import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns; 23import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns; 24import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns; 25import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns; 26import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType; 27import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns; 28import com.android.providers.contacts.ContactsDatabaseHelper.PresenceColumns; 29import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns; 30import com.android.providers.contacts.ContactsDatabaseHelper.StatusUpdatesColumns; 31import com.android.providers.contacts.ContactsDatabaseHelper.Tables; 32import com.android.providers.contacts.ContactsDatabaseHelper.Views; 33import com.android.providers.contacts.ContactsProvider2; 34import com.android.providers.contacts.NameLookupBuilder; 35import com.android.providers.contacts.NameNormalizer; 36import com.android.providers.contacts.NameSplitter; 37import com.android.providers.contacts.PhotoPriorityResolver; 38import com.android.providers.contacts.ReorderingCursorWrapper; 39import com.android.providers.contacts.TransactionContext; 40import com.android.providers.contacts.aggregation.util.CommonNicknameCache; 41import com.android.providers.contacts.aggregation.util.ContactMatcher; 42import com.android.providers.contacts.aggregation.util.ContactMatcher.MatchScore; 43import com.google.android.collect.Maps; 44 45import android.database.Cursor; 46import android.database.DatabaseUtils; 47import android.database.sqlite.SQLiteDatabase; 48import android.database.sqlite.SQLiteQueryBuilder; 49import android.database.sqlite.SQLiteStatement; 50import android.net.Uri; 51import android.provider.ContactsContract.AggregationExceptions; 52import android.provider.ContactsContract.CommonDataKinds.Email; 53import android.provider.ContactsContract.CommonDataKinds.Identity; 54import android.provider.ContactsContract.CommonDataKinds.Phone; 55import android.provider.ContactsContract.CommonDataKinds.Photo; 56import android.provider.ContactsContract.Contacts; 57import android.provider.ContactsContract.Contacts.AggregationSuggestions; 58import android.provider.ContactsContract.Data; 59import android.provider.ContactsContract.DisplayNameSources; 60import android.provider.ContactsContract.FullNameStyle; 61import android.provider.ContactsContract.PhotoFiles; 62import android.provider.ContactsContract.RawContacts; 63import android.provider.ContactsContract.StatusUpdates; 64import android.text.TextUtils; 65import android.util.EventLog; 66import android.util.Log; 67 68import java.util.ArrayList; 69import java.util.Collections; 70import java.util.HashMap; 71import java.util.HashSet; 72import java.util.Iterator; 73import java.util.List; 74import 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 */ 81public 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 } 795 796 if (contactIdToSplit != -1) { 797 splitAutomaticallyAggregatedRawContacts(txContext, db, contactIdToSplit); 798 } 799 } 800 801 /** 802 * @return true if the raw contact of {@code rawContactId} can be joined into the existing 803 * contact of {@code of contactId}. 804 * 805 * Now a raw contact can be merged into a contact containing raw contacts from 806 * the same account if there's at least one raw contact in those raw contacts 807 * that shares at least one email address, phone number, or identity. 808 */ 809 private boolean canJoinIntoContact(SQLiteDatabase db, long contactId, 810 long rawContactId, long rawContactAccountId) { 811 // First, list all raw contact IDs in contact [contactId] on account [rawContactAccountId], 812 // excluding raw_contact [rawContactId]. 813 814 // Append all found raw contact IDs into this SB to create a comma separated list of 815 // the IDs. 816 // We don't always need it, so lazily initialize it. 817 StringBuilder rawContactIdsBuilder; 818 819 mSelectionArgs3[0] = String.valueOf(contactId); 820 mSelectionArgs3[1] = String.valueOf(rawContactId); 821 mSelectionArgs3[2] = String.valueOf(rawContactAccountId); 822 final Cursor duplicatesCursor = db.rawQuery( 823 "SELECT " + RawContacts._ID + 824 " FROM " + Tables.RAW_CONTACTS + 825 " WHERE " + RawContacts.CONTACT_ID + "=?" + 826 " AND " + RawContacts._ID + "!=?" + 827 " AND " + RawContactsColumns.ACCOUNT_ID +"=?", 828 mSelectionArgs3); 829 try { 830 final int duplicateCount = duplicatesCursor.getCount(); 831 if (duplicateCount == 0) { 832 return true; // No duplicates -- common case -- bail early. 833 } 834 if (VERBOSE_LOGGING) { 835 Log.v(TAG, "canJoinIntoContact: " + duplicateCount + " duplicate(s) found"); 836 } 837 838 rawContactIdsBuilder = new StringBuilder(); 839 840 duplicatesCursor.moveToPosition(-1); 841 while (duplicatesCursor.moveToNext()) { 842 if (rawContactIdsBuilder.length() > 0) { 843 rawContactIdsBuilder.append(','); 844 } 845 rawContactIdsBuilder.append(duplicatesCursor.getLong(0)); 846 } 847 } finally { 848 duplicatesCursor.close(); 849 } 850 851 // Comma separated raw_contacts IDs. 852 final String rawContactIds = rawContactIdsBuilder.toString(); 853 854 // See if there's any raw_contacts that share an email address, a phone number, or 855 // an identity with raw_contact [rawContactId]. 856 857 // First, check for the email address. 858 mSelectionArgs2[0] = String.valueOf(mMimeTypeIdEmail); 859 mSelectionArgs2[1] = String.valueOf(rawContactId); 860 if (isFirstColumnGreaterThanZero(db, 861 "SELECT count(*)" + 862 " FROM " + Tables.DATA + " AS d1" + 863 " JOIN " + Tables.DATA + " AS d2" 864 + " ON (d1." + Email.ADDRESS + " = d2." + Email.ADDRESS + ")" + 865 " WHERE d1." + DataColumns.MIMETYPE_ID + " = ?1" + 866 " AND d2." + DataColumns.MIMETYPE_ID + " = ?1" + 867 " AND d1." + Data.RAW_CONTACT_ID + " = ?2" + 868 " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ")", 869 mSelectionArgs2)) { 870 if (VERBOSE_LOGGING) { 871 Log.v(TAG, "Relaxing rule SA: email match found for rid=" + rawContactId); 872 } 873 return true; 874 } 875 876 // Next, check for the identity. 877 mSelectionArgs2[0] = String.valueOf(mMimeTypeIdIdentity); 878 mSelectionArgs2[1] = String.valueOf(rawContactId); 879 if (isFirstColumnGreaterThanZero(db, 880 "SELECT count(*)" + 881 " FROM " + Tables.DATA + " AS d1" + 882 " JOIN " + Tables.DATA + " AS d2" 883 + " ON (d1." + Identity.IDENTITY + " = d2." + Identity.IDENTITY + " AND" + 884 " d1." + Identity.NAMESPACE + " = d2." + Identity.NAMESPACE + " )" + 885 " WHERE d1." + DataColumns.MIMETYPE_ID + " = ?1" + 886 " AND d2." + DataColumns.MIMETYPE_ID + " = ?1" + 887 " AND d1." + Data.RAW_CONTACT_ID + " = ?2" + 888 " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ")", 889 mSelectionArgs2)) { 890 if (VERBOSE_LOGGING) { 891 Log.v(TAG, "Relaxing rule SA: identity match found for rid=" + rawContactId); 892 } 893 return true; 894 } 895 896 // Lastly, the phone number. 897 // It's a bit tricker because it has to be consistent with 898 // updateMatchScoresBasedOnPhoneMatches(). 899 mSelectionArgs3[0] = String.valueOf(mMimeTypeIdPhone); 900 mSelectionArgs3[1] = String.valueOf(rawContactId); 901 mSelectionArgs3[2] = String.valueOf(mDbHelper.getUseStrictPhoneNumberComparisonParameter()); 902 903 if (isFirstColumnGreaterThanZero(db, 904 "SELECT count(*)" + 905 " FROM " + Tables.PHONE_LOOKUP + " AS p1" + 906 " JOIN " + Tables.DATA + " AS d1 ON " + 907 "(d1." + Data._ID + "=p1." + PhoneLookupColumns.DATA_ID + ")" + 908 " JOIN " + Tables.PHONE_LOOKUP + " AS p2 ON (p1." + PhoneLookupColumns.MIN_MATCH + 909 "=p2." + PhoneLookupColumns.MIN_MATCH + ")" + 910 " JOIN " + Tables.DATA + " AS d2 ON " + 911 "(d2." + Data._ID + "=p2." + PhoneLookupColumns.DATA_ID + ")" + 912 " WHERE d1." + DataColumns.MIMETYPE_ID + " = ?1" + 913 " AND d2." + DataColumns.MIMETYPE_ID + " = ?1" + 914 " AND d1." + Data.RAW_CONTACT_ID + " = ?2" + 915 " AND d2." + Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ")" + 916 " AND PHONE_NUMBERS_EQUAL(d1." + Phone.NUMBER + ",d2." + Phone.NUMBER + ",?3)", 917 mSelectionArgs3)) { 918 if (VERBOSE_LOGGING) { 919 Log.v(TAG, "Relaxing rule SA: phone match found for rid=" + rawContactId); 920 } 921 return true; 922 } 923 if (VERBOSE_LOGGING) { 924 Log.v(TAG, "Rule SA splitting up cid=" + contactId + " for rid=" + rawContactId); 925 } 926 return false; 927 } 928 929 private boolean isFirstColumnGreaterThanZero(SQLiteDatabase db, String query, 930 String[] selectionArgs) { 931 final Cursor cursor = db.rawQuery(query, selectionArgs); 932 try { 933 return cursor.moveToFirst() && (cursor.getInt(0) > 0); 934 } finally { 935 cursor.close(); 936 } 937 } 938 939 /** 940 * Breaks up an existing aggregate when a new raw contact is inserted that has 941 * come from the same account as one of the raw contacts in this aggregate. 942 */ 943 private void splitAutomaticallyAggregatedRawContacts( 944 TransactionContext txContext, SQLiteDatabase db, long contactId) { 945 mSelectionArgs1[0] = String.valueOf(contactId); 946 int count = (int) DatabaseUtils.longForQuery(db, 947 "SELECT COUNT(" + RawContacts._ID + ")" + 948 " FROM " + Tables.RAW_CONTACTS + 949 " WHERE " + RawContacts.CONTACT_ID + "=?", mSelectionArgs1); 950 if (count < 2) { 951 // A single-raw-contact aggregate does not need to be split up 952 return; 953 } 954 955 // Find all constituent raw contacts that are not held together by 956 // an explicit aggregation exception 957 String query = 958 "SELECT " + RawContacts._ID + 959 " FROM " + Tables.RAW_CONTACTS + 960 " WHERE " + RawContacts.CONTACT_ID + "=?" + 961 " AND " + RawContacts._ID + " NOT IN " + 962 "(SELECT " + AggregationExceptions.RAW_CONTACT_ID1 + 963 " FROM " + Tables.AGGREGATION_EXCEPTIONS + 964 " WHERE " + AggregationExceptions.TYPE + "=" 965 + AggregationExceptions.TYPE_KEEP_TOGETHER + 966 " UNION SELECT " + AggregationExceptions.RAW_CONTACT_ID2 + 967 " FROM " + Tables.AGGREGATION_EXCEPTIONS + 968 " WHERE " + AggregationExceptions.TYPE + "=" 969 + AggregationExceptions.TYPE_KEEP_TOGETHER + 970 ")"; 971 972 Cursor cursor = db.rawQuery(query, mSelectionArgs1); 973 try { 974 // Process up to count-1 raw contact, leaving the last one alone. 975 for (int i = 0; i < count - 1; i++) { 976 if (!cursor.moveToNext()) { 977 break; 978 } 979 long rawContactId = cursor.getLong(0); 980 createNewContactForRawContact(txContext, db, rawContactId); 981 } 982 } finally { 983 cursor.close(); 984 } 985 if (contactId > 0) { 986 updateAggregateData(txContext, contactId); 987 } 988 } 989 990 /** 991 * Creates a stand-alone Contact for the given raw contact ID. 992 */ 993 private void createNewContactForRawContact( 994 TransactionContext txContext, SQLiteDatabase db, long rawContactId) { 995 mSelectionArgs1[0] = String.valueOf(rawContactId); 996 computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1, 997 mContactInsert); 998 long contactId = mContactInsert.executeInsert(); 999 setContactIdAndMarkAggregated(rawContactId, contactId); 1000 mDbHelper.updateContactVisible(txContext, contactId); 1001 setPresenceContactId(rawContactId, contactId); 1002 updateAggregatedStatusUpdate(contactId); 1003 } 1004 1005 private static class RawContactIdQuery { 1006 public static final String TABLE = Tables.RAW_CONTACTS; 1007 public static final String[] COLUMNS = { RawContacts._ID }; 1008 public static final String SELECTION = RawContacts.CONTACT_ID + "=?"; 1009 public static final int RAW_CONTACT_ID = 0; 1010 } 1011 1012 /** 1013 * Ensures that automatic aggregation rules are followed after a contact 1014 * becomes visible or invisible. Specifically, consider this case: there are 1015 * three contacts named Foo. Two of them come from account A1 and one comes 1016 * from account A2. The aggregation rules say that in this case none of the 1017 * three Foo's should be aggregated: two of them are in the same account, so 1018 * they don't get aggregated; the third has two affinities, so it does not 1019 * join either of them. 1020 * <p> 1021 * Consider what happens if one of the "Foo"s from account A1 becomes 1022 * invisible. Nothing stands in the way of aggregating the other two 1023 * anymore, so they should get joined. 1024 * <p> 1025 * What if the invisible "Foo" becomes visible after that? We should split the 1026 * aggregate between the other two. 1027 */ 1028 public void updateAggregationAfterVisibilityChange(long contactId) { 1029 SQLiteDatabase db = mDbHelper.getWritableDatabase(); 1030 boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId); 1031 if (visible) { 1032 markContactForAggregation(db, contactId); 1033 } else { 1034 // Find all contacts that _could be_ aggregated with this one and 1035 // rerun aggregation for all of them 1036 mSelectionArgs1[0] = String.valueOf(contactId); 1037 Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS, 1038 RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null); 1039 try { 1040 while (cursor.moveToNext()) { 1041 long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID); 1042 mMatcher.clear(); 1043 1044 updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher); 1045 updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher); 1046 List<MatchScore> bestMatches = 1047 mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_PRIMARY); 1048 for (MatchScore matchScore : bestMatches) { 1049 markContactForAggregation(db, matchScore.getContactId()); 1050 } 1051 1052 mMatcher.clear(); 1053 updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher); 1054 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher); 1055 bestMatches = 1056 mMatcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SECONDARY); 1057 for (MatchScore matchScore : bestMatches) { 1058 markContactForAggregation(db, matchScore.getContactId()); 1059 } 1060 } 1061 } finally { 1062 cursor.close(); 1063 } 1064 } 1065 } 1066 1067 /** 1068 * Updates the contact ID for the specified contact. 1069 */ 1070 protected void setContactId(long rawContactId, long contactId) { 1071 mContactIdUpdate.bindLong(1, contactId); 1072 mContactIdUpdate.bindLong(2, rawContactId); 1073 mContactIdUpdate.execute(); 1074 } 1075 1076 /** 1077 * Marks the specified raw contact ID as aggregated 1078 */ 1079 private void markAggregated(long rawContactId) { 1080 mMarkAggregatedUpdate.bindLong(1, rawContactId); 1081 mMarkAggregatedUpdate.execute(); 1082 } 1083 1084 /** 1085 * Updates the contact ID for the specified contact and marks the raw contact as aggregated. 1086 */ 1087 private void setContactIdAndMarkAggregated(long rawContactId, long contactId) { 1088 mContactIdAndMarkAggregatedUpdate.bindLong(1, contactId); 1089 mContactIdAndMarkAggregatedUpdate.bindLong(2, rawContactId); 1090 mContactIdAndMarkAggregatedUpdate.execute(); 1091 } 1092 1093 private void setPresenceContactId(long rawContactId, long contactId) { 1094 mPresenceContactIdUpdate.bindLong(1, contactId); 1095 mPresenceContactIdUpdate.bindLong(2, rawContactId); 1096 mPresenceContactIdUpdate.execute(); 1097 } 1098 1099 interface AggregateExceptionPrefetchQuery { 1100 String TABLE = Tables.AGGREGATION_EXCEPTIONS; 1101 1102 String[] COLUMNS = { 1103 AggregationExceptions.RAW_CONTACT_ID1, 1104 AggregationExceptions.RAW_CONTACT_ID2, 1105 }; 1106 1107 int RAW_CONTACT_ID1 = 0; 1108 int RAW_CONTACT_ID2 = 1; 1109 } 1110 1111 // A set of raw contact IDs for which there are aggregation exceptions 1112 private final HashSet<Long> mAggregationExceptionIds = new HashSet<Long>(); 1113 private boolean mAggregationExceptionIdsValid; 1114 1115 public void invalidateAggregationExceptionCache() { 1116 mAggregationExceptionIdsValid = false; 1117 } 1118 1119 /** 1120 * Finds all raw contact IDs for which there are aggregation exceptions. The list of 1121 * ids is used as an optimization in aggregation: there is no point to run a query against 1122 * the agg_exceptions table if it is known that there are no records there for a given 1123 * raw contact ID. 1124 */ 1125 private void prefetchAggregationExceptionIds(SQLiteDatabase db) { 1126 mAggregationExceptionIds.clear(); 1127 final Cursor c = db.query(AggregateExceptionPrefetchQuery.TABLE, 1128 AggregateExceptionPrefetchQuery.COLUMNS, 1129 null, null, null, null, null); 1130 1131 try { 1132 while (c.moveToNext()) { 1133 long rawContactId1 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID1); 1134 long rawContactId2 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID2); 1135 mAggregationExceptionIds.add(rawContactId1); 1136 mAggregationExceptionIds.add(rawContactId2); 1137 } 1138 } finally { 1139 c.close(); 1140 } 1141 1142 mAggregationExceptionIdsValid = true; 1143 } 1144 1145 interface AggregateExceptionQuery { 1146 String TABLE = Tables.AGGREGATION_EXCEPTIONS 1147 + " JOIN raw_contacts raw_contacts1 " 1148 + " ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) " 1149 + " JOIN raw_contacts raw_contacts2 " 1150 + " ON (agg_exceptions.raw_contact_id2 = raw_contacts2._id) "; 1151 1152 String[] COLUMNS = { 1153 AggregationExceptions.TYPE, 1154 AggregationExceptions.RAW_CONTACT_ID1, 1155 "raw_contacts1." + RawContacts.CONTACT_ID, 1156 "raw_contacts1." + RawContactsColumns.AGGREGATION_NEEDED, 1157 "raw_contacts2." + RawContacts.CONTACT_ID, 1158 "raw_contacts2." + RawContactsColumns.AGGREGATION_NEEDED, 1159 }; 1160 1161 int TYPE = 0; 1162 int RAW_CONTACT_ID1 = 1; 1163 int CONTACT_ID1 = 2; 1164 int AGGREGATION_NEEDED_1 = 3; 1165 int CONTACT_ID2 = 4; 1166 int AGGREGATION_NEEDED_2 = 5; 1167 } 1168 1169 /** 1170 * Computes match scores based on exceptions entered by the user: always match and never match. 1171 * Returns the aggregate contact with the always match exception if any. 1172 */ 1173 private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId, 1174 ContactMatcher matcher) { 1175 if (!mAggregationExceptionIdsValid) { 1176 prefetchAggregationExceptionIds(db); 1177 } 1178 1179 // If there are no aggregation exceptions involving this raw contact, there is no need to 1180 // run a query and we can just return -1, which stands for "nothing found" 1181 if (!mAggregationExceptionIds.contains(rawContactId)) { 1182 return -1; 1183 } 1184 1185 final Cursor c = db.query(AggregateExceptionQuery.TABLE, 1186 AggregateExceptionQuery.COLUMNS, 1187 AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId 1188 + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId, 1189 null, null, null, null); 1190 1191 try { 1192 while (c.moveToNext()) { 1193 int type = c.getInt(AggregateExceptionQuery.TYPE); 1194 long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1); 1195 long contactId = -1; 1196 if (rawContactId == rawContactId1) { 1197 if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_2) == 0 1198 && !c.isNull(AggregateExceptionQuery.CONTACT_ID2)) { 1199 contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2); 1200 } 1201 } else { 1202 if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_1) == 0 1203 && !c.isNull(AggregateExceptionQuery.CONTACT_ID1)) { 1204 contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1); 1205 } 1206 } 1207 if (contactId != -1) { 1208 if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) { 1209 matcher.keepIn(contactId); 1210 } else { 1211 matcher.keepOut(contactId); 1212 } 1213 } 1214 } 1215 } finally { 1216 c.close(); 1217 } 1218 1219 return matcher.pickBestMatch(ContactMatcher.MAX_SCORE, true); 1220 } 1221 1222 /** 1223 * Picks the best matching contact based on matches between data elements. It considers 1224 * name match to be primary and phone, email etc matches to be secondary. A good primary 1225 * match triggers aggregation, while a good secondary match only triggers aggregation in 1226 * the absence of a strong primary mismatch. 1227 * <p> 1228 * Consider these examples: 1229 * <p> 1230 * John Doe with phone number 111-111-1111 and Jon Doe with phone number 111-111-1111 should 1231 * be aggregated (same number, similar names). 1232 * <p> 1233 * John Doe with phone number 111-111-1111 and Deborah Doe with phone number 111-111-1111 should 1234 * not be aggregated (same number, different names). 1235 */ 1236 private long pickBestMatchBasedOnData(SQLiteDatabase db, long rawContactId, 1237 MatchCandidateList candidates, ContactMatcher matcher) { 1238 1239 // Find good matches based on name alone 1240 long bestMatch = updateMatchScoresBasedOnDataMatches(db, rawContactId, matcher); 1241 if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) { 1242 // We found multiple matches on the name - do not aggregate because of the ambiguity 1243 return -1; 1244 } else if (bestMatch == -1) { 1245 // We haven't found a good match on name, see if we have any matches on phone, email etc 1246 bestMatch = pickBestMatchBasedOnSecondaryData(db, rawContactId, candidates, matcher); 1247 if (bestMatch == ContactMatcher.MULTIPLE_MATCHES) { 1248 return -1; 1249 } 1250 } 1251 1252 return bestMatch; 1253 } 1254 1255 1256 /** 1257 * Picks the best matching contact based on secondary data matches. The method loads 1258 * structured names for all candidate contacts and recomputes match scores using approximate 1259 * matching. 1260 */ 1261 private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db, 1262 long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) { 1263 List<Long> secondaryContactIds = matcher.prepareSecondaryMatchCandidates( 1264 ContactMatcher.SCORE_THRESHOLD_PRIMARY); 1265 if (secondaryContactIds == null || secondaryContactIds.size() > SECONDARY_HIT_LIMIT) { 1266 return -1; 1267 } 1268 1269 loadNameMatchCandidates(db, rawContactId, candidates, true); 1270 1271 mSb.setLength(0); 1272 mSb.append(RawContacts.CONTACT_ID).append(" IN ("); 1273 for (int i = 0; i < secondaryContactIds.size(); i++) { 1274 if (i != 0) { 1275 mSb.append(','); 1276 } 1277 mSb.append(secondaryContactIds.get(i)); 1278 } 1279 1280 // We only want to compare structured names to structured names 1281 // at this stage, we need to ignore all other sources of name lookup data. 1282 mSb.append(") AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL); 1283 1284 matchAllCandidates(db, mSb.toString(), candidates, matcher, 1285 ContactMatcher.MATCHING_ALGORITHM_CONSERVATIVE, null); 1286 1287 return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY, false); 1288 } 1289 1290 private interface NameLookupQuery { 1291 String TABLE = Tables.NAME_LOOKUP; 1292 1293 String SELECTION = NameLookupColumns.RAW_CONTACT_ID + "=?"; 1294 String SELECTION_STRUCTURED_NAME_BASED = 1295 SELECTION + " AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL; 1296 1297 String[] COLUMNS = new String[] { 1298 NameLookupColumns.NORMALIZED_NAME, 1299 NameLookupColumns.NAME_TYPE 1300 }; 1301 1302 int NORMALIZED_NAME = 0; 1303 int NAME_TYPE = 1; 1304 } 1305 1306 private void loadNameMatchCandidates(SQLiteDatabase db, long rawContactId, 1307 MatchCandidateList candidates, boolean structuredNameBased) { 1308 candidates.clear(); 1309 mSelectionArgs1[0] = String.valueOf(rawContactId); 1310 Cursor c = db.query(NameLookupQuery.TABLE, NameLookupQuery.COLUMNS, 1311 structuredNameBased 1312 ? NameLookupQuery.SELECTION_STRUCTURED_NAME_BASED 1313 : NameLookupQuery.SELECTION, 1314 mSelectionArgs1, null, null, null); 1315 try { 1316 while (c.moveToNext()) { 1317 String normalizedName = c.getString(NameLookupQuery.NORMALIZED_NAME); 1318 int type = c.getInt(NameLookupQuery.NAME_TYPE); 1319 candidates.add(normalizedName, type); 1320 } 1321 } finally { 1322 c.close(); 1323 } 1324 } 1325 1326 /** 1327 * Computes scores for contacts that have matching data rows. 1328 */ 1329 private long updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId, 1330 ContactMatcher matcher) { 1331 1332 updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher); 1333 updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher); 1334 long bestMatch = matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_PRIMARY, false); 1335 if (bestMatch != -1) { 1336 return bestMatch; 1337 } 1338 1339 updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher); 1340 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher); 1341 1342 return -1; 1343 } 1344 1345 private interface IdentityLookupMatchQuery { 1346 final String TABLE = Tables.DATA + " dataA" 1347 + " JOIN " + Tables.DATA + " dataB" + 1348 " ON (dataA." + Identity.NAMESPACE + "=dataB." + Identity.NAMESPACE + 1349 " AND dataA." + Identity.IDENTITY + "=dataB." + Identity.IDENTITY + ")" 1350 + " JOIN " + Tables.RAW_CONTACTS + 1351 " ON (dataB." + Data.RAW_CONTACT_ID + " = " 1352 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 1353 1354 final String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1" 1355 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2" 1356 + " AND dataA." + Identity.NAMESPACE + " NOT NULL" 1357 + " AND dataA." + Identity.IDENTITY + " NOT NULL" 1358 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2" 1359 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0" 1360 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1361 1362 final String[] COLUMNS = new String[] { 1363 RawContacts.CONTACT_ID 1364 }; 1365 1366 int CONTACT_ID = 0; 1367 } 1368 1369 /** 1370 * Finds contacts with exact identity matches to the the specified raw contact. 1371 */ 1372 private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId, 1373 ContactMatcher matcher) { 1374 mSelectionArgs2[0] = String.valueOf(rawContactId); 1375 mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity); 1376 Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS, 1377 IdentityLookupMatchQuery.SELECTION, 1378 mSelectionArgs2, RawContacts.CONTACT_ID, null, null); 1379 try { 1380 while (c.moveToNext()) { 1381 final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID); 1382 matcher.matchIdentity(contactId); 1383 } 1384 } finally { 1385 c.close(); 1386 } 1387 1388 } 1389 1390 private interface NameLookupMatchQuery { 1391 String TABLE = Tables.NAME_LOOKUP + " nameA" 1392 + " JOIN " + Tables.NAME_LOOKUP + " nameB" + 1393 " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "=" 1394 + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")" 1395 + " JOIN " + Tables.RAW_CONTACTS + 1396 " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = " 1397 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 1398 1399 String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?" 1400 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0" 1401 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1402 1403 String[] COLUMNS = new String[] { 1404 RawContacts.CONTACT_ID, 1405 "nameA." + NameLookupColumns.NORMALIZED_NAME, 1406 "nameA." + NameLookupColumns.NAME_TYPE, 1407 "nameB." + NameLookupColumns.NAME_TYPE, 1408 }; 1409 1410 int CONTACT_ID = 0; 1411 int NAME = 1; 1412 int NAME_TYPE_A = 2; 1413 int NAME_TYPE_B = 3; 1414 } 1415 1416 /** 1417 * Finds contacts with names matching the name of the specified raw contact. 1418 */ 1419 private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId, 1420 ContactMatcher matcher) { 1421 mSelectionArgs1[0] = String.valueOf(rawContactId); 1422 Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS, 1423 NameLookupMatchQuery.SELECTION, 1424 mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING); 1425 try { 1426 while (c.moveToNext()) { 1427 long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID); 1428 String name = c.getString(NameLookupMatchQuery.NAME); 1429 int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A); 1430 int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B); 1431 matcher.matchName(contactId, nameTypeA, name, 1432 nameTypeB, name, ContactMatcher.MATCHING_ALGORITHM_EXACT); 1433 if (nameTypeA == NameLookupType.NICKNAME && 1434 nameTypeB == NameLookupType.NICKNAME) { 1435 matcher.updateScoreWithNicknameMatch(contactId); 1436 } 1437 } 1438 } finally { 1439 c.close(); 1440 } 1441 } 1442 1443 private interface NameLookupMatchQueryWithParameter { 1444 String TABLE = Tables.NAME_LOOKUP 1445 + " JOIN " + Tables.RAW_CONTACTS + 1446 " ON (" + NameLookupColumns.RAW_CONTACT_ID + " = " 1447 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 1448 1449 String[] COLUMNS = new String[] { 1450 RawContacts.CONTACT_ID, 1451 NameLookupColumns.NORMALIZED_NAME, 1452 NameLookupColumns.NAME_TYPE, 1453 }; 1454 1455 int CONTACT_ID = 0; 1456 int NAME = 1; 1457 int NAME_TYPE = 2; 1458 } 1459 1460 private final class NameLookupSelectionBuilder extends NameLookupBuilder { 1461 1462 private final MatchCandidateList mNameLookupCandidates; 1463 1464 private StringBuilder mSelection = new StringBuilder( 1465 NameLookupColumns.NORMALIZED_NAME + " IN("); 1466 1467 1468 public NameLookupSelectionBuilder(NameSplitter splitter, MatchCandidateList candidates) { 1469 super(splitter); 1470 this.mNameLookupCandidates = candidates; 1471 } 1472 1473 @Override 1474 protected String[] getCommonNicknameClusters(String normalizedName) { 1475 return mCommonNicknameCache.getCommonNicknameClusters(normalizedName); 1476 } 1477 1478 @Override 1479 protected void insertNameLookup( 1480 long rawContactId, long dataId, int lookupType, String string) { 1481 mNameLookupCandidates.add(string, lookupType); 1482 DatabaseUtils.appendEscapedSQLString(mSelection, string); 1483 mSelection.append(','); 1484 } 1485 1486 public boolean isEmpty() { 1487 return mNameLookupCandidates.isEmpty(); 1488 } 1489 1490 public String getSelection() { 1491 mSelection.setLength(mSelection.length() - 1); // Strip last comma 1492 mSelection.append(')'); 1493 return mSelection.toString(); 1494 } 1495 1496 public int getLookupType(String name) { 1497 for (int i = 0; i < mNameLookupCandidates.mCount; i++) { 1498 if (mNameLookupCandidates.mList.get(i).mName.equals(name)) { 1499 return mNameLookupCandidates.mList.get(i).mLookupType; 1500 } 1501 } 1502 throw new IllegalStateException(); 1503 } 1504 } 1505 1506 /** 1507 * Finds contacts with names matching the specified name. 1508 */ 1509 private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query, 1510 MatchCandidateList candidates, ContactMatcher matcher) { 1511 candidates.clear(); 1512 NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder( 1513 mNameSplitter, candidates); 1514 builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED); 1515 if (builder.isEmpty()) { 1516 return; 1517 } 1518 1519 Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE, 1520 NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null, 1521 null, PRIMARY_HIT_LIMIT_STRING); 1522 try { 1523 while (c.moveToNext()) { 1524 long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID); 1525 String name = c.getString(NameLookupMatchQueryWithParameter.NAME); 1526 int nameTypeA = builder.getLookupType(name); 1527 int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE); 1528 matcher.matchName(contactId, nameTypeA, name, nameTypeB, name, 1529 ContactMatcher.MATCHING_ALGORITHM_EXACT); 1530 if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) { 1531 matcher.updateScoreWithNicknameMatch(contactId); 1532 } 1533 } 1534 } finally { 1535 c.close(); 1536 } 1537 } 1538 1539 private interface EmailLookupQuery { 1540 String TABLE = Tables.DATA + " dataA" 1541 + " JOIN " + Tables.DATA + " dataB" + 1542 " ON (" + "dataA." + Email.DATA + "=dataB." + Email.DATA + ")" 1543 + " JOIN " + Tables.RAW_CONTACTS + 1544 " ON (dataB." + Data.RAW_CONTACT_ID + " = " 1545 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 1546 1547 String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1" 1548 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2" 1549 + " AND dataA." + Email.DATA + " NOT NULL" 1550 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2" 1551 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0" 1552 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1553 1554 String[] COLUMNS = new String[] { 1555 RawContacts.CONTACT_ID 1556 }; 1557 1558 int CONTACT_ID = 0; 1559 } 1560 1561 private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId, 1562 ContactMatcher matcher) { 1563 mSelectionArgs2[0] = String.valueOf(rawContactId); 1564 mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail); 1565 Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS, 1566 EmailLookupQuery.SELECTION, 1567 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING); 1568 try { 1569 while (c.moveToNext()) { 1570 long contactId = c.getLong(EmailLookupQuery.CONTACT_ID); 1571 matcher.updateScoreWithEmailMatch(contactId); 1572 } 1573 } finally { 1574 c.close(); 1575 } 1576 } 1577 1578 private interface PhoneLookupQuery { 1579 String TABLE = Tables.PHONE_LOOKUP + " phoneA" 1580 + " JOIN " + Tables.DATA + " dataA" 1581 + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")" 1582 + " JOIN " + Tables.PHONE_LOOKUP + " phoneB" 1583 + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "=" 1584 + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")" 1585 + " JOIN " + Tables.DATA + " dataB" 1586 + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")" 1587 + " JOIN " + Tables.RAW_CONTACTS 1588 + " ON (dataB." + Data.RAW_CONTACT_ID + " = " 1589 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 1590 1591 String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?" 1592 + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", " 1593 + "dataB." + Phone.NUMBER + ",?)" 1594 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0" 1595 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1596 1597 String[] COLUMNS = new String[] { 1598 RawContacts.CONTACT_ID 1599 }; 1600 1601 int CONTACT_ID = 0; 1602 } 1603 1604 private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId, 1605 ContactMatcher matcher) { 1606 mSelectionArgs2[0] = String.valueOf(rawContactId); 1607 mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter(); 1608 Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS, 1609 PhoneLookupQuery.SELECTION, 1610 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING); 1611 try { 1612 while (c.moveToNext()) { 1613 long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID); 1614 matcher.updateScoreWithPhoneNumberMatch(contactId); 1615 } 1616 } finally { 1617 c.close(); 1618 } 1619 } 1620 1621 /** 1622 * Loads name lookup rows for approximate name matching and updates match scores based on that 1623 * data. 1624 */ 1625 private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates, 1626 ContactMatcher matcher) { 1627 HashSet<String> firstLetters = new HashSet<String>(); 1628 for (int i = 0; i < candidates.mCount; i++) { 1629 final NameMatchCandidate candidate = candidates.mList.get(i); 1630 if (candidate.mName.length() >= 2) { 1631 String firstLetter = candidate.mName.substring(0, 2); 1632 if (!firstLetters.contains(firstLetter)) { 1633 firstLetters.add(firstLetter); 1634 final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '" 1635 + firstLetter + "*') AND " 1636 + "(" + NameLookupColumns.NAME_TYPE + " IN(" 1637 + NameLookupType.NAME_COLLATION_KEY + "," 1638 + NameLookupType.EMAIL_BASED_NICKNAME + "," 1639 + NameLookupType.NICKNAME + ")) AND " 1640 + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1641 matchAllCandidates(db, selection, candidates, matcher, 1642 ContactMatcher.MATCHING_ALGORITHM_APPROXIMATE, 1643 String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT)); 1644 } 1645 } 1646 } 1647 } 1648 1649 private interface ContactNameLookupQuery { 1650 String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS; 1651 1652 String[] COLUMNS = new String[] { 1653 RawContacts.CONTACT_ID, 1654 NameLookupColumns.NORMALIZED_NAME, 1655 NameLookupColumns.NAME_TYPE 1656 }; 1657 1658 int CONTACT_ID = 0; 1659 int NORMALIZED_NAME = 1; 1660 int NAME_TYPE = 2; 1661 } 1662 1663 /** 1664 * Loads all candidate rows from the name lookup table and updates match scores based 1665 * on that data. 1666 */ 1667 private void matchAllCandidates(SQLiteDatabase db, String selection, 1668 MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit) { 1669 final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS, 1670 selection, null, null, null, null, limit); 1671 1672 try { 1673 while (c.moveToNext()) { 1674 Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID); 1675 String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME); 1676 int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE); 1677 1678 // Note the N^2 complexity of the following fragment. This is not a huge concern 1679 // since the number of candidates is very small and in general secondary hits 1680 // in the absence of primary hits are rare. 1681 for (int i = 0; i < candidates.mCount; i++) { 1682 NameMatchCandidate candidate = candidates.mList.get(i); 1683 matcher.matchName(contactId, candidate.mLookupType, candidate.mName, 1684 nameType, name, algorithm); 1685 } 1686 } 1687 } finally { 1688 c.close(); 1689 } 1690 } 1691 1692 private interface RawContactsQuery { 1693 String SQL_FORMAT = 1694 "SELECT " 1695 + RawContactsColumns.CONCRETE_ID + "," 1696 + RawContactsColumns.DISPLAY_NAME + "," 1697 + RawContactsColumns.DISPLAY_NAME_SOURCE + "," 1698 + AccountsColumns.CONCRETE_ACCOUNT_TYPE + "," 1699 + AccountsColumns.CONCRETE_ACCOUNT_NAME + "," 1700 + AccountsColumns.CONCRETE_DATA_SET + "," 1701 + RawContacts.SOURCE_ID + "," 1702 + RawContacts.CUSTOM_RINGTONE + "," 1703 + RawContacts.SEND_TO_VOICEMAIL + "," 1704 + RawContacts.LAST_TIME_CONTACTED + "," 1705 + RawContacts.TIMES_CONTACTED + "," 1706 + RawContacts.STARRED + "," 1707 + RawContacts.NAME_VERIFIED + "," 1708 + DataColumns.CONCRETE_ID + "," 1709 + DataColumns.CONCRETE_MIMETYPE_ID + "," 1710 + Data.IS_SUPER_PRIMARY + "," 1711 + Photo.PHOTO_FILE_ID + 1712 " FROM " + Tables.RAW_CONTACTS + 1713 " JOIN " + Tables.ACCOUNTS + " ON (" 1714 + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID 1715 + ")" + 1716 " LEFT OUTER JOIN " + Tables.DATA + 1717 " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID 1718 + " AND ((" + DataColumns.MIMETYPE_ID + "=%d" 1719 + " AND " + Photo.PHOTO + " NOT NULL)" 1720 + " OR (" + DataColumns.MIMETYPE_ID + "=%d" 1721 + " AND " + Phone.NUMBER + " NOT NULL)))"; 1722 1723 String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT + 1724 " WHERE " + RawContactsColumns.CONCRETE_ID + "=?"; 1725 1726 String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT + 1727 " WHERE " + RawContacts.CONTACT_ID + "=?" 1728 + " AND " + RawContacts.DELETED + "=0"; 1729 1730 int RAW_CONTACT_ID = 0; 1731 int DISPLAY_NAME = 1; 1732 int DISPLAY_NAME_SOURCE = 2; 1733 int ACCOUNT_TYPE = 3; 1734 int ACCOUNT_NAME = 4; 1735 int DATA_SET = 5; 1736 int SOURCE_ID = 6; 1737 int CUSTOM_RINGTONE = 7; 1738 int SEND_TO_VOICEMAIL = 8; 1739 int LAST_TIME_CONTACTED = 9; 1740 int TIMES_CONTACTED = 10; 1741 int STARRED = 11; 1742 int NAME_VERIFIED = 12; 1743 int DATA_ID = 13; 1744 int MIMETYPE_ID = 14; 1745 int IS_SUPER_PRIMARY = 15; 1746 int PHOTO_FILE_ID = 16; 1747 } 1748 1749 private interface ContactReplaceSqlStatement { 1750 String UPDATE_SQL = 1751 "UPDATE " + Tables.CONTACTS + 1752 " SET " 1753 + Contacts.NAME_RAW_CONTACT_ID + "=?, " 1754 + Contacts.PHOTO_ID + "=?, " 1755 + Contacts.PHOTO_FILE_ID + "=?, " 1756 + Contacts.SEND_TO_VOICEMAIL + "=?, " 1757 + Contacts.CUSTOM_RINGTONE + "=?, " 1758 + Contacts.LAST_TIME_CONTACTED + "=?, " 1759 + Contacts.TIMES_CONTACTED + "=?, " 1760 + Contacts.STARRED + "=?, " 1761 + Contacts.HAS_PHONE_NUMBER + "=?, " 1762 + Contacts.LOOKUP_KEY + "=? " + 1763 " WHERE " + Contacts._ID + "=?"; 1764 1765 String INSERT_SQL = 1766 "INSERT INTO " + Tables.CONTACTS + " (" 1767 + Contacts.NAME_RAW_CONTACT_ID + ", " 1768 + Contacts.PHOTO_ID + ", " 1769 + Contacts.PHOTO_FILE_ID + ", " 1770 + Contacts.SEND_TO_VOICEMAIL + ", " 1771 + Contacts.CUSTOM_RINGTONE + ", " 1772 + Contacts.LAST_TIME_CONTACTED + ", " 1773 + Contacts.TIMES_CONTACTED + ", " 1774 + Contacts.STARRED + ", " 1775 + Contacts.HAS_PHONE_NUMBER + ", " 1776 + Contacts.LOOKUP_KEY + ") " + 1777 " VALUES (?,?,?,?,?,?,?,?,?,?)"; 1778 1779 int NAME_RAW_CONTACT_ID = 1; 1780 int PHOTO_ID = 2; 1781 int PHOTO_FILE_ID = 3; 1782 int SEND_TO_VOICEMAIL = 4; 1783 int CUSTOM_RINGTONE = 5; 1784 int LAST_TIME_CONTACTED = 6; 1785 int TIMES_CONTACTED = 7; 1786 int STARRED = 8; 1787 int HAS_PHONE_NUMBER = 9; 1788 int LOOKUP_KEY = 10; 1789 int CONTACT_ID = 11; 1790 } 1791 1792 /** 1793 * Computes aggregate-level data for the specified aggregate contact ID. 1794 */ 1795 private void computeAggregateData(SQLiteDatabase db, long contactId, 1796 SQLiteStatement statement) { 1797 mSelectionArgs1[0] = String.valueOf(contactId); 1798 computeAggregateData(db, mRawContactsQueryByContactId, mSelectionArgs1, statement); 1799 } 1800 1801 /** 1802 * Indicates whether the given photo entry and priority gives this photo a higher overall 1803 * priority than the current best photo entry and priority. 1804 */ 1805 private boolean hasHigherPhotoPriority(PhotoEntry photoEntry, int priority, 1806 PhotoEntry bestPhotoEntry, int bestPriority) { 1807 int photoComparison = photoEntry.compareTo(bestPhotoEntry); 1808 return photoComparison < 0 || photoComparison == 0 && priority > bestPriority; 1809 } 1810 1811 /** 1812 * Computes aggregate-level data from constituent raw contacts. 1813 */ 1814 private void computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs, 1815 SQLiteStatement statement) { 1816 long currentRawContactId = -1; 1817 long bestPhotoId = -1; 1818 long bestPhotoFileId = 0; 1819 PhotoEntry bestPhotoEntry = null; 1820 boolean foundSuperPrimaryPhoto = false; 1821 int photoPriority = -1; 1822 int totalRowCount = 0; 1823 int contactSendToVoicemail = 0; 1824 String contactCustomRingtone = null; 1825 long contactLastTimeContacted = 0; 1826 int contactTimesContacted = 0; 1827 int contactStarred = 0; 1828 int hasPhoneNumber = 0; 1829 StringBuilder lookupKey = new StringBuilder(); 1830 1831 mDisplayNameCandidate.clear(); 1832 1833 Cursor c = db.rawQuery(sql, sqlArgs); 1834 try { 1835 while (c.moveToNext()) { 1836 long rawContactId = c.getLong(RawContactsQuery.RAW_CONTACT_ID); 1837 if (rawContactId != currentRawContactId) { 1838 currentRawContactId = rawContactId; 1839 totalRowCount++; 1840 1841 // Assemble sub-account. 1842 String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE); 1843 String dataSet = c.getString(RawContactsQuery.DATA_SET); 1844 String accountWithDataSet = (!TextUtils.isEmpty(dataSet)) 1845 ? accountType + "/" + dataSet 1846 : accountType; 1847 1848 // Display name 1849 String displayName = c.getString(RawContactsQuery.DISPLAY_NAME); 1850 int displayNameSource = c.getInt(RawContactsQuery.DISPLAY_NAME_SOURCE); 1851 int nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED); 1852 processDisplayNameCandidate(rawContactId, displayName, displayNameSource, 1853 mContactsProvider.isWritableAccountWithDataSet(accountWithDataSet), 1854 nameVerified != 0); 1855 1856 // Contact options 1857 if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) { 1858 boolean sendToVoicemail = 1859 (c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0); 1860 if (sendToVoicemail) { 1861 contactSendToVoicemail++; 1862 } 1863 } 1864 1865 if (contactCustomRingtone == null 1866 && !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) { 1867 contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE); 1868 } 1869 1870 long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED); 1871 if (lastTimeContacted > contactLastTimeContacted) { 1872 contactLastTimeContacted = lastTimeContacted; 1873 } 1874 1875 int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED); 1876 if (timesContacted > contactTimesContacted) { 1877 contactTimesContacted = timesContacted; 1878 } 1879 1880 if (c.getInt(RawContactsQuery.STARRED) != 0) { 1881 contactStarred = 1; 1882 } 1883 1884 appendLookupKey( 1885 lookupKey, 1886 accountWithDataSet, 1887 c.getString(RawContactsQuery.ACCOUNT_NAME), 1888 rawContactId, 1889 c.getString(RawContactsQuery.SOURCE_ID), 1890 displayName); 1891 } 1892 1893 if (!c.isNull(RawContactsQuery.DATA_ID)) { 1894 long dataId = c.getLong(RawContactsQuery.DATA_ID); 1895 long photoFileId = c.getLong(RawContactsQuery.PHOTO_FILE_ID); 1896 int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID); 1897 boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0; 1898 if (mimetypeId == mMimeTypeIdPhoto) { 1899 if (!foundSuperPrimaryPhoto) { 1900 // Lookup the metadata for the photo, if available. Note that data set 1901 // does not come into play here, since accounts are looked up in the 1902 // account manager in the priority resolver. 1903 PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId); 1904 String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE); 1905 int priority = mPhotoPriorityResolver.getPhotoPriority(accountType); 1906 if (superPrimary || hasHigherPhotoPriority( 1907 photoEntry, priority, bestPhotoEntry, photoPriority)) { 1908 bestPhotoEntry = photoEntry; 1909 photoPriority = priority; 1910 bestPhotoId = dataId; 1911 bestPhotoFileId = photoFileId; 1912 foundSuperPrimaryPhoto |= superPrimary; 1913 } 1914 } 1915 } else if (mimetypeId == mMimeTypeIdPhone) { 1916 hasPhoneNumber = 1; 1917 } 1918 } 1919 } 1920 } finally { 1921 c.close(); 1922 } 1923 1924 statement.bindLong(ContactReplaceSqlStatement.NAME_RAW_CONTACT_ID, 1925 mDisplayNameCandidate.rawContactId); 1926 1927 if (bestPhotoId != -1) { 1928 statement.bindLong(ContactReplaceSqlStatement.PHOTO_ID, bestPhotoId); 1929 } else { 1930 statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID); 1931 } 1932 1933 if (bestPhotoFileId != 0) { 1934 statement.bindLong(ContactReplaceSqlStatement.PHOTO_FILE_ID, bestPhotoFileId); 1935 } else { 1936 statement.bindNull(ContactReplaceSqlStatement.PHOTO_FILE_ID); 1937 } 1938 1939 statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL, 1940 totalRowCount == contactSendToVoicemail ? 1 : 0); 1941 DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE, 1942 contactCustomRingtone); 1943 statement.bindLong(ContactReplaceSqlStatement.LAST_TIME_CONTACTED, 1944 contactLastTimeContacted); 1945 statement.bindLong(ContactReplaceSqlStatement.TIMES_CONTACTED, 1946 contactTimesContacted); 1947 statement.bindLong(ContactReplaceSqlStatement.STARRED, 1948 contactStarred); 1949 statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER, 1950 hasPhoneNumber); 1951 statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY, 1952 Uri.encode(lookupKey.toString())); 1953 } 1954 1955 /** 1956 * Builds a lookup key using the given data. 1957 */ 1958 protected void appendLookupKey(StringBuilder sb, String accountTypeWithDataSet, 1959 String accountName, long rawContactId, String sourceId, String displayName) { 1960 ContactLookupKey.appendToLookupKey(sb, accountTypeWithDataSet, accountName, rawContactId, 1961 sourceId, displayName); 1962 } 1963 1964 /** 1965 * Uses the supplied values to determine if they represent a "better" display name 1966 * for the aggregate contact currently evaluated. If so, it updates 1967 * {@link #mDisplayNameCandidate} with the new values. 1968 */ 1969 private void processDisplayNameCandidate(long rawContactId, String displayName, 1970 int displayNameSource, boolean writableAccount, boolean verified) { 1971 1972 boolean replace = false; 1973 if (mDisplayNameCandidate.rawContactId == -1) { 1974 // No previous values available 1975 replace = true; 1976 } else if (!TextUtils.isEmpty(displayName)) { 1977 if (!mDisplayNameCandidate.verified && verified) { 1978 // A verified name is better than any other name 1979 replace = true; 1980 } else if (mDisplayNameCandidate.verified == verified) { 1981 if (mDisplayNameCandidate.displayNameSource < displayNameSource) { 1982 // New values come from an superior source, e.g. structured name vs phone number 1983 replace = true; 1984 } else if (mDisplayNameCandidate.displayNameSource == displayNameSource) { 1985 if (!mDisplayNameCandidate.writableAccount && writableAccount) { 1986 replace = true; 1987 } else if (mDisplayNameCandidate.writableAccount == writableAccount) { 1988 if (NameNormalizer.compareComplexity(displayName, 1989 mDisplayNameCandidate.displayName) > 0) { 1990 // New name is more complex than the previously found one 1991 replace = true; 1992 } 1993 } 1994 } 1995 } 1996 } 1997 1998 if (replace) { 1999 mDisplayNameCandidate.rawContactId = rawContactId; 2000 mDisplayNameCandidate.displayName = displayName; 2001 mDisplayNameCandidate.displayNameSource = displayNameSource; 2002 mDisplayNameCandidate.verified = verified; 2003 mDisplayNameCandidate.writableAccount = writableAccount; 2004 } 2005 } 2006 2007 private interface PhotoIdQuery { 2008 final String[] COLUMNS = new String[] { 2009 AccountsColumns.CONCRETE_ACCOUNT_TYPE, 2010 DataColumns.CONCRETE_ID, 2011 Data.IS_SUPER_PRIMARY, 2012 Photo.PHOTO_FILE_ID, 2013 }; 2014 2015 int ACCOUNT_TYPE = 0; 2016 int DATA_ID = 1; 2017 int IS_SUPER_PRIMARY = 2; 2018 int PHOTO_FILE_ID = 3; 2019 } 2020 2021 public void updatePhotoId(SQLiteDatabase db, long rawContactId) { 2022 2023 long contactId = mDbHelper.getContactId(rawContactId); 2024 if (contactId == 0) { 2025 return; 2026 } 2027 2028 long bestPhotoId = -1; 2029 long bestPhotoFileId = 0; 2030 int photoPriority = -1; 2031 2032 long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE); 2033 2034 String tables = Tables.RAW_CONTACTS 2035 + " JOIN " + Tables.ACCOUNTS + " ON (" 2036 + AccountsColumns.CONCRETE_ID + "=" + RawContactsColumns.CONCRETE_ACCOUNT_ID 2037 + ")" 2038 + " JOIN " + Tables.DATA + " ON(" 2039 + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID 2040 + " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND " 2041 + Photo.PHOTO + " NOT NULL))"; 2042 2043 mSelectionArgs1[0] = String.valueOf(contactId); 2044 final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS, 2045 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null); 2046 try { 2047 PhotoEntry bestPhotoEntry = null; 2048 while (c.moveToNext()) { 2049 long dataId = c.getLong(PhotoIdQuery.DATA_ID); 2050 long photoFileId = c.getLong(PhotoIdQuery.PHOTO_FILE_ID); 2051 boolean superPrimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0; 2052 PhotoEntry photoEntry = getPhotoMetadata(db, photoFileId); 2053 2054 // Note that data set does not come into play here, since accounts are looked up in 2055 // the account manager in the priority resolver. 2056 String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE); 2057 int priority = mPhotoPriorityResolver.getPhotoPriority(accountType); 2058 if (superPrimary || hasHigherPhotoPriority( 2059 photoEntry, priority, bestPhotoEntry, photoPriority)) { 2060 bestPhotoEntry = photoEntry; 2061 photoPriority = priority; 2062 bestPhotoId = dataId; 2063 bestPhotoFileId = photoFileId; 2064 if (superPrimary) { 2065 break; 2066 } 2067 } 2068 } 2069 } finally { 2070 c.close(); 2071 } 2072 2073 if (bestPhotoId == -1) { 2074 mPhotoIdUpdate.bindNull(1); 2075 } else { 2076 mPhotoIdUpdate.bindLong(1, bestPhotoId); 2077 } 2078 2079 if (bestPhotoFileId == 0) { 2080 mPhotoIdUpdate.bindNull(2); 2081 } else { 2082 mPhotoIdUpdate.bindLong(2, bestPhotoFileId); 2083 } 2084 2085 mPhotoIdUpdate.bindLong(3, contactId); 2086 mPhotoIdUpdate.execute(); 2087 } 2088 2089 private interface PhotoFileQuery { 2090 final String[] COLUMNS = new String[] { 2091 PhotoFiles.HEIGHT, 2092 PhotoFiles.WIDTH, 2093 PhotoFiles.FILESIZE 2094 }; 2095 2096 int HEIGHT = 0; 2097 int WIDTH = 1; 2098 int FILESIZE = 2; 2099 } 2100 2101 private class PhotoEntry implements Comparable<PhotoEntry> { 2102 // Pixel count (width * height) for the image. 2103 final int pixelCount; 2104 2105 // File size (in bytes) of the image. Not populated if the image is a thumbnail. 2106 final int fileSize; 2107 2108 private PhotoEntry(int pixelCount, int fileSize) { 2109 this.pixelCount = pixelCount; 2110 this.fileSize = fileSize; 2111 } 2112 2113 @Override 2114 public int compareTo(PhotoEntry pe) { 2115 if (pe == null) { 2116 return -1; 2117 } 2118 if (pixelCount == pe.pixelCount) { 2119 return pe.fileSize - fileSize; 2120 } else { 2121 return pe.pixelCount - pixelCount; 2122 } 2123 } 2124 } 2125 2126 private PhotoEntry getPhotoMetadata(SQLiteDatabase db, long photoFileId) { 2127 if (photoFileId == 0) { 2128 // Assume standard thumbnail size. Don't bother getting a file size for priority; 2129 // we should fall back to photo priority resolver if all we have are thumbnails. 2130 int thumbDim = mContactsProvider.getMaxThumbnailDim(); 2131 return new PhotoEntry(thumbDim * thumbDim, 0); 2132 } else { 2133 Cursor c = db.query(Tables.PHOTO_FILES, PhotoFileQuery.COLUMNS, PhotoFiles._ID + "=?", 2134 new String[]{String.valueOf(photoFileId)}, null, null, null); 2135 try { 2136 if (c.getCount() == 1) { 2137 c.moveToFirst(); 2138 int pixelCount = 2139 c.getInt(PhotoFileQuery.HEIGHT) * c.getInt(PhotoFileQuery.WIDTH); 2140 return new PhotoEntry(pixelCount, c.getInt(PhotoFileQuery.FILESIZE)); 2141 } 2142 } finally { 2143 c.close(); 2144 } 2145 } 2146 return new PhotoEntry(0, 0); 2147 } 2148 2149 private interface DisplayNameQuery { 2150 String[] COLUMNS = new String[] { 2151 RawContacts._ID, 2152 RawContactsColumns.DISPLAY_NAME, 2153 RawContactsColumns.DISPLAY_NAME_SOURCE, 2154 RawContacts.NAME_VERIFIED, 2155 RawContacts.SOURCE_ID, 2156 RawContacts.ACCOUNT_TYPE_AND_DATA_SET, 2157 }; 2158 2159 int _ID = 0; 2160 int DISPLAY_NAME = 1; 2161 int DISPLAY_NAME_SOURCE = 2; 2162 int NAME_VERIFIED = 3; 2163 int SOURCE_ID = 4; 2164 int ACCOUNT_TYPE_AND_DATA_SET = 5; 2165 } 2166 2167 public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) { 2168 long contactId = mDbHelper.getContactId(rawContactId); 2169 if (contactId == 0) { 2170 return; 2171 } 2172 2173 updateDisplayNameForContact(db, contactId); 2174 } 2175 2176 public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) { 2177 boolean lookupKeyUpdateNeeded = false; 2178 2179 mDisplayNameCandidate.clear(); 2180 2181 mSelectionArgs1[0] = String.valueOf(contactId); 2182 final Cursor c = db.query(Views.RAW_CONTACTS, DisplayNameQuery.COLUMNS, 2183 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null); 2184 try { 2185 while (c.moveToNext()) { 2186 long rawContactId = c.getLong(DisplayNameQuery._ID); 2187 String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME); 2188 int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE); 2189 int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED); 2190 String accountTypeAndDataSet = c.getString( 2191 DisplayNameQuery.ACCOUNT_TYPE_AND_DATA_SET); 2192 processDisplayNameCandidate(rawContactId, displayName, displayNameSource, 2193 mContactsProvider.isWritableAccountWithDataSet(accountTypeAndDataSet), 2194 nameVerified != 0); 2195 2196 // If the raw contact has no source id, the lookup key is based on the display 2197 // name, so the lookup key needs to be updated. 2198 lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID); 2199 } 2200 } finally { 2201 c.close(); 2202 } 2203 2204 if (mDisplayNameCandidate.rawContactId != -1) { 2205 mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId); 2206 mDisplayNameUpdate.bindLong(2, contactId); 2207 mDisplayNameUpdate.execute(); 2208 } 2209 2210 if (lookupKeyUpdateNeeded) { 2211 updateLookupKeyForContact(db, contactId); 2212 } 2213 } 2214 2215 2216 /** 2217 * Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the 2218 * specified raw contact. 2219 */ 2220 public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) { 2221 2222 long contactId = mDbHelper.getContactId(rawContactId); 2223 if (contactId == 0) { 2224 return; 2225 } 2226 2227 final SQLiteStatement hasPhoneNumberUpdate = db.compileStatement( 2228 "UPDATE " + Tables.CONTACTS + 2229 " SET " + Contacts.HAS_PHONE_NUMBER + "=" 2230 + "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)" 2231 + " FROM " + Tables.DATA_JOIN_RAW_CONTACTS 2232 + " WHERE " + DataColumns.MIMETYPE_ID + "=?" 2233 + " AND " + Phone.NUMBER + " NOT NULL" 2234 + " AND " + RawContacts.CONTACT_ID + "=?)" + 2235 " WHERE " + Contacts._ID + "=?"); 2236 try { 2237 hasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE)); 2238 hasPhoneNumberUpdate.bindLong(2, contactId); 2239 hasPhoneNumberUpdate.bindLong(3, contactId); 2240 hasPhoneNumberUpdate.execute(); 2241 } finally { 2242 hasPhoneNumberUpdate.close(); 2243 } 2244 } 2245 2246 private interface LookupKeyQuery { 2247 String TABLE = Views.RAW_CONTACTS; 2248 String[] COLUMNS = new String[] { 2249 RawContacts._ID, 2250 RawContactsColumns.DISPLAY_NAME, 2251 RawContacts.ACCOUNT_TYPE_AND_DATA_SET, 2252 RawContacts.ACCOUNT_NAME, 2253 RawContacts.SOURCE_ID, 2254 }; 2255 2256 int ID = 0; 2257 int DISPLAY_NAME = 1; 2258 int ACCOUNT_TYPE_AND_DATA_SET = 2; 2259 int ACCOUNT_NAME = 3; 2260 int SOURCE_ID = 4; 2261 } 2262 2263 public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) { 2264 long contactId = mDbHelper.getContactId(rawContactId); 2265 if (contactId == 0) { 2266 return; 2267 } 2268 2269 updateLookupKeyForContact(db, contactId); 2270 } 2271 2272 private void updateLookupKeyForContact(SQLiteDatabase db, long contactId) { 2273 String lookupKey = computeLookupKeyForContact(db, contactId); 2274 2275 if (lookupKey == null) { 2276 mLookupKeyUpdate.bindNull(1); 2277 } else { 2278 mLookupKeyUpdate.bindString(1, Uri.encode(lookupKey)); 2279 } 2280 mLookupKeyUpdate.bindLong(2, contactId); 2281 2282 mLookupKeyUpdate.execute(); 2283 } 2284 2285 protected String computeLookupKeyForContact(SQLiteDatabase db, long contactId) { 2286 StringBuilder sb = new StringBuilder(); 2287 mSelectionArgs1[0] = String.valueOf(contactId); 2288 final Cursor c = db.query(LookupKeyQuery.TABLE, LookupKeyQuery.COLUMNS, 2289 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID); 2290 try { 2291 while (c.moveToNext()) { 2292 ContactLookupKey.appendToLookupKey(sb, 2293 c.getString(LookupKeyQuery.ACCOUNT_TYPE_AND_DATA_SET), 2294 c.getString(LookupKeyQuery.ACCOUNT_NAME), 2295 c.getLong(LookupKeyQuery.ID), 2296 c.getString(LookupKeyQuery.SOURCE_ID), 2297 c.getString(LookupKeyQuery.DISPLAY_NAME)); 2298 } 2299 } finally { 2300 c.close(); 2301 } 2302 return sb.length() == 0 ? null : sb.toString(); 2303 } 2304 2305 /** 2306 * Execute {@link SQLiteStatement} that will update the 2307 * {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}. 2308 */ 2309 public void updateStarred(long rawContactId) { 2310 long contactId = mDbHelper.getContactId(rawContactId); 2311 if (contactId == 0) { 2312 return; 2313 } 2314 2315 mStarredUpdate.bindLong(1, contactId); 2316 mStarredUpdate.execute(); 2317 } 2318 2319 /** 2320 * Finds matching contacts and returns a cursor on those. 2321 */ 2322 public Cursor queryAggregationSuggestions(SQLiteQueryBuilder qb, 2323 String[] projection, long contactId, int maxSuggestions, String filter, 2324 ArrayList<AggregationSuggestionParameter> parameters) { 2325 final SQLiteDatabase db = mDbHelper.getReadableDatabase(); 2326 db.beginTransaction(); 2327 try { 2328 List<MatchScore> bestMatches = findMatchingContacts(db, contactId, parameters); 2329 return queryMatchingContacts(qb, db, projection, bestMatches, maxSuggestions, filter); 2330 } finally { 2331 db.endTransaction(); 2332 } 2333 } 2334 2335 private interface ContactIdQuery { 2336 String[] COLUMNS = new String[] { 2337 Contacts._ID 2338 }; 2339 2340 int _ID = 0; 2341 } 2342 2343 /** 2344 * Loads contacts with specified IDs and returns them in the order of IDs in the 2345 * supplied list. 2346 */ 2347 private Cursor queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db, 2348 String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter) { 2349 StringBuilder sb = new StringBuilder(); 2350 sb.append(Contacts._ID); 2351 sb.append(" IN ("); 2352 for (int i = 0; i < bestMatches.size(); i++) { 2353 MatchScore matchScore = bestMatches.get(i); 2354 if (i != 0) { 2355 sb.append(","); 2356 } 2357 sb.append(matchScore.getContactId()); 2358 } 2359 sb.append(")"); 2360 2361 if (!TextUtils.isEmpty(filter)) { 2362 sb.append(" AND " + Contacts._ID + " IN "); 2363 mContactsProvider.appendContactFilterAsNestedQuery(sb, filter); 2364 } 2365 2366 // Run a query and find ids of best matching contacts satisfying the filter (if any) 2367 HashSet<Long> foundIds = new HashSet<Long>(); 2368 Cursor cursor = db.query(qb.getTables(), ContactIdQuery.COLUMNS, sb.toString(), 2369 null, null, null, null); 2370 try { 2371 while(cursor.moveToNext()) { 2372 foundIds.add(cursor.getLong(ContactIdQuery._ID)); 2373 } 2374 } finally { 2375 cursor.close(); 2376 } 2377 2378 // Exclude all contacts that did not match the filter 2379 Iterator<MatchScore> iter = bestMatches.iterator(); 2380 while (iter.hasNext()) { 2381 long id = iter.next().getContactId(); 2382 if (!foundIds.contains(id)) { 2383 iter.remove(); 2384 } 2385 } 2386 2387 // Limit the number of returned suggestions 2388 final List<MatchScore> limitedMatches; 2389 if (bestMatches.size() > maxSuggestions) { 2390 limitedMatches = bestMatches.subList(0, maxSuggestions); 2391 } else { 2392 limitedMatches = bestMatches; 2393 } 2394 2395 // Build an in-clause with the remaining contact IDs 2396 sb.setLength(0); 2397 sb.append(Contacts._ID); 2398 sb.append(" IN ("); 2399 for (int i = 0; i < limitedMatches.size(); i++) { 2400 MatchScore matchScore = limitedMatches.get(i); 2401 if (i != 0) { 2402 sb.append(","); 2403 } 2404 sb.append(matchScore.getContactId()); 2405 } 2406 sb.append(")"); 2407 2408 // Run the final query with the required projection and contact IDs found by the first query 2409 cursor = qb.query(db, projection, sb.toString(), null, null, null, Contacts._ID); 2410 2411 // Build a sorted list of discovered IDs 2412 ArrayList<Long> sortedContactIds = new ArrayList<Long>(limitedMatches.size()); 2413 for (MatchScore matchScore : limitedMatches) { 2414 sortedContactIds.add(matchScore.getContactId()); 2415 } 2416 2417 Collections.sort(sortedContactIds); 2418 2419 // Map cursor indexes according to the descending order of match scores 2420 int[] positionMap = new int[limitedMatches.size()]; 2421 for (int i = 0; i < positionMap.length; i++) { 2422 long id = limitedMatches.get(i).getContactId(); 2423 positionMap[i] = sortedContactIds.indexOf(id); 2424 } 2425 2426 return new ReorderingCursorWrapper(cursor, positionMap); 2427 } 2428 2429 /** 2430 * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the 2431 * descending order of match score. 2432 * @param parameters 2433 */ 2434 private List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId, 2435 ArrayList<AggregationSuggestionParameter> parameters) { 2436 2437 MatchCandidateList candidates = new MatchCandidateList(); 2438 ContactMatcher matcher = new ContactMatcher(); 2439 2440 // Don't aggregate a contact with itself 2441 matcher.keepOut(contactId); 2442 2443 if (parameters == null || parameters.size() == 0) { 2444 final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS, 2445 RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null); 2446 try { 2447 while (c.moveToNext()) { 2448 long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID); 2449 updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates, 2450 matcher); 2451 } 2452 } finally { 2453 c.close(); 2454 } 2455 } else { 2456 updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates, 2457 matcher, parameters); 2458 } 2459 2460 return matcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SUGGEST); 2461 } 2462 2463 /** 2464 * Computes scores for contacts that have matching data rows. 2465 */ 2466 private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, 2467 long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) { 2468 2469 updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher); 2470 updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher); 2471 updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher); 2472 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher); 2473 loadNameMatchCandidates(db, rawContactId, candidates, false); 2474 lookupApproximateNameMatches(db, candidates, matcher); 2475 } 2476 2477 private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, 2478 MatchCandidateList candidates, ContactMatcher matcher, 2479 ArrayList<AggregationSuggestionParameter> parameters) { 2480 for (AggregationSuggestionParameter parameter : parameters) { 2481 if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) { 2482 updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher); 2483 } 2484 2485 // TODO: add support for other parameter kinds 2486 } 2487 } 2488} 2489