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