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