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