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