1 /* 2 * Copyright (C) 2015 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.aggregation; 18 19 import static com.android.providers.contacts.aggregation.util.RawContactMatcher.SCORE_THRESHOLD_PRIMARY; 20 import static com.android.providers.contacts.aggregation.util.RawContactMatcher.SCORE_THRESHOLD_SECONDARY; 21 import static com.android.providers.contacts.aggregation.util.RawContactMatcher.SCORE_THRESHOLD_SUGGEST; 22 import android.database.Cursor; 23 import android.database.sqlite.SQLiteDatabase; 24 import android.provider.ContactsContract.AggregationExceptions; 25 import android.provider.ContactsContract.CommonDataKinds.Email; 26 import android.provider.ContactsContract.CommonDataKinds.Identity; 27 import android.provider.ContactsContract.CommonDataKinds.Phone; 28 import android.provider.ContactsContract.Contacts.AggregationSuggestions; 29 import android.provider.ContactsContract.Data; 30 import android.provider.ContactsContract.FullNameStyle; 31 import android.provider.ContactsContract.PhotoFiles; 32 import android.provider.ContactsContract.RawContacts; 33 import android.text.TextUtils; 34 import android.util.Log; 35 import com.android.providers.contacts.ContactsDatabaseHelper; 36 import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns; 37 import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns; 38 import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType; 39 import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns; 40 import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns; 41 import com.android.providers.contacts.ContactsDatabaseHelper.Tables; 42 import com.android.providers.contacts.ContactsProvider2; 43 import com.android.providers.contacts.NameSplitter; 44 import com.android.providers.contacts.PhotoPriorityResolver; 45 import com.android.providers.contacts.TransactionContext; 46 import com.android.providers.contacts.aggregation.util.CommonNicknameCache; 47 import com.android.providers.contacts.aggregation.util.ContactAggregatorHelper; 48 import com.android.providers.contacts.aggregation.util.MatchScore; 49 import com.android.providers.contacts.aggregation.util.RawContactMatcher; 50 import com.android.providers.contacts.aggregation.util.RawContactMatchingCandidates; 51 import com.android.providers.contacts.database.ContactsTableUtil; 52 import com.google.android.collect.Sets; 53 import com.google.common.collect.HashMultimap; 54 import com.google.common.collect.Multimap; 55 56 import java.util.ArrayList; 57 import java.util.HashSet; 58 import java.util.List; 59 import java.util.Map; 60 import java.util.Set; 61 62 /** 63 * ContactAggregator2 deals with aggregating contact information with sufficient matching data 64 * points. E.g., two John Doe contacts with same phone numbers are presumed to be the same 65 * person unless the user declares otherwise. 66 */ 67 public class ContactAggregator2 extends AbstractContactAggregator { 68 69 // Possible operation types for contacts aggregation. 70 private static final int CREATE_NEW_CONTACT = 1; 71 private static final int KEEP_INTACT = 0; 72 private static final int RE_AGGREGATE = -1; 73 74 private final RawContactMatcher mMatcher = new RawContactMatcher(); 75 76 /** 77 * Constructor. 78 */ 79 public ContactAggregator2(ContactsProvider2 contactsProvider, 80 ContactsDatabaseHelper contactsDatabaseHelper, 81 PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter, 82 CommonNicknameCache commonNicknameCache) { 83 super(contactsProvider, contactsDatabaseHelper, photoPriorityResolver, nameSplitter, 84 commonNicknameCache); 85 } 86 87 /** 88 * Given a specific raw contact, finds all matching raw contacts and re-aggregate them 89 * based on the matching connectivity. 90 */ 91 synchronized void aggregateContact(TransactionContext txContext, SQLiteDatabase db, 92 long rawContactId, long accountId, long currentContactId, 93 MatchCandidateList candidates) { 94 95 if (!needAggregate(db, rawContactId)) { 96 if (VERBOSE_LOGGING) { 97 Log.v(TAG, "Skip rid=" + rawContactId + " which has already been aggregated."); 98 } 99 return; 100 } 101 102 if (VERBOSE_LOGGING) { 103 Log.v(TAG, "aggregateContact: rid=" + rawContactId + " cid=" + currentContactId); 104 } 105 106 int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT; 107 108 Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId); 109 if (aggModeObject != null) { 110 aggregationMode = aggModeObject; 111 } 112 113 RawContactMatcher matcher = new RawContactMatcher(); 114 RawContactMatchingCandidates matchingCandidates = new RawContactMatchingCandidates(); 115 if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) { 116 // If this is a newly inserted contact or a visible contact, look for 117 // data matches. 118 if (currentContactId == 0 119 || mDbHelper.isContactInDefaultDirectory(db, currentContactId)) { 120 // Find the set of matching candidates 121 matchingCandidates = findRawContactMatchingCandidates(db, rawContactId, candidates, 122 matcher); 123 } 124 } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) { 125 return; 126 } 127 128 // # of raw_contacts in the [currentContactId] contact excluding the [rawContactId] 129 // raw_contact. 130 long currentContactContentsCount = 0; 131 132 if (currentContactId != 0) { 133 mRawContactCountQuery.bindLong(1, currentContactId); 134 mRawContactCountQuery.bindLong(2, rawContactId); 135 currentContactContentsCount = mRawContactCountQuery.simpleQueryForLong(); 136 } 137 138 // Set aggregation operation, i.e., re-aggregate, keep intact, or create new contact based 139 // on the number of matching candidates and the number of raw_contacts in the 140 // [currentContactId] excluding the [rawContactId]. 141 final int operation; 142 final int candidatesCount = matchingCandidates.getCount(); 143 if (candidatesCount >= AGGREGATION_CONTACT_SIZE_LIMIT) { 144 operation = KEEP_INTACT; 145 if (VERBOSE_LOGGING) { 146 Log.v(TAG, "Too many matching raw contacts (" + candidatesCount 147 + ") are found, so skip aggregation"); 148 } 149 } else if (candidatesCount > 0) { 150 operation = RE_AGGREGATE; 151 } else { 152 // When there is no matching raw contact found, if there are no other raw contacts in 153 // the current aggregate, we might as well reuse it. Also, if the aggregation mode is 154 // SUSPENDED, we must reuse the same aggregate. 155 if (currentContactId != 0 156 && (currentContactContentsCount == 0 157 || aggregationMode == RawContacts.AGGREGATION_MODE_SUSPENDED)) { 158 operation = KEEP_INTACT; 159 } else { 160 operation = CREATE_NEW_CONTACT; 161 } 162 } 163 164 if (operation == KEEP_INTACT) { 165 // Aggregation unchanged 166 if (VERBOSE_LOGGING) { 167 Log.v(TAG, "Aggregation unchanged"); 168 } 169 markAggregated(db, String.valueOf(rawContactId)); 170 } else if (operation == CREATE_NEW_CONTACT) { 171 // create new contact for [rawContactId] 172 if (VERBOSE_LOGGING) { 173 Log.v(TAG, "create new contact for rid=" + rawContactId); 174 } 175 createContactForRawContacts(db, txContext, Sets.newHashSet(rawContactId), null); 176 if (currentContactContentsCount > 0) { 177 updateAggregateData(txContext, currentContactId); 178 } 179 markAggregated(db, String.valueOf(rawContactId)); 180 } else { 181 // re-aggregate 182 if (VERBOSE_LOGGING) { 183 Log.v(TAG, "Re-aggregating rids=" + rawContactId + "," 184 + TextUtils.join(",", matchingCandidates.getRawContactIdSet())); 185 } 186 reAggregateRawContacts(txContext, db, currentContactId, rawContactId, accountId, 187 currentContactContentsCount, matchingCandidates); 188 } 189 } 190 191 private boolean needAggregate(SQLiteDatabase db, long rawContactId) { 192 final String sql = "SELECT " + RawContacts._ID + " FROM " + Tables.RAW_CONTACTS + 193 " WHERE " + RawContactsColumns.AGGREGATION_NEEDED + "=1" + 194 " AND " + RawContacts._ID + "=?"; 195 196 mSelectionArgs1[0] = String.valueOf(rawContactId); 197 final Cursor cursor = db.rawQuery(sql, mSelectionArgs1); 198 199 try { 200 return cursor.getCount() != 0; 201 } finally { 202 cursor.close(); 203 } 204 } 205 /** 206 * Find the set of matching raw contacts for given rawContactId. Add all the raw contact 207 * candidates with matching scores > threshold to RawContactMatchingCandidates. Keep doing 208 * this for every raw contact in RawContactMatchingCandidates until is it not changing. 209 */ 210 private RawContactMatchingCandidates findRawContactMatchingCandidates(SQLiteDatabase db, long 211 rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) { 212 updateMatchScores(db, rawContactId, candidates, matcher); 213 final RawContactMatchingCandidates matchingCandidates = new RawContactMatchingCandidates( 214 matcher.pickBestMatches()); 215 Set<Long> newIds = new HashSet<>(); 216 newIds.addAll(matchingCandidates.getRawContactIdSet()); 217 // Keep doing the following until no new raw contact candidate is found. 218 while (!newIds.isEmpty()) { 219 if (matchingCandidates.getCount() >= AGGREGATION_CONTACT_SIZE_LIMIT) { 220 return matchingCandidates; 221 } 222 final Set<Long> tmpIdSet = new HashSet<>(); 223 for (long rId : newIds) { 224 final RawContactMatcher rMatcher = new RawContactMatcher(); 225 updateMatchScores(db, rId, new MatchCandidateList(), 226 rMatcher); 227 List<MatchScore> newMatches = rMatcher.pickBestMatches(); 228 for (MatchScore newMatch : newMatches) { 229 final long newRawContactId = newMatch.getRawContactId(); 230 if (!matchingCandidates.getRawContactIdSet().contains(newRawContactId)) { 231 tmpIdSet.add(newRawContactId); 232 matchingCandidates.add(newMatch); 233 } 234 } 235 } 236 newIds.clear(); 237 newIds.addAll(tmpIdSet); 238 } 239 return matchingCandidates; 240 } 241 242 /** 243 * Find out which mime-types are shared by more than one contacts for {@code rawContactIds}. 244 * Clear the is_super_primary settings for these mime-types. 245 * {@code rawContactIds} should be a comma separated ID list. 246 */ 247 private void clearSuperPrimarySetting(SQLiteDatabase db, String rawContactIds) { 248 final String sql = 249 "SELECT " + DataColumns.MIMETYPE_ID + ", count(1) c FROM " + 250 Tables.DATA +" WHERE " + Data.IS_SUPER_PRIMARY + " = 1 AND " + 251 Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ") group by " + 252 DataColumns.MIMETYPE_ID + " HAVING c > 1"; 253 254 // Find out which mime-types exist with is_super_primary=true on more then one contacts. 255 int index = 0; 256 final StringBuilder mimeTypeCondition = new StringBuilder(); 257 mimeTypeCondition.append(" AND " + DataColumns.MIMETYPE_ID + " IN ("); 258 259 final Cursor c = db.rawQuery(sql, null); 260 try { 261 c.moveToPosition(-1); 262 while (c.moveToNext()) { 263 if (index > 0) { 264 mimeTypeCondition.append(','); 265 } 266 mimeTypeCondition.append(c.getLong((0))); 267 index++; 268 } 269 } finally { 270 c.close(); 271 } 272 273 if (index == 0) { 274 return; 275 } 276 277 // Clear is_super_primary setting for all the mime-types with is_super_primary=true 278 // in both raw contact of rawContactId and raw contacts of contactId 279 String superPrimaryUpdateSql = "UPDATE " + Tables.DATA + 280 " SET " + Data.IS_SUPER_PRIMARY + "=0" + 281 " WHERE " + Data.RAW_CONTACT_ID + 282 " IN (" + rawContactIds + ")"; 283 284 mimeTypeCondition.append(')'); 285 superPrimaryUpdateSql += mimeTypeCondition.toString(); 286 db.execSQL(superPrimaryUpdateSql); 287 } 288 289 private String buildExceptionMatchingSql(String rawContactIdSet1, String rawContactIdSet2, 290 int aggregationType, boolean countOnly) { 291 final String idPairSelection = "SELECT " + AggregationExceptions.RAW_CONTACT_ID1 + ", " + 292 AggregationExceptions.RAW_CONTACT_ID2; 293 final String sql = 294 " FROM " + Tables.AGGREGATION_EXCEPTIONS + 295 " WHERE " + AggregationExceptions.RAW_CONTACT_ID1 + " IN (" + 296 rawContactIdSet1 + ")" + 297 " AND " + AggregationExceptions.RAW_CONTACT_ID2 + " IN (" + rawContactIdSet2 + ")" + 298 " AND " + AggregationExceptions.TYPE + "=" + aggregationType; 299 return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql : 300 idPairSelection + sql; 301 } 302 303 /** 304 * Re-aggregate rawContact of {@code rawContactId} and all the raw contacts of 305 * {@code matchingCandidates} into connected components. This only happens when a given 306 * raw contacts cannot be joined with its best matching contacts directly. 307 * 308 * Two raw contacts are considered connected if they share at least one email address, phone 309 * number or identity. Create new contact for each connected component except the very first 310 * one that doesn't contain rawContactId of {@code rawContactId}. 311 */ 312 private void reAggregateRawContacts(TransactionContext txContext, SQLiteDatabase db, 313 long currentCidForRawContact, long rawContactId, long accountId, 314 long currentContactContentsCount, RawContactMatchingCandidates matchingCandidates) { 315 // Find the connected component based on the aggregation exceptions or 316 // identity/email/phone matching for all the raw contacts of [contactId] and the give 317 // raw contact. 318 final Set<Long> allIds = new HashSet<>(); 319 allIds.add(rawContactId); 320 allIds.addAll(matchingCandidates.getRawContactIdSet()); 321 final Set<Set<Long>> connectedRawContactSets = findConnectedRawContacts(db, allIds); 322 323 final Map<Long, Long> rawContactsToAccounts = matchingCandidates.getRawContactToAccount(); 324 rawContactsToAccounts.put(rawContactId, accountId); 325 ContactAggregatorHelper.mergeComponentsWithDisjointAccounts(connectedRawContactSets, 326 rawContactsToAccounts); 327 breakComponentsByExceptions(db, connectedRawContactSets); 328 329 // Create new contact for each connected component. Use the first reusable contactId if 330 // possible. If no reusable contactId found, create new contact for the connected component. 331 // Update aggregate data for all the contactIds touched by this connected component, 332 for (Set<Long> connectedRawContactIds : connectedRawContactSets) { 333 Long contactId = null; 334 Set<Long> cidsNeedToBeUpdated = new HashSet<>(); 335 if (connectedRawContactIds.contains(rawContactId)) { 336 // If there is no other raw contacts aggregated with the given raw contact currently 337 // or all the raw contacts in [currentCidForRawContact] are still in the same 338 // connected component, we might as well reuse it. 339 if (currentCidForRawContact != 0 && 340 (currentContactContentsCount == 0) || 341 canBeReused(db, currentCidForRawContact, connectedRawContactIds)) { 342 contactId = currentCidForRawContact; 343 for (Long connectedRawContactId : connectedRawContactIds) { 344 Long cid = matchingCandidates.getContactId(connectedRawContactId); 345 if (cid != null && !cid.equals(contactId)) { 346 cidsNeedToBeUpdated.add(cid); 347 } 348 } 349 } else if (currentCidForRawContact != 0){ 350 cidsNeedToBeUpdated.add(currentCidForRawContact); 351 } 352 } else { 353 boolean foundContactId = false; 354 for (Long connectedRawContactId : connectedRawContactIds) { 355 Long currentContactId = matchingCandidates.getContactId(connectedRawContactId); 356 if (!foundContactId && currentContactId != null && 357 canBeReused(db, currentContactId, connectedRawContactIds)) { 358 contactId = currentContactId; 359 foundContactId = true; 360 } else { 361 cidsNeedToBeUpdated.add(currentContactId); 362 } 363 } 364 } 365 final String connectedRids = TextUtils.join(",", connectedRawContactIds); 366 clearSuperPrimarySetting(db, connectedRids); 367 createContactForRawContacts(db, txContext, connectedRawContactIds, contactId); 368 // re-aggregate 369 if (VERBOSE_LOGGING) { 370 Log.v(TAG, "Aggregating rids=" + connectedRawContactIds); 371 } 372 markAggregated(db, connectedRids); 373 374 for (Long cid : cidsNeedToBeUpdated) { 375 long currentRcCount = 0; 376 if (cid != 0) { 377 mRawContactCountQuery.bindLong(1, cid); 378 mRawContactCountQuery.bindLong(2, 0); 379 currentRcCount = mRawContactCountQuery.simpleQueryForLong(); 380 } 381 382 if (currentRcCount == 0) { 383 // Delete a contact if it doesn't contain anything 384 ContactsTableUtil.deleteContact(db, cid); 385 mAggregatedPresenceDelete.bindLong(1, cid); 386 mAggregatedPresenceDelete.execute(); 387 } else { 388 updateAggregateData(txContext, cid); 389 } 390 } 391 } 392 } 393 394 /** 395 * Check if contactId can be reused as the contact Id for new aggregation of all the 396 * connectedRawContactIds. If connectedRawContactIds set contains all the raw contacts 397 * currently aggregated under contactId, return true; Otherwise, return false. 398 */ 399 private boolean canBeReused(SQLiteDatabase db, Long contactId, 400 Set<Long> connectedRawContactIds) { 401 final String sql = "SELECT " + RawContactsColumns.CONCRETE_ID + " FROM " + 402 Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "=? AND " + 403 RawContacts.DELETED + "=0"; 404 mSelectionArgs1[0] = String.valueOf(contactId); 405 final Cursor cursor = db.rawQuery(sql, mSelectionArgs1); 406 try { 407 cursor.moveToPosition(-1); 408 while (cursor.moveToNext()) { 409 if (!connectedRawContactIds.contains(cursor.getLong(0))) { 410 return false; 411 } 412 } 413 } finally { 414 cursor.close(); 415 } 416 return true; 417 } 418 419 /** 420 * Separate all the raw_contacts which has "SEPARATE" aggregation exception to another 421 * raw_contacts in the same component. 422 */ 423 private void breakComponentsByExceptions(SQLiteDatabase db, 424 Set<Set<Long>> connectedRawContacts) { 425 final Set<Set<Long>> tmpSets = new HashSet<>(connectedRawContacts); 426 for (Set<Long> component : tmpSets) { 427 final String rawContacts = TextUtils.join(",", component); 428 // If "SEPARATE" exception is found inside an connected component [component], 429 // remove the [component] from [connectedRawContacts], and create new connected 430 // components for all raw contacts of [component] solely based on "JOIN" exceptions 431 // and add them to [connectedRawContacts]. 432 if (isFirstColumnGreaterThanZero(db, buildExceptionMatchingSql(rawContacts, rawContacts, 433 AggregationExceptions.TYPE_KEEP_SEPARATE, /* countOnly =*/true))) { 434 Multimap<Long, Long> joinPairs = HashMultimap.create(); 435 findIdPairs(db, buildExceptionMatchingSql(rawContacts, rawContacts), joinPairs); 436 connectedRawContacts.remove(component); 437 connectedRawContacts.addAll( 438 ContactAggregatorHelper.findConnectedComponents(component, joinPairs)); 439 } 440 } 441 } 442 443 /** 444 * Ensures that automatic aggregation rules are followed after a contact 445 * becomes visible or invisible. Specifically, consider this case: there are 446 * three contacts named Foo. Two of them come from account A1 and one comes 447 * from account A2. The aggregation rules say that in this case none of the 448 * three Foo's should be aggregated: two of them are in the same account, so 449 * they don't get aggregated; the third has two affinities, so it does not 450 * join either of them. 451 * <p> 452 * Consider what happens if one of the "Foo"s from account A1 becomes 453 * invisible. Nothing stands in the way of aggregating the other two 454 * anymore, so they should get joined. 455 * <p> 456 * What if the invisible "Foo" becomes visible after that? We should split the 457 * aggregate between the other two. 458 */ 459 public void updateAggregationAfterVisibilityChange(long contactId) { 460 SQLiteDatabase db = mDbHelper.getWritableDatabase(); 461 boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId); 462 if (visible) { 463 markContactForAggregation(db, contactId); 464 } else { 465 // Find all contacts that _could be_ aggregated with this one and 466 // rerun aggregation for all of them 467 mSelectionArgs1[0] = String.valueOf(contactId); 468 Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS, 469 RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null); 470 try { 471 while (cursor.moveToNext()) { 472 long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID); 473 mMatcher.clear(); 474 475 updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher); 476 updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher); 477 List<MatchScore> bestMatches = 478 mMatcher.pickBestMatches(SCORE_THRESHOLD_PRIMARY); 479 for (MatchScore matchScore : bestMatches) { 480 markContactForAggregation(db, matchScore.getContactId()); 481 } 482 483 mMatcher.clear(); 484 updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher); 485 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher); 486 bestMatches = 487 mMatcher.pickBestMatches(SCORE_THRESHOLD_SECONDARY); 488 for (MatchScore matchScore : bestMatches) { 489 markContactForAggregation(db, matchScore.getContactId()); 490 } 491 } 492 } finally { 493 cursor.close(); 494 } 495 } 496 } 497 498 /** 499 * Computes match scores based on exceptions entered by the user: always match and never match. 500 */ 501 private void updateMatchScoresBasedOnExceptions(SQLiteDatabase db, long rawContactId, 502 RawContactMatcher matcher) { 503 if (!mAggregationExceptionIdsValid) { 504 prefetchAggregationExceptionIds(db); 505 } 506 507 // If there are no aggregation exceptions involving this raw contact, there is no need to 508 // run a query and we can just return -1, which stands for "nothing found" 509 if (!mAggregationExceptionIds.contains(rawContactId)) { 510 return; 511 } 512 513 final Cursor c = db.query(AggregateExceptionQuery.TABLE, 514 AggregateExceptionQuery.COLUMNS, 515 AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId 516 + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId, 517 null, null, null, null); 518 519 try { 520 while (c.moveToNext()) { 521 int type = c.getInt(AggregateExceptionQuery.TYPE); 522 long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1); 523 long contactId = -1; 524 long rId = -1; 525 long accountId = -1; 526 if (rawContactId == rawContactId1) { 527 if (!c.isNull(AggregateExceptionQuery.RAW_CONTACT_ID2)) { 528 rId = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID2); 529 contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2); 530 accountId = c.getLong(AggregateExceptionQuery.ACCOUNT_ID2); 531 } 532 } else { 533 if (!c.isNull(AggregateExceptionQuery.RAW_CONTACT_ID1)) { 534 rId = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1); 535 contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1); 536 accountId = c.getLong(AggregateExceptionQuery.ACCOUNT_ID1); 537 } 538 } 539 if (rId != -1) { 540 if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) { 541 matcher.keepIn(rId, contactId, accountId); 542 } else { 543 matcher.keepOut(rId, contactId, accountId); 544 } 545 } 546 } 547 } finally { 548 c.close(); 549 } 550 } 551 552 /** 553 * Finds contacts with exact identity matches to the the specified raw contact. 554 */ 555 private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId, 556 RawContactMatcher matcher) { 557 mSelectionArgs2[0] = String.valueOf(rawContactId); 558 mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity); 559 Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS, 560 IdentityLookupMatchQuery.SELECTION, 561 mSelectionArgs2, RawContacts.CONTACT_ID, null, null); 562 try { 563 while (c.moveToNext()) { 564 final long rId = c.getLong(IdentityLookupMatchQuery.RAW_CONTACT_ID); 565 if (rId == rawContactId) { 566 continue; 567 } 568 final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID); 569 final long accountId = c.getLong(IdentityLookupMatchQuery.ACCOUNT_ID); 570 matcher.matchIdentity(rId, contactId, accountId); 571 } 572 } finally { 573 c.close(); 574 } 575 } 576 577 /** 578 * Finds contacts with names matching the name of the specified raw contact. 579 */ 580 private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId, 581 RawContactMatcher matcher) { 582 mSelectionArgs1[0] = String.valueOf(rawContactId); 583 Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS, 584 NameLookupMatchQuery.SELECTION, 585 mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING); 586 try { 587 while (c.moveToNext()) { 588 long rId = c.getLong(NameLookupMatchQuery.RAW_CONTACT_ID); 589 if (rId == rawContactId) { 590 continue; 591 } 592 long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID); 593 long accountId = c.getLong(NameLookupMatchQuery.ACCOUNT_ID); 594 String name = c.getString(NameLookupMatchQuery.NAME); 595 int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A); 596 int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B); 597 matcher.matchName(rId, contactId, accountId, nameTypeA, name, 598 nameTypeB, name, RawContactMatcher.MATCHING_ALGORITHM_EXACT); 599 if (nameTypeA == NameLookupType.NICKNAME && 600 nameTypeB == NameLookupType.NICKNAME) { 601 matcher.updateScoreWithNicknameMatch(rId, contactId, accountId); 602 } 603 } 604 } finally { 605 c.close(); 606 } 607 } 608 609 private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId, 610 RawContactMatcher matcher) { 611 mSelectionArgs2[0] = String.valueOf(rawContactId); 612 mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail); 613 Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS, 614 EmailLookupQuery.SELECTION, 615 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING); 616 try { 617 while (c.moveToNext()) { 618 long rId = c.getLong(EmailLookupQuery.RAW_CONTACT_ID); 619 if (rId == rawContactId) { 620 continue; 621 } 622 long contactId = c.getLong(EmailLookupQuery.CONTACT_ID); 623 long accountId = c.getLong(EmailLookupQuery.ACCOUNT_ID); 624 matcher.updateScoreWithEmailMatch(rId, contactId, accountId); 625 } 626 } finally { 627 c.close(); 628 } 629 } 630 631 /** 632 * Finds contacts with names matching the specified name. 633 */ 634 private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query, 635 MatchCandidateList candidates, RawContactMatcher matcher) { 636 candidates.clear(); 637 NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder( 638 mNameSplitter, candidates); 639 builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED); 640 if (builder.isEmpty()) { 641 return; 642 } 643 644 Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE, 645 NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null, 646 null, PRIMARY_HIT_LIMIT_STRING); 647 try { 648 while (c.moveToNext()) { 649 long rId = c.getLong(NameLookupMatchQueryWithParameter.RAW_CONTACT_ID); 650 long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID); 651 long accountId = c.getLong(NameLookupMatchQueryWithParameter.ACCOUNT_ID); 652 String name = c.getString(NameLookupMatchQueryWithParameter.NAME); 653 int nameTypeA = builder.getLookupType(name); 654 int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE); 655 matcher.matchName(rId, contactId, accountId, nameTypeA, name, nameTypeB, name, 656 RawContactMatcher.MATCHING_ALGORITHM_EXACT); 657 if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) { 658 matcher.updateScoreWithNicknameMatch(rId, contactId, accountId); 659 } 660 } 661 } finally { 662 c.close(); 663 } 664 } 665 666 private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId, 667 RawContactMatcher matcher) { 668 mSelectionArgs2[0] = String.valueOf(rawContactId); 669 mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter(); 670 Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS, 671 PhoneLookupQuery.SELECTION, 672 mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING); 673 try { 674 while (c.moveToNext()) { 675 long rId = c.getLong(PhoneLookupQuery.RAW_CONTACT_ID); 676 if (rId == rawContactId) { 677 continue; 678 } 679 long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID); 680 long accountId = c.getLong(PhoneLookupQuery.ACCOUNT_ID); 681 matcher.updateScoreWithPhoneNumberMatch(rId, contactId, accountId); 682 } 683 } finally { 684 c.close(); 685 } 686 } 687 688 /** 689 * Loads name lookup rows for approximate name matching and updates match scores based on that 690 * data. 691 */ 692 private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates, 693 RawContactMatcher matcher) { 694 HashSet<String> firstLetters = new HashSet<>(); 695 for (int i = 0; i < candidates.mCount; i++) { 696 final NameMatchCandidate candidate = candidates.mList.get(i); 697 if (candidate.mName.length() >= 2) { 698 String firstLetter = candidate.mName.substring(0, 2); 699 if (!firstLetters.contains(firstLetter)) { 700 firstLetters.add(firstLetter); 701 final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '" 702 + firstLetter + "*') AND " 703 + "(" + NameLookupColumns.NAME_TYPE + " IN(" 704 + NameLookupType.NAME_COLLATION_KEY + "," 705 + NameLookupType.EMAIL_BASED_NICKNAME + "," 706 + NameLookupType.NICKNAME + ")) AND " 707 + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 708 matchAllCandidates(db, selection, candidates, matcher, 709 RawContactMatcher.MATCHING_ALGORITHM_APPROXIMATE, 710 String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT)); 711 } 712 } 713 } 714 } 715 716 private interface ContactNameLookupQuery { 717 String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS; 718 719 String[] COLUMNS = new String[] { 720 RawContacts._ID, 721 RawContacts.CONTACT_ID, 722 RawContactsColumns.ACCOUNT_ID, 723 NameLookupColumns.NORMALIZED_NAME, 724 NameLookupColumns.NAME_TYPE 725 }; 726 727 int RAW_CONTACT_ID = 0; 728 int CONTACT_ID = 1; 729 int ACCOUNT_ID = 2; 730 int NORMALIZED_NAME = 3; 731 int NAME_TYPE = 4; 732 } 733 734 /** 735 * Loads all candidate rows from the name lookup table and updates match scores based 736 * on that data. 737 */ 738 private void matchAllCandidates(SQLiteDatabase db, String selection, 739 MatchCandidateList candidates, RawContactMatcher matcher, int algorithm, String limit) { 740 final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS, 741 selection, null, null, null, null, limit); 742 743 try { 744 while (c.moveToNext()) { 745 Long rawContactId = c.getLong(ContactNameLookupQuery.RAW_CONTACT_ID); 746 Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID); 747 Long accountId = c.getLong(ContactNameLookupQuery.ACCOUNT_ID); 748 String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME); 749 int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE); 750 751 // Note the N^2 complexity of the following fragment. This is not a huge concern 752 // since the number of candidates is very small and in general secondary hits 753 // in the absence of primary hits are rare. 754 for (int i = 0; i < candidates.mCount; i++) { 755 NameMatchCandidate candidate = candidates.mList.get(i); 756 matcher.matchName(rawContactId, contactId, accountId, candidate.mLookupType, 757 candidate.mName, nameType, name, algorithm); 758 } 759 } 760 } finally { 761 c.close(); 762 } 763 } 764 765 private interface PhotoFileQuery { 766 final String[] COLUMNS = new String[] { 767 PhotoFiles.HEIGHT, 768 PhotoFiles.WIDTH, 769 PhotoFiles.FILESIZE 770 }; 771 772 int HEIGHT = 0; 773 int WIDTH = 1; 774 int FILESIZE = 2; 775 } 776 777 private class PhotoEntry implements Comparable<PhotoEntry> { 778 // Pixel count (width * height) for the image. 779 final int pixelCount; 780 781 // File size (in bytes) of the image. Not populated if the image is a thumbnail. 782 final int fileSize; 783 784 private PhotoEntry(int pixelCount, int fileSize) { 785 this.pixelCount = pixelCount; 786 this.fileSize = fileSize; 787 } 788 789 @Override 790 public int compareTo(PhotoEntry pe) { 791 if (pe == null) { 792 return -1; 793 } 794 if (pixelCount == pe.pixelCount) { 795 return pe.fileSize - fileSize; 796 } else { 797 return pe.pixelCount - pixelCount; 798 } 799 } 800 } 801 802 /** 803 * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the 804 * descending order of match score. 805 * @param parameters 806 */ 807 protected List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId, 808 ArrayList<AggregationSuggestionParameter> parameters) { 809 810 MatchCandidateList candidates = new MatchCandidateList(); 811 RawContactMatcher matcher = new RawContactMatcher(); 812 813 if (parameters == null || parameters.size() == 0) { 814 final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS, 815 RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null); 816 try { 817 while (c.moveToNext()) { 818 long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID); 819 long accountId = c.getLong(RawContactIdQuery.ACCOUNT_ID); 820 // Don't aggregate a contact with its own raw contacts. 821 matcher.keepOut(rawContactId, contactId, accountId); 822 updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates, 823 matcher); 824 } 825 } finally { 826 c.close(); 827 } 828 } else { 829 updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates, 830 matcher, parameters); 831 } 832 833 return matcher.pickBestMatches(SCORE_THRESHOLD_SUGGEST); 834 } 835 836 /** 837 * Computes suggestion scores for contacts that have matching data rows. 838 * Aggregation suggestion doesn't consider aggregation exceptions, but is purely based on the 839 * raw contacts information. 840 */ 841 private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, 842 long rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) { 843 844 updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher); 845 updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher); 846 updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher); 847 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher); 848 loadNameMatchCandidates(db, rawContactId, candidates, false); 849 lookupApproximateNameMatches(db, candidates, matcher); 850 } 851 852 /** 853 * Computes scores for contacts that have matching data rows. 854 */ 855 private void updateMatchScores(SQLiteDatabase db, long rawContactId, 856 MatchCandidateList candidates, RawContactMatcher matcher) { 857 //update primary score 858 updateMatchScoresBasedOnExceptions(db, rawContactId, matcher); 859 updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher); 860 // update scores only if the raw contact doesn't have structured name 861 if (rawContactWithoutName(db, rawContactId)) { 862 updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher); 863 updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher); 864 updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher); 865 final List<Long> secondaryRawContactIds = matcher.prepareSecondaryMatchCandidates(); 866 if (secondaryRawContactIds != null 867 && secondaryRawContactIds.size() <= SECONDARY_HIT_LIMIT) { 868 updateScoreForCandidatesWithoutName(db, secondaryRawContactIds, matcher); 869 } 870 } 871 } 872 873 private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db, 874 MatchCandidateList candidates, RawContactMatcher matcher, 875 ArrayList<AggregationSuggestionParameter> parameters) { 876 for (AggregationSuggestionParameter parameter : parameters) { 877 if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) { 878 updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher); 879 } 880 881 // TODO: add support for other parameter kinds 882 } 883 } 884 885 private boolean rawContactWithoutName(SQLiteDatabase db, long rawContactId) { 886 String selection = RawContacts._ID + " =" + rawContactId; 887 final Cursor c = db.query(NullNameRawContactsIdsQuery.TABLE, 888 NullNameRawContactsIdsQuery.COLUMNS, selection, null, null, null, null); 889 890 try { 891 if (c.moveToFirst()) { 892 return TextUtils.isEmpty(c.getString(NullNameRawContactsIdsQuery.NAME)); 893 } 894 } finally { 895 c.close(); 896 } 897 return false; 898 } 899 900 /** 901 * Update scores for matches with secondary data matching but no structured name. 902 */ 903 private void updateScoreForCandidatesWithoutName(SQLiteDatabase db, 904 List<Long> secondaryRawContactIds, RawContactMatcher matcher) { 905 906 mSb.setLength(0); 907 908 mSb.append(RawContacts._ID).append(" IN ("); 909 for (int i = 0; i < secondaryRawContactIds.size(); i++) { 910 if (i != 0) { 911 mSb.append(","); 912 } 913 mSb.append(secondaryRawContactIds.get(i)); 914 } 915 mSb.append( ")"); 916 final Cursor c = db.query(NullNameRawContactsIdsQuery.TABLE, 917 NullNameRawContactsIdsQuery.COLUMNS, mSb.toString(), null, null, null, null); 918 919 try { 920 while (c.moveToNext()) { 921 Long rId = c.getLong(NullNameRawContactsIdsQuery.RAW_CONTACT_ID); 922 Long contactId = c.getLong(NullNameRawContactsIdsQuery.CONTACT_ID); 923 Long accountId = c.getLong(NullNameRawContactsIdsQuery.ACCOUNT_ID); 924 String name = c.getString(NullNameRawContactsIdsQuery.NAME); 925 if (TextUtils.isEmpty(name)) { 926 matcher.matchNoName(rId, contactId, accountId); 927 } 928 } 929 } finally { 930 c.close(); 931 } 932 } 933 934 protected interface IdentityLookupMatchQuery { 935 final String TABLE = Tables.DATA + " dataA" 936 + " JOIN " + Tables.DATA + " dataB" + 937 " ON (dataA." + Identity.NAMESPACE + "=dataB." + Identity.NAMESPACE + 938 " AND dataA." + Identity.IDENTITY + "=dataB." + Identity.IDENTITY + ")" 939 + " JOIN " + Tables.RAW_CONTACTS + 940 " ON (dataB." + Data.RAW_CONTACT_ID + " = " 941 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 942 943 final String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1" 944 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2" 945 + " AND dataA." + Identity.NAMESPACE + " NOT NULL" 946 + " AND dataA." + Identity.IDENTITY + " NOT NULL" 947 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2" 948 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 949 950 final String[] COLUMNS = new String[] { 951 RawContactsColumns.CONCRETE_ID, RawContacts.CONTACT_ID, 952 RawContactsColumns.ACCOUNT_ID 953 }; 954 955 int RAW_CONTACT_ID = 0; 956 int CONTACT_ID = 1; 957 int ACCOUNT_ID = 2; 958 } 959 960 protected interface NameLookupMatchQuery { 961 String TABLE = Tables.NAME_LOOKUP + " nameA" 962 + " JOIN " + Tables.NAME_LOOKUP + " nameB" + 963 " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "=" 964 + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")" 965 + " JOIN " + Tables.RAW_CONTACTS + 966 " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = " 967 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 968 969 String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?" 970 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 971 972 String[] COLUMNS = new String[] { 973 RawContacts._ID, 974 RawContacts.CONTACT_ID, 975 RawContactsColumns.ACCOUNT_ID, 976 "nameA." + NameLookupColumns.NORMALIZED_NAME, 977 "nameA." + NameLookupColumns.NAME_TYPE, 978 "nameB." + NameLookupColumns.NAME_TYPE, 979 }; 980 981 int RAW_CONTACT_ID = 0; 982 int CONTACT_ID = 1; 983 int ACCOUNT_ID = 2; 984 int NAME = 3; 985 int NAME_TYPE_A = 4; 986 int NAME_TYPE_B = 5; 987 } 988 989 protected interface EmailLookupQuery { 990 String TABLE = Tables.DATA + " dataA" 991 + " JOIN " + Tables.DATA + " dataB" + 992 " ON dataA." + Email.DATA + "= dataB." + Email.DATA 993 + " JOIN " + Tables.RAW_CONTACTS + 994 " ON (dataB." + Data.RAW_CONTACT_ID + " = " 995 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 996 997 String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1" 998 + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2" 999 + " AND dataA." + Email.DATA + " NOT NULL" 1000 + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2" 1001 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1002 1003 String[] COLUMNS = new String[] { 1004 Tables.RAW_CONTACTS + "." + RawContacts._ID, 1005 RawContacts.CONTACT_ID, 1006 RawContactsColumns.ACCOUNT_ID 1007 }; 1008 1009 int RAW_CONTACT_ID = 0; 1010 int CONTACT_ID = 1; 1011 int ACCOUNT_ID = 2; 1012 } 1013 1014 protected interface PhoneLookupQuery { 1015 String TABLE = Tables.PHONE_LOOKUP + " phoneA" 1016 + " JOIN " + Tables.DATA + " dataA" 1017 + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")" 1018 + " JOIN " + Tables.PHONE_LOOKUP + " phoneB" 1019 + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "=" 1020 + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")" 1021 + " JOIN " + Tables.DATA + " dataB" 1022 + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")" 1023 + " JOIN " + Tables.RAW_CONTACTS 1024 + " ON (dataB." + Data.RAW_CONTACT_ID + " = " 1025 + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")"; 1026 1027 String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?" 1028 + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", " 1029 + "dataB." + Phone.NUMBER + ",?)" 1030 + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY; 1031 1032 String[] COLUMNS = new String[] { 1033 Tables.RAW_CONTACTS + "." + RawContacts._ID, 1034 RawContacts.CONTACT_ID, 1035 RawContactsColumns.ACCOUNT_ID 1036 }; 1037 1038 int RAW_CONTACT_ID = 0; 1039 int CONTACT_ID = 1; 1040 int ACCOUNT_ID = 2; 1041 } 1042 1043 protected interface NullNameRawContactsIdsQuery { 1044 final String TABLE = Tables.RAW_CONTACTS + " LEFT OUTER JOIN " + Tables.NAME_LOOKUP 1045 + " ON "+ RawContacts._ID + " = " + NameLookupColumns.RAW_CONTACT_ID 1046 + " AND " + NameLookupColumns.NAME_TYPE + " = " + NameLookupType.NAME_EXACT; 1047 1048 final String[] COLUMNS = new String[] { 1049 RawContacts._ID, RawContacts.CONTACT_ID, RawContactsColumns.ACCOUNT_ID, 1050 NameLookupColumns.NORMALIZED_NAME}; 1051 1052 int RAW_CONTACT_ID = 0; 1053 int CONTACT_ID = 1; 1054 int ACCOUNT_ID = 2; 1055 int NAME = 3; 1056 } 1057 } 1058