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 17 package com.android.providers.contacts; 18 19 import com.android.providers.contacts.ContactMatcher.MatchScore; 20 import com.android.providers.contacts.ContactsDatabaseHelper.AggregatedPresenceColumns; 21 import com.android.providers.contacts.ContactsDatabaseHelper.ContactsColumns; 22 import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns; 23 import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns; 24 import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType; 25 import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns; 26 import com.android.providers.contacts.ContactsDatabaseHelper.PresenceColumns; 27 import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns; 28 import com.android.providers.contacts.ContactsDatabaseHelper.Tables; 29 30 import android.content.ContentValues; 31 import android.database.Cursor; 32 import android.database.DatabaseUtils; 33 import android.database.sqlite.SQLiteDatabase; 34 import android.database.sqlite.SQLiteQueryBuilder; 35 import android.database.sqlite.SQLiteStatement; 36 import android.net.Uri; 37 import android.provider.ContactsContract.AggregationExceptions; 38 import android.provider.ContactsContract.Contacts; 39 import android.provider.ContactsContract.Data; 40 import android.provider.ContactsContract.DisplayNameSources; 41 import android.provider.ContactsContract.RawContacts; 42 import android.provider.ContactsContract.StatusUpdates; 43 import android.provider.ContactsContract.CommonDataKinds.Email; 44 import android.provider.ContactsContract.CommonDataKinds.Phone; 45 import android.provider.ContactsContract.CommonDataKinds.Photo; 46 import android.text.TextUtils; 47 import android.util.EventLog; 48 import android.util.Log; 49 50 import java.util.ArrayList; 51 import java.util.Collections; 52 import java.util.HashMap; 53 import java.util.HashSet; 54 import java.util.Iterator; 55 import java.util.List; 56 57 58 /** 59 * ContactAggregator deals with aggregating contact information coming from different sources. 60 * Two John Doe contacts from two disjoint sources are presumed to be the same 61 * person unless the user declares otherwise. 62 */ 63 public class ContactAggregator { 64 65 private static final String TAG = "ContactAggregator"; 66 67 private static final boolean VERBOSE_LOGGING = Log.isLoggable(TAG, Log.VERBOSE); 68 69 private static final String STRUCTURED_NAME_BASED_LOOKUP_SQL = 70 NameLookupColumns.NAME_TYPE + " IN (" 71 + NameLookupType.NAME_EXACT + "," 72 + NameLookupType.NAME_VARIANT + "," 73 + NameLookupType.NAME_COLLATION_KEY + ")"; 74 75 // From system/core/logcat/event-log-tags 76 // aggregator [time, count] will be logged for each aggregator cycle. 77 // For the query (as opposed to the merge), count will be negative 78 public static final int LOG_SYNC_CONTACTS_AGGREGATION = 2747; 79 80 // If we encounter more than this many contacts with matching names, aggregate only this many 81 private static final int PRIMARY_HIT_LIMIT = 15; 82 private static final String PRIMARY_HIT_LIMIT_STRING = String.valueOf(PRIMARY_HIT_LIMIT); 83 84 // If we encounter more than this many contacts with matching phone number or email, 85 // don't attempt to aggregate - this is likely an error or a shared corporate data element. 86 private static final int SECONDARY_HIT_LIMIT = 20; 87 private static final String SECONDARY_HIT_LIMIT_STRING = String.valueOf(SECONDARY_HIT_LIMIT); 88 89 // If we encounter more than this many contacts with matching name during aggregation 90 // suggestion lookup, ignore the remaining results. 91 private static final int FIRST_LETTER_SUGGESTION_HIT_LIMIT = 100; 92 93 private final ContactsProvider2 mContactsProvider; 94 private final ContactsDatabaseHelper mDbHelper; 95 private PhotoPriorityResolver mPhotoPriorityResolver; 96 private boolean mEnabled = true; 97 98 /** Precompiled sql statement for setting an aggregated presence */ 99 private SQLiteStatement mAggregatedPresenceReplace; 100 private SQLiteStatement mPresenceContactIdUpdate; 101 private SQLiteStatement mRawContactCountQuery; 102 private SQLiteStatement mContactDelete; 103 private SQLiteStatement mAggregatedPresenceDelete; 104 private SQLiteStatement mMarkForAggregation; 105 private SQLiteStatement mPhotoIdUpdate; 106 private SQLiteStatement mDisplayNameUpdate; 107 private SQLiteStatement mHasPhoneNumberUpdate; 108 private SQLiteStatement mLookupKeyUpdate; 109 private SQLiteStatement mStarredUpdate; 110 private SQLiteStatement mContactIdAndMarkAggregatedUpdate; 111 private SQLiteStatement mContactIdUpdate; 112 private SQLiteStatement mMarkAggregatedUpdate; 113 private SQLiteStatement mContactUpdate; 114 private SQLiteStatement mContactInsert; 115 116 private HashMap<Long, Integer> mRawContactsMarkedForAggregation = new HashMap<Long, Integer>(); 117 118 private String[] mSelectionArgs1 = new String[1]; 119 private String[] mSelectionArgs2 = new String[2]; 120 private String[] mSelectionArgs3 = new String[3]; 121 private long mMimeTypeIdEmail; 122 private long mMimeTypeIdPhoto; 123 private long mMimeTypeIdPhone; 124 private String mRawContactsQueryByRawContactId; 125 private String mRawContactsQueryByContactId; 126 private StringBuilder mSb = new StringBuilder(); 127 private MatchCandidateList mCandidates = new MatchCandidateList(); 128 private ContactMatcher mMatcher = new ContactMatcher(); 129 private ContentValues mValues = new ContentValues(); 130 private DisplayNameCandidate mDisplayNameCandidate = new DisplayNameCandidate(); 131 132 /** 133 * Captures a potential match for a given name. The matching algorithm 134 * constructs a bunch of NameMatchCandidate objects for various potential matches 135 * and then executes the search in bulk. 136 */ 137 private static class NameMatchCandidate { 138 String mName; 139 int mLookupType; 140 141 public NameMatchCandidate(String name, int nameLookupType) { 142 mName = name; 143 mLookupType = nameLookupType; 144 } 145 } 146 147 /** 148 * A list of {@link NameMatchCandidate} that keeps its elements even when the list is 149 * truncated. This is done for optimization purposes to avoid excessive object allocation. 150 */ 151 private static class MatchCandidateList { 152 private final ArrayList<NameMatchCandidate> mList = new ArrayList<NameMatchCandidate>(); 153 private int mCount; 154 155 /** 156 * Adds a {@link NameMatchCandidate} element or updates the next one if it already exists. 157 */ 158 public void add(String name, int nameLookupType) { 159 if (mCount >= mList.size()) { 160 mList.add(new NameMatchCandidate(name, nameLookupType)); 161 } else { 162 NameMatchCandidate candidate = mList.get(mCount); 163 candidate.mName = name; 164 candidate.mLookupType = nameLookupType; 165 } 166 mCount++; 167 } 168 169 public void clear() { 170 mCount = 0; 171 } 172 } 173 174 /** 175 * A convenience class used in the algorithm that figures out which of available 176 * display names to use for an aggregate contact. 177 */ 178 private static class DisplayNameCandidate { 179 long rawContactId; 180 String displayName; 181 int displayNameSource; 182 boolean verified; 183 boolean writableAccount; 184 185 public DisplayNameCandidate() { 186 clear(); 187 } 188 189 public void clear() { 190 rawContactId = -1; 191 displayName = null; 192 displayNameSource = DisplayNameSources.UNDEFINED; 193 verified = false; 194 writableAccount = false; 195 } 196 } 197 198 /** 199 * Constructor. 200 */ 201 public ContactAggregator(ContactsProvider2 contactsProvider, 202 ContactsDatabaseHelper contactsDatabaseHelper, 203 PhotoPriorityResolver photoPriorityResolver) { 204 mContactsProvider = contactsProvider; 205 mDbHelper = contactsDatabaseHelper; 206 mPhotoPriorityResolver = photoPriorityResolver; 207 208 SQLiteDatabase db = mDbHelper.getReadableDatabase(); 209 210 // Since we have no way of determining which custom status was set last, 211 // we'll just pick one randomly. We are using MAX as an approximation of randomness 212 mAggregatedPresenceReplace = db.compileStatement( 213 "INSERT OR REPLACE INTO " + Tables.AGGREGATED_PRESENCE + "(" 214 + AggregatedPresenceColumns.CONTACT_ID + ", " 215 + StatusUpdates.PRESENCE_STATUS 216 + ") SELECT ?, MAX(" + StatusUpdates.PRESENCE_STATUS + ") " 217 + " FROM " + Tables.PRESENCE 218 + " WHERE " + PresenceColumns.CONTACT_ID + "=?"); 219 220 mRawContactCountQuery = db.compileStatement( 221 "SELECT COUNT(" + RawContacts._ID + ")" + 222 " FROM " + Tables.RAW_CONTACTS + 223 " WHERE " + RawContacts.CONTACT_ID + "=?" 224 + " AND " + RawContacts._ID + "<>?"); 225 226 mContactDelete = db.compileStatement( 227 "DELETE FROM " + Tables.CONTACTS + 228 " WHERE " + Contacts._ID + "=?"); 229 230 mAggregatedPresenceDelete = db.compileStatement( 231 "DELETE FROM " + Tables.AGGREGATED_PRESENCE + 232 " WHERE " + AggregatedPresenceColumns.CONTACT_ID + "=?"); 233 234 mMarkForAggregation = db.compileStatement( 235 "UPDATE " + Tables.RAW_CONTACTS + 236 " SET " + RawContactsColumns.AGGREGATION_NEEDED + "=1" + 237 " WHERE " + RawContacts._ID + "=?" 238 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"); 239 240 mPhotoIdUpdate = db.compileStatement( 241 "UPDATE " + Tables.CONTACTS + 242 " SET " + Contacts.PHOTO_ID + "=? " + 243 " WHERE " + Contacts._ID + "=?"); 244 245 mDisplayNameUpdate = db.compileStatement( 246 "UPDATE " + Tables.CONTACTS + 247 " SET " + Contacts.NAME_RAW_CONTACT_ID + "=? " + 248 " WHERE " + Contacts._ID + "=?"); 249 250 mLookupKeyUpdate = db.compileStatement( 251 "UPDATE " + Tables.CONTACTS + 252 " SET " + Contacts.LOOKUP_KEY + "=? " + 253 " WHERE " + Contacts._ID + "=?"); 254 255 mHasPhoneNumberUpdate = db.compileStatement( 256 "UPDATE " + Tables.CONTACTS + 257 " SET " + Contacts.HAS_PHONE_NUMBER + "=" 258 + "(SELECT (CASE WHEN COUNT(*)=0 THEN 0 ELSE 1 END)" 259 + " FROM " + Tables.DATA_JOIN_RAW_CONTACTS 260 + " WHERE " + DataColumns.MIMETYPE_ID + "=?" 261 + " AND " + Phone.NUMBER + " NOT NULL" 262 + " AND " + RawContacts.CONTACT_ID + "=?)" + 263 " WHERE " + Contacts._ID + "=?"); 264 265 mStarredUpdate = db.compileStatement("UPDATE " + Tables.CONTACTS + " SET " 266 + Contacts.STARRED + "=(SELECT (CASE WHEN COUNT(" + RawContacts.STARRED 267 + ")=0 THEN 0 ELSE 1 END) FROM " + Tables.RAW_CONTACTS + " WHERE " 268 + RawContacts.CONTACT_ID + "=" + ContactsColumns.CONCRETE_ID + " AND " 269 + RawContacts.STARRED + "=1)" + " WHERE " + Contacts._ID + "=?"); 270 271 mContactIdAndMarkAggregatedUpdate = db.compileStatement( 272 "UPDATE " + Tables.RAW_CONTACTS + 273 " SET " + RawContacts.CONTACT_ID + "=?, " 274 + RawContactsColumns.AGGREGATION_NEEDED + "=0" + 275 " WHERE " + RawContacts._ID + "=?"); 276 277 mContactIdUpdate = db.compileStatement( 278 "UPDATE " + Tables.RAW_CONTACTS + 279 " SET " + RawContacts.CONTACT_ID + "=?" + 280 " WHERE " + RawContacts._ID + "=?"); 281 282 mMarkAggregatedUpdate = db.compileStatement( 283 "UPDATE " + Tables.RAW_CONTACTS + 284 " SET " + RawContactsColumns.AGGREGATION_NEEDED + "=0" + 285 " WHERE " + RawContacts._ID + "=?"); 286 287 mPresenceContactIdUpdate = db.compileStatement( 288 "UPDATE " + Tables.PRESENCE + 289 " SET " + PresenceColumns.CONTACT_ID + "=?" + 290 " WHERE " + PresenceColumns.RAW_CONTACT_ID + "=?"); 291 292 mContactUpdate = db.compileStatement(ContactReplaceSqlStatement.UPDATE_SQL); 293 mContactInsert = db.compileStatement(ContactReplaceSqlStatement.INSERT_SQL); 294 295 mMimeTypeIdEmail = mDbHelper.getMimeTypeId(Email.CONTENT_ITEM_TYPE); 296 mMimeTypeIdPhoto = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE); 297 mMimeTypeIdPhone = mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE); 298 299 // Query used to retrieve data from raw contacts to populate the corresponding aggregate 300 mRawContactsQueryByRawContactId = String.format( 301 RawContactsQuery.SQL_FORMAT_BY_RAW_CONTACT_ID, 302 mMimeTypeIdPhoto, mMimeTypeIdPhone); 303 304 mRawContactsQueryByContactId = String.format( 305 RawContactsQuery.SQL_FORMAT_BY_CONTACT_ID, 306 mMimeTypeIdPhoto, mMimeTypeIdPhone); 307 } 308 309 public void setEnabled(boolean enabled) { 310 mEnabled = enabled; 311 } 312 313 public boolean isEnabled() { 314 return mEnabled; 315 } 316 317 private interface AggregationQuery { 318 String SQL = 319 "SELECT " + RawContacts._ID + "," + RawContacts.CONTACT_ID + 320 " FROM " + Tables.RAW_CONTACTS + 321 " WHERE " + RawContacts._ID + " IN("; 322 323 int _ID = 0; 324 int CONTACT_ID = 1; 325 } 326 327 /** 328 * Aggregate all raw contacts that were marked for aggregation in the current transaction. 329 * Call just before committing the transaction. 330 */ 331 public void aggregateInTransaction(SQLiteDatabase db) { 332 int count = mRawContactsMarkedForAggregation.size(); 333 if (count == 0) { 334 return; 335 } 336 337 long start = System.currentTimeMillis(); 338 if (VERBOSE_LOGGING) { 339 Log.v(TAG, "Contact aggregation: " + count); 340 } 341 342 EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, start, -count); 343 344 String selectionArgs[] = new String[count]; 345 346 int index = 0; 347 mSb.setLength(0); 348 mSb.append(AggregationQuery.SQL); 349 for (long rawContactId : mRawContactsMarkedForAggregation.keySet()) { 350 if (index > 0) { 351 mSb.append(','); 352 } 353 mSb.append('?'); 354 selectionArgs[index++] = String.valueOf(rawContactId); 355 } 356 357 mSb.append(')'); 358 359 long rawContactIds[] = new long[count]; 360 long contactIds[] = new long[count]; 361 Cursor c = db.rawQuery(mSb.toString(), selectionArgs); 362 try { 363 count = c.getCount(); 364 index = 0; 365 while (c.moveToNext()) { 366 rawContactIds[index] = c.getLong(AggregationQuery._ID); 367 contactIds[index] = c.getLong(AggregationQuery.CONTACT_ID); 368 index++; 369 } 370 } finally { 371 c.close(); 372 } 373 374 for (int i = 0; i < count; i++) { 375 aggregateContact(db, rawContactIds[i], contactIds[i], mCandidates, mMatcher, mValues); 376 } 377 378 long elapsedTime = System.currentTimeMillis() - start; 379 EventLog.writeEvent(LOG_SYNC_CONTACTS_AGGREGATION, elapsedTime, count); 380 381 if (VERBOSE_LOGGING) { 382 String performance = count == 0 ? "" : ", " + (elapsedTime / count) + " ms per contact"; 383 Log.i(TAG, "Contact aggregation complete: " + count + performance); 384 } 385 } 386 387 public void clearPendingAggregations() { 388 mRawContactsMarkedForAggregation.clear(); 389 } 390 391 public void markNewForAggregation(long rawContactId, int aggregationMode) { 392 mRawContactsMarkedForAggregation.put(rawContactId, aggregationMode); 393 } 394 395 public void markForAggregation(long rawContactId, int aggregationMode, boolean force) { 396 if (!force && mRawContactsMarkedForAggregation.containsKey(rawContactId)) { 397 // As per ContactsContract documentation, default aggregation mode 398 // does not override a previously set mode 399 if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) { 400 aggregationMode = mRawContactsMarkedForAggregation.get(rawContactId); 401 } 402 } else { 403 mMarkForAggregation.bindLong(1, rawContactId); 404 mMarkForAggregation.execute(); 405 } 406 407 mRawContactsMarkedForAggregation.put(rawContactId, aggregationMode); 408 } 409 410 /** 411 * Creates a new contact based on the given raw contact. Does not perform aggregation. 412 */ 413 public void onRawContactInsert(SQLiteDatabase db, long rawContactId) { 414 mSelectionArgs1[0] = String.valueOf(rawContactId); 415 computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1, mContactInsert); 416 long contactId = mContactInsert.executeInsert(); 417 setContactId(rawContactId, contactId); 418 mDbHelper.updateContactVisible(contactId); 419 } 420 421 /** 422 * Synchronously aggregate the specified contact assuming an open transaction. 423 */ 424 public void aggregateContact(SQLiteDatabase db, long rawContactId, long currentContactId) { 425 if (!mEnabled) { 426 return; 427 } 428 429 MatchCandidateList candidates = new MatchCandidateList(); 430 ContactMatcher matcher = new ContactMatcher(); 431 ContentValues values = new ContentValues(); 432 433 aggregateContact(db, rawContactId, currentContactId, candidates, matcher, values); 434 } 435 436 public void updateAggregateData(long contactId) { 437 if (!mEnabled) { 438 return; 439 } 440 441 final SQLiteDatabase db = mDbHelper.getWritableDatabase(); 442 computeAggregateData(db, contactId, mContactUpdate); 443 mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId); 444 mContactUpdate.execute(); 445 446 mDbHelper.updateContactVisible(contactId); 447 updateAggregatedPresence(contactId); 448 } 449 450 private void updateAggregatedPresence(long contactId) { 451 mAggregatedPresenceReplace.bindLong(1, contactId); 452 mAggregatedPresenceReplace.bindLong(2, contactId); 453 mAggregatedPresenceReplace.execute(); 454 } 455 456 /** 457 * Given a specific raw contact, finds all matching aggregate contacts and chooses the one 458 * with the highest match score. If no such contact is found, creates a new contact. 459 */ 460 private synchronized void aggregateContact(SQLiteDatabase db, long rawContactId, 461 long currentContactId, MatchCandidateList candidates, ContactMatcher matcher, 462 ContentValues values) { 463 464 int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT; 465 466 Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId); 467 if (aggModeObject != null) { 468 aggregationMode = aggModeObject; 469 } 470 471 long contactId = -1; 472 473 if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) { 474 candidates.clear(); 475 matcher.clear(); 476 477 contactId = pickBestMatchBasedOnExceptions(db, rawContactId, matcher); 478 if (contactId == -1) { 479 contactId = pickBestMatchBasedOnData(db, rawContactId, candidates, matcher); 480 } 481 } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) { 482 return; 483 } 484 485 long currentContactContentsCount = 0; 486 487 if (currentContactId != 0) { 488 mRawContactCountQuery.bindLong(1, currentContactId); 489 mRawContactCountQuery.bindLong(2, rawContactId); 490 currentContactContentsCount = mRawContactCountQuery.simpleQueryForLong(); 491 } 492 493 // If there are no other raw contacts in the current aggregate, we might as well reuse it. 494 // Also, if the aggregation mode is SUSPENDED, we must reuse the same aggregate. 495 if (contactId == -1 496 && currentContactId != 0 497 && (currentContactContentsCount == 0 498 || aggregationMode == RawContacts.AGGREGATION_MODE_SUSPENDED)) { 499 contactId = currentContactId; 500 } 501 502 if (contactId == currentContactId) { 503 // Aggregation unchanged 504 markAggregated(rawContactId); 505 } else if (contactId == -1) { 506 // Splitting an aggregate 507 mSelectionArgs1[0] = String.valueOf(rawContactId); 508 computeAggregateData(db, mRawContactsQueryByRawContactId, mSelectionArgs1, 509 mContactInsert); 510 contactId = mContactInsert.executeInsert(); 511 setContactIdAndMarkAggregated(rawContactId, contactId); 512 mDbHelper.updateContactVisible(contactId); 513 514 setPresenceContactId(rawContactId, contactId); 515 516 updateAggregatedPresence(contactId); 517 518 if (currentContactContentsCount > 0) { 519 updateAggregateData(currentContactId); 520 } 521 } else { 522 // Joining with an existing aggregate 523 if (currentContactContentsCount == 0) { 524 // Delete a previous aggregate if it only contained this raw contact 525 mContactDelete.bindLong(1, currentContactId); 526 mContactDelete.execute(); 527 528 mAggregatedPresenceDelete.bindLong(1, currentContactId); 529 mAggregatedPresenceDelete.execute(); 530 } 531 532 setContactIdAndMarkAggregated(rawContactId, contactId); 533 computeAggregateData(db, contactId, mContactUpdate); 534 mContactUpdate.bindLong(ContactReplaceSqlStatement.CONTACT_ID, contactId); 535 mContactUpdate.execute(); 536 mDbHelper.updateContactVisible(contactId); 537 updateAggregatedPresence(contactId); 538 } 539 } 540 541 /** 542 * Updates the contact ID for the specified contact. 543 */ 544 private void setContactId(long rawContactId, long contactId) { 545 mContactIdUpdate.bindLong(1, contactId); 546 mContactIdUpdate.bindLong(2, rawContactId); 547 mContactIdUpdate.execute(); 548 } 549 550 /** 551 * Marks the specified raw contact ID as aggregated 552 */ 553 private void markAggregated(long rawContactId) { 554 mMarkAggregatedUpdate.bindLong(1, rawContactId); 555 mMarkAggregatedUpdate.execute(); 556 } 557 558 /** 559 * Updates the contact ID for the specified contact and marks the raw contact as aggregated. 560 */ 561 private void setContactIdAndMarkAggregated(long rawContactId, long contactId) { 562 mContactIdAndMarkAggregatedUpdate.bindLong(1, contactId); 563 mContactIdAndMarkAggregatedUpdate.bindLong(2, rawContactId); 564 mContactIdAndMarkAggregatedUpdate.execute(); 565 } 566 567 private void setPresenceContactId(long rawContactId, long contactId) { 568 mPresenceContactIdUpdate.bindLong(1, contactId); 569 mPresenceContactIdUpdate.bindLong(2, rawContactId); 570 mPresenceContactIdUpdate.execute(); 571 } 572 573 interface AggregateExceptionPrefetchQuery { 574 String TABLE = Tables.AGGREGATION_EXCEPTIONS; 575 576 String[] COLUMNS = { 577 AggregationExceptions.RAW_CONTACT_ID1, 578 AggregationExceptions.RAW_CONTACT_ID2, 579 }; 580 581 int RAW_CONTACT_ID1 = 0; 582 int RAW_CONTACT_ID2 = 1; 583 } 584 585 // A set of raw contact IDs for which there are aggregation exceptions 586 private final HashSet<Long> mAggregationExceptionIds = new HashSet<Long>(); 587 private boolean mAggregationExceptionIdsValid; 588 589 public void invalidateAggregationExceptionCache() { 590 mAggregationExceptionIdsValid = false; 591 } 592 593 /** 594 * Finds all raw contact IDs for which there are aggregation exceptions. The list of 595 * ids is used as an optimization in aggregation: there is no point to run a query against 596 * the agg_exceptions table if it is known that there are no records there for a given 597 * raw contact ID. 598 */ 599 private void prefetchAggregationExceptionIds(SQLiteDatabase db) { 600 mAggregationExceptionIds.clear(); 601 final Cursor c = db.query(AggregateExceptionPrefetchQuery.TABLE, 602 AggregateExceptionPrefetchQuery.COLUMNS, 603 null, null, null, null, null); 604 605 try { 606 while (c.moveToNext()) { 607 long rawContactId1 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID1); 608 long rawContactId2 = c.getLong(AggregateExceptionPrefetchQuery.RAW_CONTACT_ID2); 609 mAggregationExceptionIds.add(rawContactId1); 610 mAggregationExceptionIds.add(rawContactId2); 611 } 612 } finally { 613 c.close(); 614 } 615 616 mAggregationExceptionIdsValid = true; 617 } 618 619 interface AggregateExceptionQuery { 620 String TABLE = Tables.AGGREGATION_EXCEPTIONS 621 + " JOIN raw_contacts raw_contacts1 " 622 + " ON (agg_exceptions.raw_contact_id1 = raw_contacts1._id) " 623 + " JOIN raw_contacts raw_contacts2 " 624 + " ON (agg_exceptions.raw_contact_id2 = raw_contacts2._id) "; 625 626 String[] COLUMNS = { 627 AggregationExceptions.TYPE, 628 AggregationExceptions.RAW_CONTACT_ID1, 629 "raw_contacts1." + RawContacts.CONTACT_ID, 630 "raw_contacts1." + RawContactsColumns.AGGREGATION_NEEDED, 631 "raw_contacts2." + RawContacts.CONTACT_ID, 632 "raw_contacts2." + RawContactsColumns.AGGREGATION_NEEDED, 633 }; 634 635 int TYPE = 0; 636 int RAW_CONTACT_ID1 = 1; 637 int CONTACT_ID1 = 2; 638 int AGGREGATION_NEEDED_1 = 3; 639 int CONTACT_ID2 = 4; 640 int AGGREGATION_NEEDED_2 = 5; 641 } 642 643 /** 644 * Computes match scores based on exceptions entered by the user: always match and never match. 645 * Returns the aggregate contact with the always match exception if any. 646 */ 647 private long pickBestMatchBasedOnExceptions(SQLiteDatabase db, long rawContactId, 648 ContactMatcher matcher) { 649 if (!mAggregationExceptionIdsValid) { 650 prefetchAggregationExceptionIds(db); 651 } 652 653 // If there are no aggregation exceptions involving this raw contact, there is no need to 654 // run a query and we can just return -1, which stands for "nothing found" 655 if (!mAggregationExceptionIds.contains(rawContactId)) { 656 return -1; 657 } 658 659 final Cursor c = db.query(AggregateExceptionQuery.TABLE, 660 AggregateExceptionQuery.COLUMNS, 661 AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId 662 + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId, 663 null, null, null, null); 664 665 try { 666 while (c.moveToNext()) { 667 int type = c.getInt(AggregateExceptionQuery.TYPE); 668 long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1); 669 long contactId = -1; 670 if (rawContactId == rawContactId1) { 671 if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_2) == 0 672 && !c.isNull(AggregateExceptionQuery.CONTACT_ID2)) { 673 contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2); 674 } 675 } else { 676 if (c.getInt(AggregateExceptionQuery.AGGREGATION_NEEDED_1) == 0 677 && !c.isNull(AggregateExceptionQuery.CONTACT_ID1)) { 678 contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1); 679 } 680 } 681 if (contactId != -1) { 682 if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) { 683 matcher.keepIn(contactId); 684 } else { 685 matcher.keepOut(contactId); 686 } 687 } 688 } 689 } finally { 690 c.close(); 691 } 692 693 return matcher.pickBestMatch(ContactMatcher.MAX_SCORE); 694 } 695 696 /** 697 * Picks the best matching contact based on matches between data elements. It considers 698 * name match to be primary and phone, email etc matches to be secondary. A good primary 699 * match triggers aggregation, while a good secondary match only triggers aggregation in 700 * the absence of a strong primary mismatch. 701 * <p> 702 * Consider these examples: 703 * <p> 704 * John Doe with phone number 111-111-1111 and Jon Doe with phone number 111-111-1111 should 705 * be aggregated (same number, similar names). 706 * <p> 707 * John Doe with phone number 111-111-1111 and Deborah Doe with phone number 111-111-1111 should 708 * not be aggregated (same number, different names). 709 */ 710 private long pickBestMatchBasedOnData(SQLiteDatabase db, long rawContactId, 711 MatchCandidateList candidates, ContactMatcher matcher) { 712 713 // Find good matches based on name alone 714 long bestMatch = updateMatchScoresBasedOnDataMatches(db, rawContactId, candidates, matcher); 715 if (bestMatch == -1) { 716 // We haven't found a good match on name, see if we have any matches on phone, email etc 717 bestMatch = pickBestMatchBasedOnSecondaryData(db, rawContactId, candidates, matcher); 718 } 719 720 return bestMatch; 721 } 722 723 724 /** 725 * Picks the best matching contact based on secondary data matches. The method loads 726 * structured names for all candidate contacts and recomputes match scores using approximate 727 * matching. 728 */ 729 private long pickBestMatchBasedOnSecondaryData(SQLiteDatabase db, 730 long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) { 731 List<Long> secondaryContactIds = matcher.prepareSecondaryMatchCandidates( 732 ContactMatcher.SCORE_THRESHOLD_PRIMARY); 733 if (secondaryContactIds == null || secondaryContactIds.size() > SECONDARY_HIT_LIMIT) { 734 return -1; 735 } 736 737 loadNameMatchCandidates(db, rawContactId, candidates, true); 738 739 mSb.setLength(0); 740 mSb.append(RawContacts.CONTACT_ID).append(" IN ("); 741 for (int i = 0; i < secondaryContactIds.size(); i++) { 742 if (i != 0) { 743 mSb.append(','); 744 } 745 mSb.append(secondaryContactIds.get(i)); 746 } 747 748 // We only want to compare structured names to structured names 749 // at this stage, we need to ignore all other sources of name lookup data. 750 mSb.append(") AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL); 751 752 matchAllCandidates(db, mSb.toString(), candidates, matcher, 753 ContactMatcher.MATCHING_ALGORITHM_CONSERVATIVE, null); 754 755 return matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_SECONDARY); 756 } 757 758 private interface NameLookupQuery { 759 String TABLE = Tables.NAME_LOOKUP; 760 761 String SELECTION = NameLookupColumns.RAW_CONTACT_ID + "=?"; 762 String SELECTION_STRUCTURED_NAME_BASED = 763 SELECTION + " AND " + STRUCTURED_NAME_BASED_LOOKUP_SQL; 764 765 String[] COLUMNS = new String[] { 766 NameLookupColumns.NORMALIZED_NAME, 767 NameLookupColumns.NAME_TYPE 768 }; 769 770 int NORMALIZED_NAME = 0; 771 int NAME_TYPE = 1; 772 } 773 774 private void loadNameMatchCandidates(SQLiteDatabase db, long rawContactId, 775 MatchCandidateList candidates, boolean structuredNameBased) { 776 candidates.clear(); 777 mSelectionArgs1[0] = String.valueOf(rawContactId); 778 Cursor c = db.query(NameLookupQuery.TABLE, NameLookupQuery.COLUMNS, 779 structuredNameBased 780 ? NameLookupQuery.SELECTION_STRUCTURED_NAME_BASED 781 : NameLookupQuery.SELECTION, 782 mSelectionArgs1, null, null, null); 783 try { 784 while (c.moveToNext()) { 785 String normalizedName = c.getString(NameLookupQuery.NORMALIZED_NAME); 786 int type = c.getInt(NameLookupQuery.NAME_TYPE); 787 candidates.add(normalizedName, type); 788 } 789 } finally { 790 c.close(); 791 } 792 } 793 794 /** 795 * Computes scores for contacts that have matching data rows. 796 */ 797 private long updateMatchScoresBasedOnDataMatches(SQLiteDatabase db, long rawContactId, 798 MatchCandidateList candidates, ContactMatcher matcher) { 799 800 updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher); 801 long bestMatch = matcher.pickBestMatch(ContactMatcher.SCORE_THRESHOLD_PRIMARY); 802 if (bestMatch != -1) { 803 return bestMatch; 804 } 805 806 updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher); 807 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher); 808 809 return -1; 810 } 811 812 private interface NameLookupMatchQuery { 813 String TABLE = Tables.NAME_LOOKUP + " nameA" 814 + " JOIN " + Tables.NAME_LOOKUP + " nameB" + 815 " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "=" 816 + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")" 817 + " JOIN " + Tables.RAW_CONTACTS + 818 " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = " 819 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 820 821 String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?" 822 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"; 823 824 String[] COLUMNS = new String[] { 825 RawContacts.CONTACT_ID, 826 "nameA." + NameLookupColumns.NORMALIZED_NAME, 827 "nameA." + NameLookupColumns.NAME_TYPE, 828 "nameB." + NameLookupColumns.NAME_TYPE, 829 }; 830 831 int CONTACT_ID = 0; 832 int NAME = 1; 833 int NAME_TYPE_A = 2; 834 int NAME_TYPE_B = 3; 835 } 836 837 private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId, 838 ContactMatcher matcher) { 839 mSelectionArgs1[0] = String.valueOf(rawContactId); 840 Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS, 841 NameLookupMatchQuery.SELECTION, 842 mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING); 843 try { 844 while (c.moveToNext()) { 845 long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID); 846 String name = c.getString(NameLookupMatchQuery.NAME); 847 int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A); 848 int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B); 849 matcher.matchName(contactId, nameTypeA, name, 850 nameTypeB, name, ContactMatcher.MATCHING_ALGORITHM_EXACT); 851 if (nameTypeA == NameLookupType.NICKNAME && 852 nameTypeB == NameLookupType.NICKNAME) { 853 matcher.updateScoreWithNicknameMatch(contactId); 854 } 855 } 856 } finally { 857 c.close(); 858 } 859 } 860 861 private interface EmailLookupQuery { 862 String TABLE = Tables.DATA + " dataA" 863 + " JOIN " + Tables.DATA + " dataB" + 864 " ON (" + "dataA." + Email.DATA + "=dataB." + Email.DATA + ")" 865 + " JOIN " + Tables.RAW_CONTACTS + 866 " ON (dataB." + Data.RAW_CONTACT_ID + " = " 867 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 868 869 String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?" 870 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?" 871 + " AND dataA." + Email.DATA + " NOT NULL" 872 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?" 873 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"; 874 875 String[] COLUMNS = new String[] { 876 RawContacts.CONTACT_ID 877 }; 878 879 int CONTACT_ID = 0; 880 } 881 882 private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId, 883 ContactMatcher matcher) { 884 mSelectionArgs3[0] = String.valueOf(rawContactId); 885 mSelectionArgs3[1] = mSelectionArgs3[2] = String.valueOf(mMimeTypeIdEmail); 886 Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS, 887 EmailLookupQuery.SELECTION, 888 mSelectionArgs3, null, null, null, SECONDARY_HIT_LIMIT_STRING); 889 try { 890 while (c.moveToNext()) { 891 long contactId = c.getLong(EmailLookupQuery.CONTACT_ID); 892 matcher.updateScoreWithEmailMatch(contactId); 893 } 894 } finally { 895 c.close(); 896 } 897 } 898 899 private interface PhoneLookupQuery { 900 String TABLE = Tables.PHONE_LOOKUP + " phoneA" 901 + " JOIN " + Tables.DATA + " dataA" 902 + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")" 903 + " JOIN " + Tables.PHONE_LOOKUP + " phoneB" 904 + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "=" 905 + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")" 906 + " JOIN " + Tables.DATA + " dataB" 907 + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")" 908 + " JOIN " + Tables.RAW_CONTACTS 909 + " ON (dataB." + Data.RAW_CONTACT_ID + " = " 910 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 911 912 String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?" 913 + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", " 914 + "dataB." + Phone.NUMBER + ",?)" 915 + " AND " + RawContactsColumns.AGGREGATION_NEEDED + "=0"; 916 917 String[] COLUMNS = new String[] { 918 RawContacts.CONTACT_ID 919 }; 920 921 int CONTACT_ID = 0; 922 } 923 924 private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId, 925 ContactMatcher matcher) { 926 mSelectionArgs2[0] = String.valueOf(rawContactId); 927 mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter(); 928 Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS, 929 PhoneLookupQuery.SELECTION, 930 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING); 931 try { 932 while (c.moveToNext()) { 933 long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID); 934 matcher.updateScoreWithPhoneNumberMatch(contactId); 935 } 936 } finally { 937 c.close(); 938 } 939 940 } 941 942 /** 943 * Loads name lookup rows for approximate name matching and updates match scores based on that 944 * data. 945 */ 946 private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates, 947 ContactMatcher matcher) { 948 HashSet<String> firstLetters = new HashSet<String>(); 949 for (int i = 0; i < candidates.mCount; i++) { 950 final NameMatchCandidate candidate = candidates.mList.get(i); 951 if (candidate.mName.length() >= 2) { 952 String firstLetter = candidate.mName.substring(0, 2); 953 if (!firstLetters.contains(firstLetter)) { 954 firstLetters.add(firstLetter); 955 final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '" 956 + firstLetter + "*') AND " 957 + NameLookupColumns.NAME_TYPE + " IN(" 958 + NameLookupType.NAME_COLLATION_KEY + "," 959 + NameLookupType.EMAIL_BASED_NICKNAME + "," 960 + NameLookupType.NICKNAME + ")"; 961 matchAllCandidates(db, selection, candidates, matcher, 962 ContactMatcher.MATCHING_ALGORITHM_APPROXIMATE, 963 String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT)); 964 } 965 } 966 } 967 } 968 969 private interface ContactNameLookupQuery { 970 String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS; 971 972 String[] COLUMNS = new String[] { 973 RawContacts.CONTACT_ID, 974 NameLookupColumns.NORMALIZED_NAME, 975 NameLookupColumns.NAME_TYPE 976 }; 977 978 int CONTACT_ID = 0; 979 int NORMALIZED_NAME = 1; 980 int NAME_TYPE = 2; 981 } 982 983 /** 984 * Loads all candidate rows from the name lookup table and updates match scores based 985 * on that data. 986 */ 987 private void matchAllCandidates(SQLiteDatabase db, String selection, 988 MatchCandidateList candidates, ContactMatcher matcher, int algorithm, String limit) { 989 final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS, 990 selection, null, null, null, null, limit); 991 992 try { 993 while (c.moveToNext()) { 994 Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID); 995 String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME); 996 int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE); 997 998 // Note the N^2 complexity of the following fragment. This is not a huge concern 999 // since the number of candidates is very small and in general secondary hits 1000 // in the absence of primary hits are rare. 1001 for (int i = 0; i < candidates.mCount; i++) { 1002 NameMatchCandidate candidate = candidates.mList.get(i); 1003 matcher.matchName(contactId, candidate.mLookupType, candidate.mName, 1004 nameType, name, algorithm); 1005 } 1006 } 1007 } finally { 1008 c.close(); 1009 } 1010 } 1011 1012 private interface RawContactsQuery { 1013 String SQL_FORMAT = 1014 "SELECT " 1015 + RawContactsColumns.CONCRETE_ID + "," 1016 + RawContactsColumns.DISPLAY_NAME + "," 1017 + RawContactsColumns.DISPLAY_NAME_SOURCE + "," 1018 + RawContacts.ACCOUNT_TYPE + "," 1019 + RawContacts.ACCOUNT_NAME + "," 1020 + RawContacts.SOURCE_ID + "," 1021 + RawContacts.CUSTOM_RINGTONE + "," 1022 + RawContacts.SEND_TO_VOICEMAIL + "," 1023 + RawContacts.LAST_TIME_CONTACTED + "," 1024 + RawContacts.TIMES_CONTACTED + "," 1025 + RawContacts.STARRED + "," 1026 + RawContacts.IS_RESTRICTED + "," 1027 + RawContacts.NAME_VERIFIED + "," 1028 + DataColumns.CONCRETE_ID + "," 1029 + DataColumns.CONCRETE_MIMETYPE_ID + "," 1030 + Data.IS_SUPER_PRIMARY + 1031 " FROM " + Tables.RAW_CONTACTS + 1032 " LEFT OUTER JOIN " + Tables.DATA + 1033 " ON (" + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID 1034 + " AND ((" + DataColumns.MIMETYPE_ID + "=%d" 1035 + " AND " + Photo.PHOTO + " NOT NULL)" 1036 + " OR (" + DataColumns.MIMETYPE_ID + "=%d" 1037 + " AND " + Phone.NUMBER + " NOT NULL)))"; 1038 1039 String SQL_FORMAT_BY_RAW_CONTACT_ID = SQL_FORMAT + 1040 " WHERE " + RawContactsColumns.CONCRETE_ID + "=?"; 1041 1042 String SQL_FORMAT_BY_CONTACT_ID = SQL_FORMAT + 1043 " WHERE " + RawContacts.CONTACT_ID + "=?" 1044 + " AND " + RawContacts.DELETED + "=0"; 1045 1046 int RAW_CONTACT_ID = 0; 1047 int DISPLAY_NAME = 1; 1048 int DISPLAY_NAME_SOURCE = 2; 1049 int ACCOUNT_TYPE = 3; 1050 int ACCOUNT_NAME = 4; 1051 int SOURCE_ID = 5; 1052 int CUSTOM_RINGTONE = 6; 1053 int SEND_TO_VOICEMAIL = 7; 1054 int LAST_TIME_CONTACTED = 8; 1055 int TIMES_CONTACTED = 9; 1056 int STARRED = 10; 1057 int IS_RESTRICTED = 11; 1058 int NAME_VERIFIED = 12; 1059 int DATA_ID = 13; 1060 int MIMETYPE_ID = 14; 1061 int IS_SUPER_PRIMARY = 15; 1062 } 1063 1064 private interface ContactReplaceSqlStatement { 1065 String UPDATE_SQL = 1066 "UPDATE " + Tables.CONTACTS + 1067 " SET " 1068 + Contacts.NAME_RAW_CONTACT_ID + "=?, " 1069 + Contacts.PHOTO_ID + "=?, " 1070 + Contacts.SEND_TO_VOICEMAIL + "=?, " 1071 + Contacts.CUSTOM_RINGTONE + "=?, " 1072 + Contacts.LAST_TIME_CONTACTED + "=?, " 1073 + Contacts.TIMES_CONTACTED + "=?, " 1074 + Contacts.STARRED + "=?, " 1075 + Contacts.HAS_PHONE_NUMBER + "=?, " 1076 + ContactsColumns.SINGLE_IS_RESTRICTED + "=?, " 1077 + Contacts.LOOKUP_KEY + "=? " + 1078 " WHERE " + Contacts._ID + "=?"; 1079 1080 String INSERT_SQL = 1081 "INSERT INTO " + Tables.CONTACTS + " (" 1082 + Contacts.NAME_RAW_CONTACT_ID + ", " 1083 + Contacts.PHOTO_ID + ", " 1084 + Contacts.SEND_TO_VOICEMAIL + ", " 1085 + Contacts.CUSTOM_RINGTONE + ", " 1086 + Contacts.LAST_TIME_CONTACTED + ", " 1087 + Contacts.TIMES_CONTACTED + ", " 1088 + Contacts.STARRED + ", " 1089 + Contacts.HAS_PHONE_NUMBER + ", " 1090 + ContactsColumns.SINGLE_IS_RESTRICTED + ", " 1091 + Contacts.LOOKUP_KEY + ", " 1092 + Contacts.IN_VISIBLE_GROUP + ") " + 1093 " VALUES (?,?,?,?,?,?,?,?,?,?,0)"; 1094 1095 int NAME_RAW_CONTACT_ID = 1; 1096 int PHOTO_ID = 2; 1097 int SEND_TO_VOICEMAIL = 3; 1098 int CUSTOM_RINGTONE = 4; 1099 int LAST_TIME_CONTACTED = 5; 1100 int TIMES_CONTACTED = 6; 1101 int STARRED = 7; 1102 int HAS_PHONE_NUMBER = 8; 1103 int SINGLE_IS_RESTRICTED = 9; 1104 int LOOKUP_KEY = 10; 1105 int CONTACT_ID = 11; 1106 } 1107 1108 /** 1109 * Computes aggregate-level data for the specified aggregate contact ID. 1110 */ 1111 private void computeAggregateData(SQLiteDatabase db, long contactId, 1112 SQLiteStatement statement) { 1113 mSelectionArgs1[0] = String.valueOf(contactId); 1114 computeAggregateData(db, mRawContactsQueryByContactId, mSelectionArgs1, statement); 1115 } 1116 1117 /** 1118 * Computes aggregate-level data from constituent raw contacts. 1119 */ 1120 private void computeAggregateData(final SQLiteDatabase db, String sql, String[] sqlArgs, 1121 SQLiteStatement statement) { 1122 long currentRawContactId = -1; 1123 long bestPhotoId = -1; 1124 boolean foundSuperPrimaryPhoto = false; 1125 int photoPriority = -1; 1126 int totalRowCount = 0; 1127 int contactSendToVoicemail = 0; 1128 String contactCustomRingtone = null; 1129 long contactLastTimeContacted = 0; 1130 int contactTimesContacted = 0; 1131 int contactStarred = 0; 1132 int singleIsRestricted = 1; 1133 int hasPhoneNumber = 0; 1134 1135 mDisplayNameCandidate.clear(); 1136 1137 mSb.setLength(0); // Lookup key 1138 Cursor c = db.rawQuery(sql, sqlArgs); 1139 try { 1140 while (c.moveToNext()) { 1141 long rawContactId = c.getLong(RawContactsQuery.RAW_CONTACT_ID); 1142 if (rawContactId != currentRawContactId) { 1143 currentRawContactId = rawContactId; 1144 totalRowCount++; 1145 1146 // Display name 1147 String displayName = c.getString(RawContactsQuery.DISPLAY_NAME); 1148 int displayNameSource = c.getInt(RawContactsQuery.DISPLAY_NAME_SOURCE); 1149 int nameVerified = c.getInt(RawContactsQuery.NAME_VERIFIED); 1150 String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE); 1151 processDisplayNameCanditate(rawContactId, displayName, displayNameSource, 1152 mContactsProvider.isWritableAccount(accountType), nameVerified != 0); 1153 1154 1155 // Contact options 1156 if (!c.isNull(RawContactsQuery.SEND_TO_VOICEMAIL)) { 1157 boolean sendToVoicemail = 1158 (c.getInt(RawContactsQuery.SEND_TO_VOICEMAIL) != 0); 1159 if (sendToVoicemail) { 1160 contactSendToVoicemail++; 1161 } 1162 } 1163 1164 if (contactCustomRingtone == null 1165 && !c.isNull(RawContactsQuery.CUSTOM_RINGTONE)) { 1166 contactCustomRingtone = c.getString(RawContactsQuery.CUSTOM_RINGTONE); 1167 } 1168 1169 long lastTimeContacted = c.getLong(RawContactsQuery.LAST_TIME_CONTACTED); 1170 if (lastTimeContacted > contactLastTimeContacted) { 1171 contactLastTimeContacted = lastTimeContacted; 1172 } 1173 1174 int timesContacted = c.getInt(RawContactsQuery.TIMES_CONTACTED); 1175 if (timesContacted > contactTimesContacted) { 1176 contactTimesContacted = timesContacted; 1177 } 1178 1179 if (c.getInt(RawContactsQuery.STARRED) != 0) { 1180 contactStarred = 1; 1181 } 1182 1183 // Single restricted 1184 if (totalRowCount > 1) { 1185 // Not single 1186 singleIsRestricted = 0; 1187 } else { 1188 int isRestricted = c.getInt(RawContactsQuery.IS_RESTRICTED); 1189 1190 if (isRestricted == 0) { 1191 // Not restricted 1192 singleIsRestricted = 0; 1193 } 1194 } 1195 1196 ContactLookupKey.appendToLookupKey(mSb, 1197 c.getString(RawContactsQuery.ACCOUNT_TYPE), 1198 c.getString(RawContactsQuery.ACCOUNT_NAME), 1199 rawContactId, 1200 c.getString(RawContactsQuery.SOURCE_ID), 1201 displayName); 1202 } 1203 1204 if (!c.isNull(RawContactsQuery.DATA_ID)) { 1205 long dataId = c.getLong(RawContactsQuery.DATA_ID); 1206 int mimetypeId = c.getInt(RawContactsQuery.MIMETYPE_ID); 1207 boolean superPrimary = c.getInt(RawContactsQuery.IS_SUPER_PRIMARY) != 0; 1208 if (mimetypeId == mMimeTypeIdPhoto) { 1209 if (!foundSuperPrimaryPhoto) { 1210 String accountType = c.getString(RawContactsQuery.ACCOUNT_TYPE); 1211 int priority = mPhotoPriorityResolver.getPhotoPriority(accountType); 1212 if (superPrimary || priority > photoPriority) { 1213 photoPriority = priority; 1214 bestPhotoId = dataId; 1215 foundSuperPrimaryPhoto |= superPrimary; 1216 } 1217 } 1218 } else if (mimetypeId == mMimeTypeIdPhone) { 1219 hasPhoneNumber = 1; 1220 } 1221 } 1222 } 1223 } finally { 1224 c.close(); 1225 } 1226 1227 statement.bindLong(ContactReplaceSqlStatement.NAME_RAW_CONTACT_ID, 1228 mDisplayNameCandidate.rawContactId); 1229 1230 if (bestPhotoId != -1) { 1231 statement.bindLong(ContactReplaceSqlStatement.PHOTO_ID, bestPhotoId); 1232 } else { 1233 statement.bindNull(ContactReplaceSqlStatement.PHOTO_ID); 1234 } 1235 1236 statement.bindLong(ContactReplaceSqlStatement.SEND_TO_VOICEMAIL, 1237 totalRowCount == contactSendToVoicemail ? 1 : 0); 1238 DatabaseUtils.bindObjectToProgram(statement, ContactReplaceSqlStatement.CUSTOM_RINGTONE, 1239 contactCustomRingtone); 1240 statement.bindLong(ContactReplaceSqlStatement.LAST_TIME_CONTACTED, 1241 contactLastTimeContacted); 1242 statement.bindLong(ContactReplaceSqlStatement.TIMES_CONTACTED, 1243 contactTimesContacted); 1244 statement.bindLong(ContactReplaceSqlStatement.STARRED, 1245 contactStarred); 1246 statement.bindLong(ContactReplaceSqlStatement.HAS_PHONE_NUMBER, 1247 hasPhoneNumber); 1248 statement.bindLong(ContactReplaceSqlStatement.SINGLE_IS_RESTRICTED, 1249 singleIsRestricted); 1250 statement.bindString(ContactReplaceSqlStatement.LOOKUP_KEY, 1251 Uri.encode(mSb.toString())); 1252 } 1253 1254 /** 1255 * Uses the supplied values to determine if they represent a "better" display name 1256 * for the aggregate contact currently evaluated. If so, it updates 1257 * {@link #mDisplayNameCandidate} with the new values. 1258 */ 1259 private void processDisplayNameCanditate(long rawContactId, String displayName, 1260 int displayNameSource, boolean writableAccount, boolean verified) { 1261 1262 boolean replace = false; 1263 if (mDisplayNameCandidate.rawContactId == -1) { 1264 // No previous values available 1265 replace = true; 1266 } else if (!TextUtils.isEmpty(displayName)) { 1267 if (!mDisplayNameCandidate.verified && verified) { 1268 // A verified name is better than any other name 1269 replace = true; 1270 } else if (mDisplayNameCandidate.verified == verified) { 1271 if (mDisplayNameCandidate.displayNameSource < displayNameSource) { 1272 // New values come from an superior source, e.g. structured name vs phone number 1273 replace = true; 1274 } else if (mDisplayNameCandidate.displayNameSource == displayNameSource) { 1275 if (!mDisplayNameCandidate.writableAccount && writableAccount) { 1276 replace = true; 1277 } else if (mDisplayNameCandidate.writableAccount == writableAccount) { 1278 if (NameNormalizer.compareComplexity(displayName, 1279 mDisplayNameCandidate.displayName) > 0) { 1280 // New name is more complex than the previously found one 1281 replace = true; 1282 } 1283 } 1284 } 1285 } 1286 } 1287 1288 if (replace) { 1289 mDisplayNameCandidate.rawContactId = rawContactId; 1290 mDisplayNameCandidate.displayName = displayName; 1291 mDisplayNameCandidate.displayNameSource = displayNameSource; 1292 mDisplayNameCandidate.verified = verified; 1293 mDisplayNameCandidate.writableAccount = writableAccount; 1294 } 1295 } 1296 1297 private interface PhotoIdQuery { 1298 String[] COLUMNS = new String[] { 1299 RawContacts.ACCOUNT_TYPE, 1300 DataColumns.CONCRETE_ID, 1301 Data.IS_SUPER_PRIMARY, 1302 }; 1303 1304 int ACCOUNT_TYPE = 0; 1305 int DATA_ID = 1; 1306 int IS_SUPER_PRIMARY = 2; 1307 } 1308 1309 public void updatePhotoId(SQLiteDatabase db, long rawContactId) { 1310 1311 long contactId = mDbHelper.getContactId(rawContactId); 1312 if (contactId == 0) { 1313 return; 1314 } 1315 1316 long bestPhotoId = -1; 1317 int photoPriority = -1; 1318 1319 long photoMimeType = mDbHelper.getMimeTypeId(Photo.CONTENT_ITEM_TYPE); 1320 1321 String tables = Tables.RAW_CONTACTS + " JOIN " + Tables.DATA + " ON(" 1322 + DataColumns.CONCRETE_RAW_CONTACT_ID + "=" + RawContactsColumns.CONCRETE_ID 1323 + " AND (" + DataColumns.MIMETYPE_ID + "=" + photoMimeType + " AND " 1324 + Photo.PHOTO + " NOT NULL))"; 1325 1326 mSelectionArgs1[0] = String.valueOf(contactId); 1327 final Cursor c = db.query(tables, PhotoIdQuery.COLUMNS, 1328 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null); 1329 try { 1330 while (c.moveToNext()) { 1331 long dataId = c.getLong(PhotoIdQuery.DATA_ID); 1332 boolean superprimary = c.getInt(PhotoIdQuery.IS_SUPER_PRIMARY) != 0; 1333 if (superprimary) { 1334 bestPhotoId = dataId; 1335 break; 1336 } 1337 1338 String accountType = c.getString(PhotoIdQuery.ACCOUNT_TYPE); 1339 int priority = mPhotoPriorityResolver.getPhotoPriority(accountType); 1340 if (priority > photoPriority) { 1341 photoPriority = priority; 1342 bestPhotoId = dataId; 1343 } 1344 } 1345 } finally { 1346 c.close(); 1347 } 1348 1349 if (bestPhotoId == -1) { 1350 mPhotoIdUpdate.bindNull(1); 1351 } else { 1352 mPhotoIdUpdate.bindLong(1, bestPhotoId); 1353 } 1354 mPhotoIdUpdate.bindLong(2, contactId); 1355 mPhotoIdUpdate.execute(); 1356 } 1357 1358 private interface DisplayNameQuery { 1359 String[] COLUMNS = new String[] { 1360 RawContacts._ID, 1361 RawContactsColumns.DISPLAY_NAME, 1362 RawContactsColumns.DISPLAY_NAME_SOURCE, 1363 RawContacts.NAME_VERIFIED, 1364 RawContacts.SOURCE_ID, 1365 RawContacts.ACCOUNT_TYPE, 1366 }; 1367 1368 int _ID = 0; 1369 int DISPLAY_NAME = 1; 1370 int DISPLAY_NAME_SOURCE = 2; 1371 int NAME_VERIFIED = 3; 1372 int SOURCE_ID = 4; 1373 int ACCOUNT_TYPE = 5; 1374 } 1375 1376 public void updateDisplayNameForRawContact(SQLiteDatabase db, long rawContactId) { 1377 long contactId = mDbHelper.getContactId(rawContactId); 1378 if (contactId == 0) { 1379 return; 1380 } 1381 1382 updateDisplayNameForContact(db, contactId); 1383 } 1384 1385 public void updateDisplayNameForContact(SQLiteDatabase db, long contactId) { 1386 boolean lookupKeyUpdateNeeded = false; 1387 1388 mDisplayNameCandidate.clear(); 1389 1390 mSelectionArgs1[0] = String.valueOf(contactId); 1391 final Cursor c = db.query(Tables.RAW_CONTACTS, DisplayNameQuery.COLUMNS, 1392 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, null); 1393 try { 1394 while (c.moveToNext()) { 1395 long rawContactId = c.getLong(DisplayNameQuery._ID); 1396 String displayName = c.getString(DisplayNameQuery.DISPLAY_NAME); 1397 int displayNameSource = c.getInt(DisplayNameQuery.DISPLAY_NAME_SOURCE); 1398 int nameVerified = c.getInt(DisplayNameQuery.NAME_VERIFIED); 1399 String accountType = c.getString(DisplayNameQuery.ACCOUNT_TYPE); 1400 1401 processDisplayNameCanditate(rawContactId, displayName, displayNameSource, 1402 mContactsProvider.isWritableAccount(accountType), nameVerified != 0); 1403 1404 // If the raw contact has no source id, the lookup key is based on the display 1405 // name, so the lookup key needs to be updated. 1406 lookupKeyUpdateNeeded |= c.isNull(DisplayNameQuery.SOURCE_ID); 1407 } 1408 } finally { 1409 c.close(); 1410 } 1411 1412 if (mDisplayNameCandidate.rawContactId != -1) { 1413 mDisplayNameUpdate.bindLong(1, mDisplayNameCandidate.rawContactId); 1414 mDisplayNameUpdate.bindLong(2, contactId); 1415 mDisplayNameUpdate.execute(); 1416 } 1417 1418 if (lookupKeyUpdateNeeded) { 1419 updateLookupKeyForContact(db, contactId); 1420 } 1421 } 1422 1423 /** 1424 * Updates the {@link Contacts#HAS_PHONE_NUMBER} flag for the aggregate contact containing the 1425 * specified raw contact. 1426 */ 1427 public void updateHasPhoneNumber(SQLiteDatabase db, long rawContactId) { 1428 1429 long contactId = mDbHelper.getContactId(rawContactId); 1430 if (contactId == 0) { 1431 return; 1432 } 1433 1434 mHasPhoneNumberUpdate.bindLong(1, mDbHelper.getMimeTypeId(Phone.CONTENT_ITEM_TYPE)); 1435 mHasPhoneNumberUpdate.bindLong(2, contactId); 1436 mHasPhoneNumberUpdate.bindLong(3, contactId); 1437 mHasPhoneNumberUpdate.execute(); 1438 } 1439 1440 private interface LookupKeyQuery { 1441 String[] COLUMNS = new String[] { 1442 RawContacts._ID, 1443 RawContactsColumns.DISPLAY_NAME, 1444 RawContacts.ACCOUNT_TYPE, 1445 RawContacts.ACCOUNT_NAME, 1446 RawContacts.SOURCE_ID, 1447 }; 1448 1449 int ID = 0; 1450 int DISPLAY_NAME = 1; 1451 int ACCOUNT_TYPE = 2; 1452 int ACCOUNT_NAME = 3; 1453 int SOURCE_ID = 4; 1454 } 1455 1456 public void updateLookupKeyForRawContact(SQLiteDatabase db, long rawContactId) { 1457 long contactId = mDbHelper.getContactId(rawContactId); 1458 if (contactId == 0) { 1459 return; 1460 } 1461 1462 updateLookupKeyForContact(db, contactId); 1463 } 1464 1465 public void updateLookupKeyForContact(SQLiteDatabase db, long contactId) { 1466 mSb.setLength(0); 1467 mSelectionArgs1[0] = String.valueOf(contactId); 1468 final Cursor c = db.query(Tables.RAW_CONTACTS, LookupKeyQuery.COLUMNS, 1469 RawContacts.CONTACT_ID + "=?", mSelectionArgs1, null, null, RawContacts._ID); 1470 try { 1471 while (c.moveToNext()) { 1472 ContactLookupKey.appendToLookupKey(mSb, 1473 c.getString(LookupKeyQuery.ACCOUNT_TYPE), 1474 c.getString(LookupKeyQuery.ACCOUNT_NAME), 1475 c.getLong(LookupKeyQuery.ID), 1476 c.getString(LookupKeyQuery.SOURCE_ID), 1477 c.getString(LookupKeyQuery.DISPLAY_NAME)); 1478 } 1479 } finally { 1480 c.close(); 1481 } 1482 1483 if (mSb.length() == 0) { 1484 mLookupKeyUpdate.bindNull(1); 1485 } else { 1486 mLookupKeyUpdate.bindString(1, Uri.encode(mSb.toString())); 1487 } 1488 mLookupKeyUpdate.bindLong(2, contactId); 1489 1490 mLookupKeyUpdate.execute(); 1491 } 1492 1493 /** 1494 * Execute {@link SQLiteStatement} that will update the 1495 * {@link Contacts#STARRED} flag for the given {@link RawContacts#_ID}. 1496 */ 1497 protected void updateStarred(long rawContactId) { 1498 long contactId = mDbHelper.getContactId(rawContactId); 1499 if (contactId == 0) { 1500 return; 1501 } 1502 1503 mStarredUpdate.bindLong(1, contactId); 1504 mStarredUpdate.execute(); 1505 } 1506 1507 /** 1508 * Finds matching contacts and returns a cursor on those. 1509 */ 1510 public Cursor queryAggregationSuggestions(SQLiteQueryBuilder qb, String[] projection, 1511 long contactId, int maxSuggestions, String filter) { 1512 final SQLiteDatabase db = mDbHelper.getReadableDatabase(); 1513 1514 List<MatchScore> bestMatches = findMatchingContacts(db, contactId); 1515 return queryMatchingContacts(qb, db, contactId, projection, bestMatches, maxSuggestions, 1516 filter); 1517 } 1518 1519 private interface ContactIdQuery { 1520 String[] COLUMNS = new String[] { 1521 Contacts._ID 1522 }; 1523 1524 int _ID = 0; 1525 } 1526 1527 /** 1528 * Loads contacts with specified IDs and returns them in the order of IDs in the 1529 * supplied list. 1530 */ 1531 private Cursor queryMatchingContacts(SQLiteQueryBuilder qb, SQLiteDatabase db, long contactId, 1532 String[] projection, List<MatchScore> bestMatches, int maxSuggestions, String filter) { 1533 1534 StringBuilder sb = new StringBuilder(); 1535 sb.append(Contacts._ID); 1536 sb.append(" IN ("); 1537 for (int i = 0; i < bestMatches.size(); i++) { 1538 MatchScore matchScore = bestMatches.get(i); 1539 if (i != 0) { 1540 sb.append(","); 1541 } 1542 sb.append(matchScore.getContactId()); 1543 } 1544 sb.append(")"); 1545 1546 if (!TextUtils.isEmpty(filter)) { 1547 sb.append(" AND " + Contacts._ID + " IN "); 1548 mContactsProvider.appendContactFilterAsNestedQuery(sb, filter); 1549 } 1550 1551 // Run a query and find ids of best matching contacts satisfying the filter (if any) 1552 HashSet<Long> foundIds = new HashSet<Long>(); 1553 Cursor cursor = db.query(qb.getTables(), ContactIdQuery.COLUMNS, sb.toString(), 1554 null, null, null, null); 1555 try { 1556 while(cursor.moveToNext()) { 1557 foundIds.add(cursor.getLong(ContactIdQuery._ID)); 1558 } 1559 } finally { 1560 cursor.close(); 1561 } 1562 1563 // Exclude all contacts that did not match the filter 1564 Iterator<MatchScore> iter = bestMatches.iterator(); 1565 while (iter.hasNext()) { 1566 long id = iter.next().getContactId(); 1567 if (!foundIds.contains(id)) { 1568 iter.remove(); 1569 } 1570 } 1571 1572 // Limit the number of returned suggestions 1573 if (bestMatches.size() > maxSuggestions) { 1574 bestMatches = bestMatches.subList(0, maxSuggestions); 1575 } 1576 1577 // Build an in-clause with the remaining contact IDs 1578 sb.setLength(0); 1579 sb.append(Contacts._ID); 1580 sb.append(" IN ("); 1581 for (int i = 0; i < bestMatches.size(); i++) { 1582 MatchScore matchScore = bestMatches.get(i); 1583 if (i != 0) { 1584 sb.append(","); 1585 } 1586 sb.append(matchScore.getContactId()); 1587 } 1588 sb.append(")"); 1589 1590 // Run the final query with the required projection and contact IDs found by the first query 1591 cursor = qb.query(db, projection, sb.toString(), null, null, null, Contacts._ID); 1592 1593 // Build a sorted list of discovered IDs 1594 ArrayList<Long> sortedContactIds = new ArrayList<Long>(bestMatches.size()); 1595 for (MatchScore matchScore : bestMatches) { 1596 sortedContactIds.add(matchScore.getContactId()); 1597 } 1598 1599 Collections.sort(sortedContactIds); 1600 1601 // Map cursor indexes according to the descending order of match scores 1602 int[] positionMap = new int[bestMatches.size()]; 1603 for (int i = 0; i < positionMap.length; i++) { 1604 long id = bestMatches.get(i).getContactId(); 1605 positionMap[i] = sortedContactIds.indexOf(id); 1606 } 1607 1608 return new ReorderingCursorWrapper(cursor, positionMap); 1609 } 1610 1611 private interface RawContactIdQuery { 1612 String TABLE = Tables.RAW_CONTACTS; 1613 1614 String[] COLUMNS = new String[] { 1615 RawContacts._ID 1616 }; 1617 1618 int _ID = 0; 1619 } 1620 1621 /** 1622 * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the 1623 * descending order of match score. 1624 */ 1625 private List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId) { 1626 1627 MatchCandidateList candidates = new MatchCandidateList(); 1628 ContactMatcher matcher = new ContactMatcher(); 1629 1630 // Don't aggregate a contact with itself 1631 matcher.keepOut(contactId); 1632 1633 final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS, 1634 RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null); 1635 try { 1636 while (c.moveToNext()) { 1637 long rawContactId = c.getLong(RawContactIdQuery._ID); 1638 updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates, 1639 matcher); 1640 } 1641 } finally { 1642 c.close(); 1643 } 1644 1645 return matcher.pickBestMatches(ContactMatcher.SCORE_THRESHOLD_SUGGEST); 1646 } 1647 1648 /** 1649 * Computes scores for contacts that have matching data rows. 1650 */ 1651 private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, 1652 long rawContactId, MatchCandidateList candidates, ContactMatcher matcher) { 1653 1654 updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher); 1655 updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher); 1656 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher); 1657 loadNameMatchCandidates(db, rawContactId, candidates, false); 1658 lookupApproximateNameMatches(db, candidates, matcher); 1659 } 1660 } 1661