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