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