RecipientAlternatesAdapter.java revision 8af0d3b6f34e03c08c8e67be2190da01c59889da
1/* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.ex.chips; 18 19import android.accounts.Account; 20import android.content.ContentResolver; 21import android.content.Context; 22import android.database.Cursor; 23import android.database.MatrixCursor; 24import android.net.Uri; 25import android.provider.ContactsContract; 26import android.provider.ContactsContract.Contacts; 27import android.text.TextUtils; 28import android.text.util.Rfc822Token; 29import android.text.util.Rfc822Tokenizer; 30import android.util.Log; 31import android.view.View; 32import android.view.ViewGroup; 33import android.widget.CursorAdapter; 34 35import com.android.ex.chips.BaseRecipientAdapter.DirectoryListQuery; 36import com.android.ex.chips.BaseRecipientAdapter.DirectorySearchParams; 37import com.android.ex.chips.DropdownChipLayouter.AdapterType; 38import com.android.ex.chips.Queries.Query; 39 40import java.util.ArrayList; 41import java.util.HashMap; 42import java.util.HashSet; 43import java.util.List; 44import java.util.Map; 45import java.util.Set; 46 47/** 48 * RecipientAlternatesAdapter backs the RecipientEditTextView for managing contacts 49 * queried by email or by phone number. 50 */ 51public class RecipientAlternatesAdapter extends CursorAdapter { 52 public static final int MAX_LOOKUPS = 50; 53 54 private final long mCurrentId; 55 56 private int mCheckedItemPosition = -1; 57 58 private OnCheckedItemChangedListener mCheckedItemChangedListener; 59 60 private static final String TAG = "RecipAlternates"; 61 62 public static final int QUERY_TYPE_EMAIL = 0; 63 public static final int QUERY_TYPE_PHONE = 1; 64 private final Long mDirectoryId; 65 private DropdownChipLayouter mDropdownChipLayouter; 66 67 private static final Map<String, String> sCorrectedPhotoUris = new HashMap<String, String>(); 68 69 public interface RecipientMatchCallback { 70 public void matchesFound(Map<String, RecipientEntry> results); 71 /** 72 * Called with all addresses that could not be resolved to valid recipients. 73 */ 74 public void matchesNotFound(Set<String> unfoundAddresses); 75 } 76 77 public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter, 78 ArrayList<String> inAddresses, Account account, RecipientMatchCallback callback) { 79 getMatchingRecipients(context, adapter, inAddresses, QUERY_TYPE_EMAIL, account, callback); 80 } 81 82 /** 83 * Get a HashMap of address to RecipientEntry that contains all contact 84 * information for a contact with the provided address, if one exists. This 85 * may block the UI, so run it in an async task. 86 * 87 * @param context Context. 88 * @param inAddresses Array of addresses on which to perform the lookup. 89 * @param callback RecipientMatchCallback called when a match or matches are found. 90 */ 91 public static void getMatchingRecipients(Context context, BaseRecipientAdapter adapter, 92 ArrayList<String> inAddresses, int addressType, Account account, 93 RecipientMatchCallback callback) { 94 Queries.Query query; 95 if (addressType == QUERY_TYPE_EMAIL) { 96 query = Queries.EMAIL; 97 } else { 98 query = Queries.PHONE; 99 } 100 int addressesSize = Math.min(MAX_LOOKUPS, inAddresses.size()); 101 HashSet<String> addresses = new HashSet<String>(); 102 StringBuilder bindString = new StringBuilder(); 103 // Create the "?" string and set up arguments. 104 for (int i = 0; i < addressesSize; i++) { 105 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); 106 addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); 107 bindString.append("?"); 108 if (i < addressesSize - 1) { 109 bindString.append(","); 110 } 111 } 112 113 if (Log.isLoggable(TAG, Log.DEBUG)) { 114 Log.d(TAG, "Doing reverse lookup for " + addresses.toString()); 115 } 116 117 String[] addressArray = new String[addresses.size()]; 118 addresses.toArray(addressArray); 119 HashMap<String, RecipientEntry> recipientEntries = null; 120 Cursor c = null; 121 122 try { 123 c = context.getContentResolver().query( 124 query.getContentUri(), 125 query.getProjection(), 126 query.getProjection()[Queries.Query.DESTINATION] + " IN (" 127 + bindString.toString() + ")", addressArray, null); 128 recipientEntries = processContactEntries(c, null /* directoryId */); 129 callback.matchesFound(recipientEntries); 130 } finally { 131 if (c != null) { 132 c.close(); 133 } 134 } 135 136 final Set<String> matchesNotFound = new HashSet<String>(); 137 138 getMatchingRecipientsFromDirectoryQueries(context, recipientEntries, 139 addresses, account, matchesNotFound, query, callback); 140 141 getMatchingRecipientsFromExtensionMatcher(adapter, matchesNotFound, callback); 142 } 143 144 public static void getMatchingRecipientsFromDirectoryQueries(Context context, 145 Map<String, RecipientEntry> recipientEntries, Set<String> addresses, 146 Account account, Set<String> matchesNotFound, 147 RecipientMatchCallback callback) { 148 getMatchingRecipientsFromDirectoryQueries( 149 context, recipientEntries, addresses, account, 150 matchesNotFound, Queries.EMAIL, callback); 151 } 152 153 private static void getMatchingRecipientsFromDirectoryQueries(Context context, 154 Map<String, RecipientEntry> recipientEntries, Set<String> addresses, 155 Account account, Set<String> matchesNotFound, Queries.Query query, 156 RecipientMatchCallback callback) { 157 // See if any entries did not resolve; if so, we need to check other 158 // directories 159 160 if (recipientEntries.size() < addresses.size()) { 161 final List<DirectorySearchParams> paramsList; 162 Cursor directoryCursor = null; 163 try { 164 directoryCursor = context.getContentResolver().query(DirectoryListQuery.URI, 165 DirectoryListQuery.PROJECTION, null, null, null); 166 if (directoryCursor == null) { 167 paramsList = null; 168 } else { 169 paramsList = BaseRecipientAdapter.setupOtherDirectories(context, 170 directoryCursor, account); 171 } 172 } finally { 173 if (directoryCursor != null) { 174 directoryCursor.close(); 175 } 176 } 177 // Run a directory query for each unmatched recipient. 178 HashSet<String> unresolvedAddresses = new HashSet<String>(); 179 for (String address : addresses) { 180 if (!recipientEntries.containsKey(address)) { 181 unresolvedAddresses.add(address); 182 } 183 } 184 185 matchesNotFound.addAll(unresolvedAddresses); 186 187 if (paramsList != null) { 188 Cursor directoryContactsCursor = null; 189 for (String unresolvedAddress : unresolvedAddresses) { 190 Long directoryId = null; 191 for (int i = 0; i < paramsList.size(); i++) { 192 try { 193 directoryContactsCursor = doQuery(unresolvedAddress, 1, 194 paramsList.get(i).directoryId, account, 195 context.getContentResolver(), query); 196 } finally { 197 if (directoryContactsCursor != null 198 && directoryContactsCursor.getCount() == 0) { 199 directoryContactsCursor.close(); 200 directoryContactsCursor = null; 201 } else { 202 directoryId = paramsList.get(i).directoryId; 203 break; 204 } 205 } 206 } 207 if (directoryContactsCursor != null) { 208 try { 209 final Map<String, RecipientEntry> entries = 210 processContactEntries(directoryContactsCursor, directoryId); 211 212 for (final String address : entries.keySet()) { 213 matchesNotFound.remove(address); 214 } 215 216 callback.matchesFound(entries); 217 } finally { 218 directoryContactsCursor.close(); 219 } 220 } 221 } 222 } 223 } 224 } 225 226 public static void getMatchingRecipientsFromExtensionMatcher(BaseRecipientAdapter adapter, 227 Set<String> matchesNotFound, RecipientMatchCallback callback) { 228 // If no matches found in contact provider or the directories, try the extension 229 // matcher. 230 // todo (aalbert): This whole method needs to be in the adapter? 231 if (adapter != null) { 232 final Map<String, RecipientEntry> entries = 233 adapter.getMatchingRecipients(matchesNotFound); 234 if (entries != null && entries.size() > 0) { 235 callback.matchesFound(entries); 236 for (final String address : entries.keySet()) { 237 matchesNotFound.remove(address); 238 } 239 } 240 } 241 callback.matchesNotFound(matchesNotFound); 242 } 243 244 private static HashMap<String, RecipientEntry> processContactEntries(Cursor c, 245 Long directoryId) { 246 HashMap<String, RecipientEntry> recipientEntries = new HashMap<String, RecipientEntry>(); 247 if (c != null && c.moveToFirst()) { 248 do { 249 String address = c.getString(Queries.Query.DESTINATION); 250 251 final RecipientEntry newRecipientEntry = RecipientEntry.constructTopLevelEntry( 252 c.getString(Queries.Query.NAME), 253 c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), 254 c.getString(Queries.Query.DESTINATION), 255 c.getInt(Queries.Query.DESTINATION_TYPE), 256 c.getString(Queries.Query.DESTINATION_LABEL), 257 c.getLong(Queries.Query.CONTACT_ID), 258 directoryId, 259 c.getLong(Queries.Query.DATA_ID), 260 c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), 261 true, 262 c.getString(Queries.Query.LOOKUP_KEY)); 263 264 /* 265 * In certain situations, we may have two results for one address, where one of the 266 * results is just the email address, and the other has a name and photo, so we want 267 * to use the better one. 268 */ 269 final RecipientEntry recipientEntry = 270 getBetterRecipient(recipientEntries.get(address), newRecipientEntry); 271 272 recipientEntries.put(address, recipientEntry); 273 if (Log.isLoggable(TAG, Log.DEBUG)) { 274 Log.d(TAG, "Received reverse look up information for " + address 275 + " RESULTS: " 276 + " NAME : " + c.getString(Queries.Query.NAME) 277 + " CONTACT ID : " + c.getLong(Queries.Query.CONTACT_ID) 278 + " ADDRESS :" + c.getString(Queries.Query.DESTINATION)); 279 } 280 } while (c.moveToNext()); 281 } 282 return recipientEntries; 283 } 284 285 /** 286 * Given two {@link RecipientEntry}s for the same email address, this will return the one that 287 * contains more complete information for display purposes. Defaults to <code>entry2</code> if 288 * no significant differences are found. 289 */ 290 static RecipientEntry getBetterRecipient(final RecipientEntry entry1, 291 final RecipientEntry entry2) { 292 // If only one has passed in, use it 293 if (entry2 == null) { 294 return entry1; 295 } 296 297 if (entry1 == null) { 298 return entry2; 299 } 300 301 // If only one has a display name, use it 302 if (!TextUtils.isEmpty(entry1.getDisplayName()) 303 && TextUtils.isEmpty(entry2.getDisplayName())) { 304 return entry1; 305 } 306 307 if (!TextUtils.isEmpty(entry2.getDisplayName()) 308 && TextUtils.isEmpty(entry1.getDisplayName())) { 309 return entry2; 310 } 311 312 // If only one has a display name that is not the same as the destination, use it 313 if (!TextUtils.equals(entry1.getDisplayName(), entry1.getDestination()) 314 && TextUtils.equals(entry2.getDisplayName(), entry2.getDestination())) { 315 return entry1; 316 } 317 318 if (!TextUtils.equals(entry2.getDisplayName(), entry2.getDestination()) 319 && TextUtils.equals(entry1.getDisplayName(), entry1.getDestination())) { 320 return entry2; 321 } 322 323 // If only one has a photo, use it 324 if ((entry1.getPhotoThumbnailUri() != null || entry1.getPhotoBytes() != null) 325 && (entry2.getPhotoThumbnailUri() == null && entry2.getPhotoBytes() == null)) { 326 return entry1; 327 } 328 329 if ((entry2.getPhotoThumbnailUri() != null || entry2.getPhotoBytes() != null) 330 && (entry1.getPhotoThumbnailUri() == null && entry1.getPhotoBytes() == null)) { 331 return entry2; 332 } 333 334 // Go with the second option as a default 335 return entry2; 336 } 337 338 private static Cursor doQuery(CharSequence constraint, int limit, Long directoryId, 339 Account account, ContentResolver resolver, Query query) { 340 final Uri.Builder builder = query 341 .getContentFilterUri() 342 .buildUpon() 343 .appendPath(constraint.toString()) 344 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, 345 String.valueOf(limit + BaseRecipientAdapter.ALLOWANCE_FOR_DUPLICATES)); 346 if (directoryId != null) { 347 builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 348 String.valueOf(directoryId)); 349 } 350 if (account != null) { 351 builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_NAME, account.name); 352 builder.appendQueryParameter(BaseRecipientAdapter.PRIMARY_ACCOUNT_TYPE, account.type); 353 } 354 final Cursor cursor = resolver.query(builder.build(), query.getProjection(), null, null, 355 null); 356 return cursor; 357 } 358 359 public RecipientAlternatesAdapter(Context context, long contactId, Long directoryId, 360 String lookupKey, long currentId, int queryMode, OnCheckedItemChangedListener listener, 361 DropdownChipLayouter dropdownChipLayouter) { 362 super(context, 363 getCursorForConstruction(context, contactId, directoryId, lookupKey, queryMode), 0); 364 mCurrentId = currentId; 365 mDirectoryId = directoryId; 366 mCheckedItemChangedListener = listener; 367 368 mDropdownChipLayouter = dropdownChipLayouter; 369 } 370 371 private static Cursor getCursorForConstruction(Context context, long contactId, 372 Long directoryId, String lookupKey, int queryType) { 373 final Cursor cursor; 374 final String desiredMimeType; 375 if (queryType == QUERY_TYPE_EMAIL) { 376 final Uri uri; 377 final StringBuilder selection = new StringBuilder(); 378 selection.append(Queries.EMAIL.getProjection()[Queries.Query.CONTACT_ID]); 379 selection.append(" = ?"); 380 381 if (directoryId == null || lookupKey == null) { 382 uri = Queries.EMAIL.getContentUri(); 383 desiredMimeType = null; 384 } else { 385 final Uri.Builder builder = Contacts.getLookupUri(contactId, lookupKey).buildUpon(); 386 builder.appendPath(Contacts.Entity.CONTENT_DIRECTORY) 387 .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 388 String.valueOf(directoryId)); 389 uri = builder.build(); 390 desiredMimeType = ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE; 391 } 392 cursor = context.getContentResolver().query( 393 uri, 394 Queries.EMAIL.getProjection(), 395 selection.toString(), new String[] { 396 String.valueOf(contactId) 397 }, null); 398 } else { 399 final Uri uri; 400 final StringBuilder selection = new StringBuilder(); 401 selection.append(Queries.PHONE.getProjection()[Queries.Query.CONTACT_ID]); 402 selection.append(" = ?"); 403 404 if (lookupKey == null) { 405 uri = Queries.PHONE.getContentUri(); 406 desiredMimeType = null; 407 } else { 408 final Uri.Builder builder = Contacts.getLookupUri(contactId, lookupKey).buildUpon(); 409 builder.appendPath(Contacts.Entity.CONTENT_DIRECTORY) 410 .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 411 String.valueOf(directoryId)); 412 uri = builder.build(); 413 desiredMimeType = ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE; 414 } 415 cursor = context.getContentResolver().query( 416 uri, 417 Queries.PHONE.getProjection(), 418 selection.toString(), new String[] { 419 String.valueOf(contactId) 420 }, null); 421 } 422 423 final Cursor resultCursor = removeUndesiredDestinations(cursor, desiredMimeType, lookupKey); 424 cursor.close(); 425 426 return resultCursor; 427 } 428 429 /** 430 * @return a new cursor based on the given cursor with all duplicate destinations removed. 431 * 432 * It's only intended to use for the alternate list, so... 433 * - This method ignores all other fields and dedupe solely on the destination. Normally, 434 * if a cursor contains multiple contacts and they have the same destination, we'd still want 435 * to show both. 436 * - This method creates a MatrixCursor, so all data will be kept in memory. We wouldn't want 437 * to do this if the original cursor is large, but it's okay here because the alternate list 438 * won't be that big. 439 * 440 * @param desiredMimeType If this is non-<code>null</code>, only entries with this mime type 441 * will be added to the cursor 442 * @param lookupKey The lookup key used for this contact if there isn't one in the cursor. This 443 * should be the same one used in the query that returned the cursor 444 */ 445 // Visible for testing 446 static Cursor removeUndesiredDestinations(final Cursor original, final String desiredMimeType, 447 final String lookupKey) { 448 final MatrixCursor result = new MatrixCursor( 449 original.getColumnNames(), original.getCount()); 450 final HashSet<String> destinationsSeen = new HashSet<String>(); 451 452 String defaultDisplayName = null; 453 String defaultPhotoThumbnailUri = null; 454 int defaultDisplayNameSource = 0; 455 456 // Find some nice defaults in case we need them 457 original.moveToPosition(-1); 458 while (original.moveToNext()) { 459 final String mimeType = original.getString(Query.MIME_TYPE); 460 461 if (ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE.equals( 462 mimeType)) { 463 // Store this data 464 defaultDisplayName = original.getString(Query.NAME); 465 defaultPhotoThumbnailUri = original.getString(Query.PHOTO_THUMBNAIL_URI); 466 defaultDisplayNameSource = original.getInt(Query.DISPLAY_NAME_SOURCE); 467 break; 468 } 469 } 470 471 original.moveToPosition(-1); 472 while (original.moveToNext()) { 473 if (desiredMimeType != null) { 474 final String mimeType = original.getString(Query.MIME_TYPE); 475 if (!desiredMimeType.equals(mimeType)) { 476 continue; 477 } 478 } 479 final String destination = original.getString(Query.DESTINATION); 480 if (destinationsSeen.contains(destination)) { 481 continue; 482 } 483 destinationsSeen.add(destination); 484 485 final Object[] row = new Object[] { 486 original.getString(Query.NAME), 487 original.getString(Query.DESTINATION), 488 original.getInt(Query.DESTINATION_TYPE), 489 original.getString(Query.DESTINATION_LABEL), 490 original.getLong(Query.CONTACT_ID), 491 original.getLong(Query.DATA_ID), 492 original.getString(Query.PHOTO_THUMBNAIL_URI), 493 original.getInt(Query.DISPLAY_NAME_SOURCE), 494 original.getString(Query.LOOKUP_KEY), 495 original.getString(Query.MIME_TYPE) 496 }; 497 498 if (row[Query.NAME] == null) { 499 row[Query.NAME] = defaultDisplayName; 500 } 501 if (row[Query.PHOTO_THUMBNAIL_URI] == null) { 502 row[Query.PHOTO_THUMBNAIL_URI] = defaultPhotoThumbnailUri; 503 } 504 if ((Integer) row[Query.DISPLAY_NAME_SOURCE] == 0) { 505 row[Query.DISPLAY_NAME_SOURCE] = defaultDisplayNameSource; 506 } 507 if (row[Query.LOOKUP_KEY] == null) { 508 row[Query.LOOKUP_KEY] = lookupKey; 509 } 510 511 // Ensure we don't have two '?' like content://.../...?account_name=...?sz=... 512 final String photoThumbnailUri = (String) row[Query.PHOTO_THUMBNAIL_URI]; 513 if (photoThumbnailUri != null) { 514 if (sCorrectedPhotoUris.containsKey(photoThumbnailUri)) { 515 row[Query.PHOTO_THUMBNAIL_URI] = sCorrectedPhotoUris.get(photoThumbnailUri); 516 } else if (photoThumbnailUri.indexOf('?') != photoThumbnailUri.lastIndexOf('?')) { 517 final String[] parts = photoThumbnailUri.split("\\?"); 518 final StringBuilder correctedUriBuilder = new StringBuilder(); 519 for (int i = 0; i < parts.length; i++) { 520 if (i == 1) { 521 correctedUriBuilder.append("?"); // We only want one of these 522 } else if (i > 1) { 523 correctedUriBuilder.append("&"); // And we want these elsewhere 524 } 525 correctedUriBuilder.append(parts[i]); 526 } 527 528 final String correctedUri = correctedUriBuilder.toString(); 529 sCorrectedPhotoUris.put(photoThumbnailUri, correctedUri); 530 row[Query.PHOTO_THUMBNAIL_URI] = correctedUri; 531 } 532 } 533 534 result.addRow(row); 535 } 536 537 return result; 538 } 539 540 @Override 541 public long getItemId(int position) { 542 Cursor c = getCursor(); 543 if (c.moveToPosition(position)) { 544 c.getLong(Queries.Query.DATA_ID); 545 } 546 return -1; 547 } 548 549 public RecipientEntry getRecipientEntry(int position) { 550 Cursor c = getCursor(); 551 c.moveToPosition(position); 552 return RecipientEntry.constructTopLevelEntry( 553 c.getString(Queries.Query.NAME), 554 c.getInt(Queries.Query.DISPLAY_NAME_SOURCE), 555 c.getString(Queries.Query.DESTINATION), 556 c.getInt(Queries.Query.DESTINATION_TYPE), 557 c.getString(Queries.Query.DESTINATION_LABEL), 558 c.getLong(Queries.Query.CONTACT_ID), 559 mDirectoryId, 560 c.getLong(Queries.Query.DATA_ID), 561 c.getString(Queries.Query.PHOTO_THUMBNAIL_URI), 562 true, 563 c.getString(Queries.Query.LOOKUP_KEY)); 564 } 565 566 @Override 567 public View getView(int position, View convertView, ViewGroup parent) { 568 Cursor cursor = getCursor(); 569 cursor.moveToPosition(position); 570 if (convertView == null) { 571 convertView = mDropdownChipLayouter.newView(); 572 } 573 if (cursor.getLong(Queries.Query.DATA_ID) == mCurrentId) { 574 mCheckedItemPosition = position; 575 if (mCheckedItemChangedListener != null) { 576 mCheckedItemChangedListener.onCheckedItemChanged(mCheckedItemPosition); 577 } 578 } 579 bindView(convertView, convertView.getContext(), cursor); 580 return convertView; 581 } 582 583 @Override 584 public void bindView(View view, Context context, Cursor cursor) { 585 int position = cursor.getPosition(); 586 RecipientEntry entry = getRecipientEntry(position); 587 588 mDropdownChipLayouter.bindView(view, null, entry, position, 589 AdapterType.RECIPIENT_ALTERNATES, null); 590 } 591 592 @Override 593 public View newView(Context context, Cursor cursor, ViewGroup parent) { 594 return mDropdownChipLayouter.newView(); 595 } 596 597 /*package*/ static interface OnCheckedItemChangedListener { 598 public void onCheckedItemChanged(int position); 599 } 600} 601