BaseRecipientAdapter.java revision e091107390e8f884c45c74ea4b3984c6f5ca5948
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.content.pm.PackageManager; 23import android.content.pm.PackageManager.NameNotFoundException; 24import android.content.res.Resources; 25import android.database.Cursor; 26import android.graphics.Bitmap; 27import android.graphics.BitmapFactory; 28import android.net.Uri; 29import android.os.AsyncTask; 30import android.os.Handler; 31import android.os.Message; 32import android.provider.ContactsContract; 33import android.provider.ContactsContract.CommonDataKinds.Photo; 34import android.provider.ContactsContract.Directory; 35import android.support.v4.util.LruCache; 36import android.text.TextUtils; 37import android.text.util.Rfc822Token; 38import android.util.Log; 39import android.view.LayoutInflater; 40import android.view.View; 41import android.view.ViewGroup; 42import android.widget.AutoCompleteTextView; 43import android.widget.BaseAdapter; 44import android.widget.Filter; 45import android.widget.Filterable; 46import android.widget.ImageView; 47import android.widget.TextView; 48 49import java.io.ByteArrayOutputStream; 50import java.io.InputStream; 51import java.io.IOException; 52import java.util.ArrayList; 53import java.util.Arrays; 54import java.util.HashSet; 55import java.util.LinkedHashMap; 56import java.util.List; 57import java.util.Map; 58import java.util.Set; 59 60/** 61 * Adapter for showing a recipient list. 62 */ 63public abstract class BaseRecipientAdapter extends BaseAdapter implements Filterable, 64 AccountSpecifier { 65 private static final String TAG = "BaseRecipientAdapter"; 66 67 private static final boolean DEBUG = false; 68 69 /** 70 * The preferred number of results to be retrieved. This number may be 71 * exceeded if there are several directories configured, because we will use 72 * the same limit for all directories. 73 */ 74 private static final int DEFAULT_PREFERRED_MAX_RESULT_COUNT = 10; 75 76 /** 77 * The number of extra entries requested to allow for duplicates. Duplicates 78 * are removed from the overall result. 79 */ 80 static final int ALLOWANCE_FOR_DUPLICATES = 5; 81 82 // This is ContactsContract.PRIMARY_ACCOUNT_NAME. Available from ICS as hidden 83 static final String PRIMARY_ACCOUNT_NAME = "name_for_primary_account"; 84 // This is ContactsContract.PRIMARY_ACCOUNT_TYPE. Available from ICS as hidden 85 static final String PRIMARY_ACCOUNT_TYPE = "type_for_primary_account"; 86 87 /** The number of photos cached in this Adapter. */ 88 private static final int PHOTO_CACHE_SIZE = 20; 89 90 /** 91 * The "Waiting for more contacts" message will be displayed if search is not complete 92 * within this many milliseconds. 93 */ 94 private static final int MESSAGE_SEARCH_PENDING_DELAY = 1000; 95 /** Used to prepare "Waiting for more contacts" message. */ 96 private static final int MESSAGE_SEARCH_PENDING = 1; 97 98 public static final int QUERY_TYPE_EMAIL = 0; 99 public static final int QUERY_TYPE_PHONE = 1; 100 101 private final Queries.Query mQuery; 102 private final int mQueryType; 103 104 /** 105 * Model object for a {@link Directory} row. 106 */ 107 public final static class DirectorySearchParams { 108 public long directoryId; 109 public String directoryType; 110 public String displayName; 111 public String accountName; 112 public String accountType; 113 public CharSequence constraint; 114 public DirectoryFilter filter; 115 } 116 117 private static class PhotoQuery { 118 public static final String[] PROJECTION = { 119 Photo.PHOTO 120 }; 121 122 public static final int PHOTO = 0; 123 } 124 125 protected static class DirectoryListQuery { 126 127 public static final Uri URI = 128 Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories"); 129 public static final String[] PROJECTION = { 130 Directory._ID, // 0 131 Directory.ACCOUNT_NAME, // 1 132 Directory.ACCOUNT_TYPE, // 2 133 Directory.DISPLAY_NAME, // 3 134 Directory.PACKAGE_NAME, // 4 135 Directory.TYPE_RESOURCE_ID, // 5 136 }; 137 138 public static final int ID = 0; 139 public static final int ACCOUNT_NAME = 1; 140 public static final int ACCOUNT_TYPE = 2; 141 public static final int DISPLAY_NAME = 3; 142 public static final int PACKAGE_NAME = 4; 143 public static final int TYPE_RESOURCE_ID = 5; 144 } 145 146 /** Used to temporarily hold results in Cursor objects. */ 147 protected static class TemporaryEntry { 148 public final String displayName; 149 public final String destination; 150 public final int destinationType; 151 public final String destinationLabel; 152 public final long contactId; 153 public final long dataId; 154 public final String thumbnailUriString; 155 public final int displayNameSource; 156 157 public TemporaryEntry( 158 String displayName, 159 String destination, 160 int destinationType, 161 String destinationLabel, 162 long contactId, 163 long dataId, 164 String thumbnailUriString, 165 int displayNameSource) { 166 this.displayName = displayName; 167 this.destination = destination; 168 this.destinationType = destinationType; 169 this.destinationLabel = destinationLabel; 170 this.contactId = contactId; 171 this.dataId = dataId; 172 this.thumbnailUriString = thumbnailUriString; 173 this.displayNameSource = displayNameSource; 174 } 175 176 public TemporaryEntry(Cursor cursor) { 177 this.displayName = cursor.getString(Queries.Query.NAME); 178 this.destination = cursor.getString(Queries.Query.DESTINATION); 179 this.destinationType = cursor.getInt(Queries.Query.DESTINATION_TYPE); 180 this.destinationLabel = cursor.getString(Queries.Query.DESTINATION_LABEL); 181 this.contactId = cursor.getLong(Queries.Query.CONTACT_ID); 182 this.dataId = cursor.getLong(Queries.Query.DATA_ID); 183 this.thumbnailUriString = cursor.getString(Queries.Query.PHOTO_THUMBNAIL_URI); 184 this.displayNameSource = cursor.getInt(Queries.Query.DISPLAY_NAME_SOURCE); 185 } 186 } 187 188 /** 189 * Used to pass results from {@link DefaultFilter#performFiltering(CharSequence)} to 190 * {@link DefaultFilter#publishResults(CharSequence, android.widget.Filter.FilterResults)} 191 */ 192 private static class DefaultFilterResult { 193 public final List<RecipientEntry> entries; 194 public final LinkedHashMap<Long, List<RecipientEntry>> entryMap; 195 public final List<RecipientEntry> nonAggregatedEntries; 196 public final Set<String> existingDestinations; 197 public final List<DirectorySearchParams> paramsList; 198 199 public DefaultFilterResult(List<RecipientEntry> entries, 200 LinkedHashMap<Long, List<RecipientEntry>> entryMap, 201 List<RecipientEntry> nonAggregatedEntries, 202 Set<String> existingDestinations, 203 List<DirectorySearchParams> paramsList) { 204 this.entries = entries; 205 this.entryMap = entryMap; 206 this.nonAggregatedEntries = nonAggregatedEntries; 207 this.existingDestinations = existingDestinations; 208 this.paramsList = paramsList; 209 } 210 } 211 212 /** 213 * An asynchronous filter used for loading two data sets: email rows from the local 214 * contact provider and the list of {@link Directory}'s. 215 */ 216 private final class DefaultFilter extends Filter { 217 218 @Override 219 protected FilterResults performFiltering(CharSequence constraint) { 220 if (DEBUG) { 221 Log.d(TAG, "start filtering. constraint: " + constraint + ", thread:" 222 + Thread.currentThread()); 223 } 224 225 final FilterResults results = new FilterResults(); 226 Cursor defaultDirectoryCursor = null; 227 Cursor directoryCursor = null; 228 229 if (TextUtils.isEmpty(constraint)) { 230 clearTempEntries(); 231 // Return empty results. 232 return results; 233 } 234 235 try { 236 defaultDirectoryCursor = doQuery(constraint, mPreferredMaxResultCount, null); 237 238 if (defaultDirectoryCursor == null) { 239 if (DEBUG) { 240 Log.w(TAG, "null cursor returned for default Email filter query."); 241 } 242 } else { 243 // These variables will become mEntries, mEntryMap, mNonAggregatedEntries, and 244 // mExistingDestinations. Here we shouldn't use those member variables directly 245 // since this method is run outside the UI thread. 246 final LinkedHashMap<Long, List<RecipientEntry>> entryMap = 247 new LinkedHashMap<Long, List<RecipientEntry>>(); 248 final List<RecipientEntry> nonAggregatedEntries = 249 new ArrayList<RecipientEntry>(); 250 final Set<String> existingDestinations = new HashSet<String>(); 251 252 while (defaultDirectoryCursor.moveToNext()) { 253 // Note: At this point each entry doesn't contain any photo 254 // (thus getPhotoBytes() returns null). 255 putOneEntry(new TemporaryEntry(defaultDirectoryCursor), 256 true, entryMap, nonAggregatedEntries, existingDestinations); 257 } 258 259 // We'll copy this result to mEntry in publicResults() (run in the UX thread). 260 final List<RecipientEntry> entries = constructEntryList( 261 entryMap, nonAggregatedEntries); 262 263 // After having local results, check the size of results. If the results are 264 // not enough, we search remote directories, which will take longer time. 265 final int limit = mPreferredMaxResultCount - existingDestinations.size(); 266 final List<DirectorySearchParams> paramsList; 267 if (limit > 0) { 268 if (DEBUG) { 269 Log.d(TAG, "More entries should be needed (current: " 270 + existingDestinations.size() 271 + ", remaining limit: " + limit + ") "); 272 } 273 directoryCursor = mContentResolver.query( 274 DirectoryListQuery.URI, DirectoryListQuery.PROJECTION, 275 null, null, null); 276 paramsList = setupOtherDirectories(mContext, directoryCursor, mAccount); 277 } else { 278 // We don't need to search other directories. 279 paramsList = null; 280 } 281 282 results.values = new DefaultFilterResult( 283 entries, entryMap, nonAggregatedEntries, 284 existingDestinations, paramsList); 285 results.count = 1; 286 } 287 } finally { 288 if (defaultDirectoryCursor != null) { 289 defaultDirectoryCursor.close(); 290 } 291 if (directoryCursor != null) { 292 directoryCursor.close(); 293 } 294 } 295 return results; 296 } 297 298 @Override 299 protected void publishResults(final CharSequence constraint, FilterResults results) { 300 // If a user types a string very quickly and database is slow, "constraint" refers to 301 // an older text which shows inconsistent results for users obsolete (b/4998713). 302 // TODO: Fix it. 303 mCurrentConstraint = constraint; 304 305 clearTempEntries(); 306 307 if (results.values != null) { 308 DefaultFilterResult defaultFilterResult = (DefaultFilterResult) results.values; 309 mEntryMap = defaultFilterResult.entryMap; 310 mNonAggregatedEntries = defaultFilterResult.nonAggregatedEntries; 311 mExistingDestinations = defaultFilterResult.existingDestinations; 312 313 // If there are no local results, in the new result set, cache off what had been 314 // shown to the user for use until the first directory result is returned 315 if (defaultFilterResult.entries.size() == 0 && 316 defaultFilterResult.paramsList != null) { 317 cacheCurrentEntries(); 318 } 319 320 updateEntries(defaultFilterResult.entries); 321 322 // We need to search other remote directories, doing other Filter requests. 323 if (defaultFilterResult.paramsList != null) { 324 final int limit = mPreferredMaxResultCount - 325 defaultFilterResult.existingDestinations.size(); 326 startSearchOtherDirectories(constraint, defaultFilterResult.paramsList, limit); 327 } 328 } 329 330 } 331 332 @Override 333 public CharSequence convertResultToString(Object resultValue) { 334 final RecipientEntry entry = (RecipientEntry)resultValue; 335 final String displayName = entry.getDisplayName(); 336 final String emailAddress = entry.getDestination(); 337 if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) { 338 return emailAddress; 339 } else { 340 return new Rfc822Token(displayName, emailAddress, null).toString(); 341 } 342 } 343 } 344 345 /** 346 * An asynchronous filter that performs search in a particular directory. 347 */ 348 protected class DirectoryFilter extends Filter { 349 private final DirectorySearchParams mParams; 350 private int mLimit; 351 352 public DirectoryFilter(DirectorySearchParams params) { 353 mParams = params; 354 } 355 356 public synchronized void setLimit(int limit) { 357 this.mLimit = limit; 358 } 359 360 public synchronized int getLimit() { 361 return this.mLimit; 362 } 363 364 @Override 365 protected FilterResults performFiltering(CharSequence constraint) { 366 if (DEBUG) { 367 Log.d(TAG, "DirectoryFilter#performFiltering. directoryId: " + mParams.directoryId 368 + ", constraint: " + constraint + ", thread: " + Thread.currentThread()); 369 } 370 final FilterResults results = new FilterResults(); 371 results.values = null; 372 results.count = 0; 373 374 if (!TextUtils.isEmpty(constraint)) { 375 final ArrayList<TemporaryEntry> tempEntries = new ArrayList<TemporaryEntry>(); 376 377 Cursor cursor = null; 378 try { 379 // We don't want to pass this Cursor object to UI thread (b/5017608). 380 // Assuming the result should contain fairly small results (at most ~10), 381 // We just copy everything to local structure. 382 cursor = doQuery(constraint, getLimit(), mParams.directoryId); 383 384 if (cursor != null) { 385 while (cursor.moveToNext()) { 386 tempEntries.add(new TemporaryEntry(cursor)); 387 } 388 } 389 } finally { 390 if (cursor != null) { 391 cursor.close(); 392 } 393 } 394 if (!tempEntries.isEmpty()) { 395 results.values = tempEntries; 396 results.count = 1; 397 } 398 } 399 400 if (DEBUG) { 401 Log.v(TAG, "finished loading directory \"" + mParams.displayName + "\"" + 402 " with query " + constraint); 403 } 404 405 return results; 406 } 407 408 @Override 409 protected void publishResults(final CharSequence constraint, FilterResults results) { 410 if (DEBUG) { 411 Log.d(TAG, "DirectoryFilter#publishResult. constraint: " + constraint 412 + ", mCurrentConstraint: " + mCurrentConstraint); 413 } 414 mDelayedMessageHandler.removeDelayedLoadMessage(); 415 // Check if the received result matches the current constraint 416 // If not - the user must have continued typing after the request was issued, which 417 // means several member variables (like mRemainingDirectoryLoad) are already 418 // overwritten so shouldn't be touched here anymore. 419 if (TextUtils.equals(constraint, mCurrentConstraint)) { 420 if (results.count > 0) { 421 @SuppressWarnings("unchecked") 422 final ArrayList<TemporaryEntry> tempEntries = 423 (ArrayList<TemporaryEntry>) results.values; 424 425 for (TemporaryEntry tempEntry : tempEntries) { 426 putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT, 427 mEntryMap, mNonAggregatedEntries, mExistingDestinations); 428 } 429 } 430 431 // If there are remaining directories, set up delayed message again. 432 mRemainingDirectoryCount--; 433 if (mRemainingDirectoryCount > 0) { 434 if (DEBUG) { 435 Log.d(TAG, "Resend delayed load message. Current mRemainingDirectoryLoad: " 436 + mRemainingDirectoryCount); 437 } 438 mDelayedMessageHandler.sendDelayedLoadMessage(); 439 } 440 441 // If this directory result has some items, or there are no more directories that 442 // we are waiting for, clear the temp results 443 if (results.count > 0 || mRemainingDirectoryCount == 0) { 444 // Clear the temp entries 445 clearTempEntries(); 446 } 447 } 448 449 // Show the list again without "waiting" message. 450 updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries)); 451 } 452 } 453 454 private final Context mContext; 455 private final ContentResolver mContentResolver; 456 private final LayoutInflater mInflater; 457 private Account mAccount; 458 private final int mPreferredMaxResultCount; 459 private final Handler mHandler = new Handler(); 460 461 /** 462 * {@link #mEntries} is responsible for showing every result for this Adapter. To 463 * construct it, we use {@link #mEntryMap}, {@link #mNonAggregatedEntries}, and 464 * {@link #mExistingDestinations}. 465 * 466 * First, each destination (an email address or a phone number) with a valid contactId is 467 * inserted into {@link #mEntryMap} and grouped by the contactId. Destinations without valid 468 * contactId (possible if they aren't in local storage) are stored in 469 * {@link #mNonAggregatedEntries}. 470 * Duplicates are removed using {@link #mExistingDestinations}. 471 * 472 * After having all results from Cursor objects, all destinations in mEntryMap are copied to 473 * {@link #mEntries}. If the number of destinations is not enough (i.e. less than 474 * {@link #mPreferredMaxResultCount}), destinations in mNonAggregatedEntries are also used. 475 * 476 * These variables are only used in UI thread, thus should not be touched in 477 * performFiltering() methods. 478 */ 479 private LinkedHashMap<Long, List<RecipientEntry>> mEntryMap; 480 private List<RecipientEntry> mNonAggregatedEntries; 481 private Set<String> mExistingDestinations; 482 /** Note: use {@link #updateEntries(List)} to update this variable. */ 483 private List<RecipientEntry> mEntries; 484 private List<RecipientEntry> mTempEntries; 485 486 /** The number of directories this adapter is waiting for results. */ 487 private int mRemainingDirectoryCount; 488 489 /** 490 * Used to ignore asynchronous queries with a different constraint, which may happen when 491 * users type characters quickly. 492 */ 493 private CharSequence mCurrentConstraint; 494 495 private final LruCache<Uri, byte[]> mPhotoCacheMap; 496 497 /** 498 * Handler specific for maintaining "Waiting for more contacts" message, which will be shown 499 * when: 500 * - there are directories to be searched 501 * - results from directories are slow to come 502 */ 503 private final class DelayedMessageHandler extends Handler { 504 @Override 505 public void handleMessage(Message msg) { 506 if (mRemainingDirectoryCount > 0) { 507 updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries)); 508 } 509 } 510 511 public void sendDelayedLoadMessage() { 512 sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null), 513 MESSAGE_SEARCH_PENDING_DELAY); 514 } 515 516 public void removeDelayedLoadMessage() { 517 removeMessages(MESSAGE_SEARCH_PENDING); 518 } 519 } 520 521 private final DelayedMessageHandler mDelayedMessageHandler = new DelayedMessageHandler(); 522 523 private EntriesUpdatedObserver mEntriesUpdatedObserver; 524 525 /** 526 * Constructor for email queries. 527 */ 528 public BaseRecipientAdapter(Context context) { 529 this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL); 530 } 531 532 public BaseRecipientAdapter(Context context, int preferredMaxResultCount) { 533 this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL); 534 } 535 536 public BaseRecipientAdapter(int queryMode, Context context) { 537 this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode); 538 } 539 540 public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) { 541 this(context, preferredMaxResultCount, queryMode); 542 } 543 544 public BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode) { 545 mContext = context; 546 mContentResolver = context.getContentResolver(); 547 mInflater = LayoutInflater.from(context); 548 mPreferredMaxResultCount = preferredMaxResultCount; 549 mPhotoCacheMap = new LruCache<Uri, byte[]>(PHOTO_CACHE_SIZE); 550 mQueryType = queryMode; 551 552 if (queryMode == QUERY_TYPE_EMAIL) { 553 mQuery = Queries.EMAIL; 554 } else if (queryMode == QUERY_TYPE_PHONE) { 555 mQuery = Queries.PHONE; 556 } else { 557 mQuery = Queries.EMAIL; 558 Log.e(TAG, "Unsupported query type: " + queryMode); 559 } 560 } 561 562 public Context getContext() { 563 return mContext; 564 } 565 566 public int getQueryType() { 567 return mQueryType; 568 } 569 570 /** 571 * Set the account when known. Causes the search to prioritize contacts from that account. 572 */ 573 @Override 574 public void setAccount(Account account) { 575 mAccount = account; 576 } 577 578 /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */ 579 @Override 580 public Filter getFilter() { 581 return new DefaultFilter(); 582 } 583 584 /** 585 * An extesion to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows 586 * additional sources of contacts to be considered as matching recipients. 587 * @param addresses A set of addresses to be matched 588 * @return A list of matches or null if none found 589 */ 590 public Map<String, RecipientEntry> getMatchingRecipients(Set<String> addresses) { 591 return null; 592 } 593 594 public static List<DirectorySearchParams> setupOtherDirectories(Context context, 595 Cursor directoryCursor, Account account) { 596 final PackageManager packageManager = context.getPackageManager(); 597 final List<DirectorySearchParams> paramsList = new ArrayList<DirectorySearchParams>(); 598 DirectorySearchParams preferredDirectory = null; 599 while (directoryCursor.moveToNext()) { 600 final long id = directoryCursor.getLong(DirectoryListQuery.ID); 601 602 // Skip the local invisible directory, because the default directory already includes 603 // all local results. 604 if (id == Directory.LOCAL_INVISIBLE) { 605 continue; 606 } 607 608 final DirectorySearchParams params = new DirectorySearchParams(); 609 final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME); 610 final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID); 611 params.directoryId = id; 612 params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME); 613 params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME); 614 params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE); 615 if (packageName != null && resourceId != 0) { 616 try { 617 final Resources resources = 618 packageManager.getResourcesForApplication(packageName); 619 params.directoryType = resources.getString(resourceId); 620 if (params.directoryType == null) { 621 Log.e(TAG, "Cannot resolve directory name: " 622 + resourceId + "@" + packageName); 623 } 624 } catch (NameNotFoundException e) { 625 Log.e(TAG, "Cannot resolve directory name: " 626 + resourceId + "@" + packageName, e); 627 } 628 } 629 630 // If an account has been provided and we found a directory that 631 // corresponds to that account, place that directory second, directly 632 // underneath the local contacts. 633 if (account != null && account.name.equals(params.accountName) && 634 account.type.equals(params.accountType)) { 635 preferredDirectory = params; 636 } else { 637 paramsList.add(params); 638 } 639 } 640 641 if (preferredDirectory != null) { 642 paramsList.add(1, preferredDirectory); 643 } 644 645 return paramsList; 646 } 647 648 /** 649 * Starts search in other directories using {@link Filter}. Results will be handled in 650 * {@link DirectoryFilter}. 651 */ 652 protected void startSearchOtherDirectories( 653 CharSequence constraint, List<DirectorySearchParams> paramsList, int limit) { 654 final int count = paramsList.size(); 655 // Note: skipping the default partition (index 0), which has already been loaded 656 for (int i = 1; i < count; i++) { 657 final DirectorySearchParams params = paramsList.get(i); 658 params.constraint = constraint; 659 if (params.filter == null) { 660 params.filter = new DirectoryFilter(params); 661 } 662 params.filter.setLimit(limit); 663 params.filter.filter(constraint); 664 } 665 666 // Directory search started. We may show "waiting" message if directory results are slow 667 // enough. 668 mRemainingDirectoryCount = count - 1; 669 mDelayedMessageHandler.sendDelayedLoadMessage(); 670 } 671 672 private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry, 673 LinkedHashMap<Long, List<RecipientEntry>> entryMap, 674 List<RecipientEntry> nonAggregatedEntries, 675 Set<String> existingDestinations) { 676 if (existingDestinations.contains(entry.destination)) { 677 return; 678 } 679 680 existingDestinations.add(entry.destination); 681 682 if (!isAggregatedEntry) { 683 nonAggregatedEntries.add(RecipientEntry.constructTopLevelEntry( 684 entry.displayName, 685 entry.displayNameSource, 686 entry.destination, entry.destinationType, entry.destinationLabel, 687 entry.contactId, entry.dataId, entry.thumbnailUriString, true)); 688 } else if (entryMap.containsKey(entry.contactId)) { 689 // We already have a section for the person. 690 final List<RecipientEntry> entryList = entryMap.get(entry.contactId); 691 entryList.add(RecipientEntry.constructSecondLevelEntry( 692 entry.displayName, 693 entry.displayNameSource, 694 entry.destination, entry.destinationType, entry.destinationLabel, 695 entry.contactId, entry.dataId, entry.thumbnailUriString, true)); 696 } else { 697 final List<RecipientEntry> entryList = new ArrayList<RecipientEntry>(); 698 entryList.add(RecipientEntry.constructTopLevelEntry( 699 entry.displayName, 700 entry.displayNameSource, 701 entry.destination, entry.destinationType, entry.destinationLabel, 702 entry.contactId, entry.dataId, entry.thumbnailUriString, true)); 703 entryMap.put(entry.contactId, entryList); 704 } 705 } 706 707 /** 708 * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to 709 * fetch a cached photo for each contact entry (other than separators), or request another 710 * thread to get one from directories. 711 */ 712 private List<RecipientEntry> constructEntryList( 713 LinkedHashMap<Long, List<RecipientEntry>> entryMap, 714 List<RecipientEntry> nonAggregatedEntries) { 715 final List<RecipientEntry> entries = new ArrayList<RecipientEntry>(); 716 int validEntryCount = 0; 717 for (Map.Entry<Long, List<RecipientEntry>> mapEntry : entryMap.entrySet()) { 718 final List<RecipientEntry> entryList = mapEntry.getValue(); 719 final int size = entryList.size(); 720 for (int i = 0; i < size; i++) { 721 RecipientEntry entry = entryList.get(i); 722 entries.add(entry); 723 tryFetchPhoto(entry); 724 validEntryCount++; 725 } 726 if (validEntryCount > mPreferredMaxResultCount) { 727 break; 728 } 729 } 730 if (validEntryCount <= mPreferredMaxResultCount) { 731 for (RecipientEntry entry : nonAggregatedEntries) { 732 if (validEntryCount > mPreferredMaxResultCount) { 733 break; 734 } 735 entries.add(entry); 736 tryFetchPhoto(entry); 737 738 validEntryCount++; 739 } 740 } 741 742 return entries; 743 } 744 745 746 protected interface EntriesUpdatedObserver { 747 public void onChanged(List<RecipientEntry> entries); 748 } 749 750 public void registerUpdateObserver(EntriesUpdatedObserver observer) { 751 mEntriesUpdatedObserver = observer; 752 } 753 754 /** Resets {@link #mEntries} and notify the event to its parent ListView. */ 755 private void updateEntries(List<RecipientEntry> newEntries) { 756 mEntries = newEntries; 757 mEntriesUpdatedObserver.onChanged(newEntries); 758 notifyDataSetChanged(); 759 } 760 761 private void cacheCurrentEntries() { 762 mTempEntries = mEntries; 763 } 764 765 private void clearTempEntries() { 766 mTempEntries = null; 767 } 768 769 protected List<RecipientEntry> getEntries() { 770 return mTempEntries != null ? mTempEntries : mEntries; 771 } 772 773 private void tryFetchPhoto(final RecipientEntry entry) { 774 final Uri photoThumbnailUri = entry.getPhotoThumbnailUri(); 775 if (photoThumbnailUri != null) { 776 final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri); 777 if (photoBytes != null) { 778 entry.setPhotoBytes(photoBytes); 779 // notifyDataSetChanged() should be called by a caller. 780 } else { 781 if (DEBUG) { 782 Log.d(TAG, "No photo cache for " + entry.getDisplayName() 783 + ". Fetch one asynchronously"); 784 } 785 fetchPhotoAsync(entry, photoThumbnailUri); 786 } 787 } 788 } 789 790 // For reading photos for directory contacts, this is the chunksize for 791 // copying from the inputstream to the output stream. 792 private static final int BUFFER_SIZE = 1024*16; 793 794 private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri) { 795 final AsyncTask<Void, Void, byte[]> photoLoadTask = new AsyncTask<Void, Void, byte[]>() { 796 @Override 797 protected byte[] doInBackground(Void... params) { 798 // First try running a query. Images for local contacts are 799 // loaded by sending a query to the ContactsProvider. 800 final Cursor photoCursor = mContentResolver.query( 801 photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null); 802 if (photoCursor != null) { 803 try { 804 if (photoCursor.moveToFirst()) { 805 return photoCursor.getBlob(PhotoQuery.PHOTO); 806 } 807 } finally { 808 photoCursor.close(); 809 } 810 } else { 811 // If the query fails, try streaming the URI directly. 812 // For remote directory images, this URI resolves to the 813 // directory provider and the images are loaded by sending 814 // an openFile call to the provider. 815 try { 816 InputStream is = mContentResolver.openInputStream( 817 photoThumbnailUri); 818 if (is != null) { 819 byte[] buffer = new byte[BUFFER_SIZE]; 820 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 821 try { 822 int size; 823 while ((size = is.read(buffer)) != -1) { 824 baos.write(buffer, 0, size); 825 } 826 } finally { 827 is.close(); 828 } 829 return baos.toByteArray(); 830 } 831 } catch (IOException ex) { 832 // ignore 833 } 834 } 835 return null; 836 } 837 838 @Override 839 protected void onPostExecute(final byte[] photoBytes) { 840 entry.setPhotoBytes(photoBytes); 841 if (photoBytes != null) { 842 mPhotoCacheMap.put(photoThumbnailUri, photoBytes); 843 notifyDataSetChanged(); 844 } 845 } 846 }; 847 photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); 848 } 849 850 protected void fetchPhoto(final RecipientEntry entry, final Uri photoThumbnailUri) { 851 byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri); 852 if (photoBytes != null) { 853 entry.setPhotoBytes(photoBytes); 854 return; 855 } 856 final Cursor photoCursor = mContentResolver.query(photoThumbnailUri, PhotoQuery.PROJECTION, 857 null, null, null); 858 if (photoCursor != null) { 859 try { 860 if (photoCursor.moveToFirst()) { 861 photoBytes = photoCursor.getBlob(PhotoQuery.PHOTO); 862 entry.setPhotoBytes(photoBytes); 863 mPhotoCacheMap.put(photoThumbnailUri, photoBytes); 864 } 865 } finally { 866 photoCursor.close(); 867 } 868 } 869 } 870 871 private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) { 872 final Uri.Builder builder = mQuery.getContentFilterUri().buildUpon() 873 .appendPath(constraint.toString()) 874 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, 875 String.valueOf(limit + ALLOWANCE_FOR_DUPLICATES)); 876 if (directoryId != null) { 877 builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 878 String.valueOf(directoryId)); 879 } 880 if (mAccount != null) { 881 builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name); 882 builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type); 883 } 884 final long start = System.currentTimeMillis(); 885 final Cursor cursor = mContentResolver.query( 886 builder.build(), mQuery.getProjection(), null, null, null); 887 final long end = System.currentTimeMillis(); 888 if (DEBUG) { 889 Log.d(TAG, "Time for autocomplete (query: " + constraint 890 + ", directoryId: " + directoryId + ", num_of_results: " 891 + (cursor != null ? cursor.getCount() : "null") + "): " 892 + (end - start) + " ms"); 893 } 894 return cursor; 895 } 896 897 // TODO: This won't be used at all. We should find better way to quit the thread.. 898 /*public void close() { 899 mEntries = null; 900 mPhotoCacheMap.evictAll(); 901 if (!sPhotoHandlerThread.quit()) { 902 Log.w(TAG, "Failed to quit photo handler thread, ignoring it."); 903 } 904 }*/ 905 906 @Override 907 public int getCount() { 908 final List<RecipientEntry> entries = getEntries(); 909 return entries != null ? entries.size() : 0; 910 } 911 912 @Override 913 public Object getItem(int position) { 914 return getEntries().get(position); 915 } 916 917 @Override 918 public long getItemId(int position) { 919 return position; 920 } 921 922 @Override 923 public int getViewTypeCount() { 924 return RecipientEntry.ENTRY_TYPE_SIZE; 925 } 926 927 @Override 928 public int getItemViewType(int position) { 929 return getEntries().get(position).getEntryType(); 930 } 931 932 @Override 933 public boolean isEnabled(int position) { 934 return getEntries().get(position).isSelectable(); 935 } 936 937 @Override 938 public View getView(int position, View convertView, ViewGroup parent) { 939 final RecipientEntry entry = getEntries().get(position); 940 String displayName = entry.getDisplayName(); 941 String destination = entry.getDestination(); 942 if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, destination)) { 943 displayName = destination; 944 945 // We only show the destination for secondary entries, so clear it 946 // only for the first level. 947 if (entry.isFirstLevel()) { 948 destination = null; 949 } 950 } 951 952 final View itemView = convertView != null ? convertView : mInflater.inflate( 953 getItemLayout(), parent, false); 954 final TextView displayNameView = (TextView) itemView.findViewById(getDisplayNameId()); 955 final TextView destinationView = (TextView) itemView.findViewById(getDestinationId()); 956 final TextView destinationTypeView = (TextView) itemView 957 .findViewById(getDestinationTypeId()); 958 final ImageView imageView = (ImageView) itemView.findViewById(getPhotoId()); 959 displayNameView.setText(displayName); 960 if (!TextUtils.isEmpty(destination)) { 961 destinationView.setText(destination); 962 } else { 963 destinationView.setText(null); 964 } 965 if (destinationTypeView != null) { 966 final CharSequence destinationType = mQuery 967 .getTypeLabel(mContext.getResources(), entry.getDestinationType(), 968 entry.getDestinationLabel()).toString().toUpperCase(); 969 970 destinationTypeView.setText(destinationType); 971 } 972 973 if (entry.isFirstLevel()) { 974 displayNameView.setVisibility(View.VISIBLE); 975 if (imageView != null) { 976 imageView.setVisibility(View.VISIBLE); 977 final byte[] photoBytes = entry.getPhotoBytes(); 978 if (photoBytes != null) { 979 final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0, 980 photoBytes.length); 981 imageView.setImageBitmap(photo); 982 } else { 983 imageView.setImageResource(getDefaultPhotoResource()); 984 } 985 } 986 } else { 987 displayNameView.setVisibility(View.GONE); 988 if (imageView != null) { 989 imageView.setVisibility(View.INVISIBLE); 990 } 991 } 992 return itemView; 993 } 994 995 /** 996 * Returns a layout id for each item inside auto-complete list. 997 * 998 * Each View must contain two TextViews (for display name and destination) and one ImageView 999 * (for photo). Ids for those should be available via {@link #getDisplayNameId()}, 1000 * {@link #getDestinationId()}, and {@link #getPhotoId()}. 1001 */ 1002 protected int getItemLayout() { 1003 return R.layout.chips_recipient_dropdown_item; 1004 } 1005 1006 /** 1007 * Returns a resource ID representing an image which should be shown when ther's no relevant 1008 * photo is available. 1009 */ 1010 protected int getDefaultPhotoResource() { 1011 return R.drawable.ic_contact_picture; 1012 } 1013 1014 /** 1015 * Returns an id for TextView in an item View for showing a display name. By default 1016 * {@link android.R.id#title} is returned. 1017 */ 1018 protected int getDisplayNameId() { 1019 return android.R.id.title; 1020 } 1021 1022 /** 1023 * Returns an id for TextView in an item View for showing a destination 1024 * (an email address or a phone number). 1025 * By default {@link android.R.id#text1} is returned. 1026 */ 1027 protected int getDestinationId() { 1028 return android.R.id.text1; 1029 } 1030 1031 /** 1032 * Returns an id for TextView in an item View for showing the type of the destination. 1033 * By default {@link android.R.id#text2} is returned. 1034 */ 1035 protected int getDestinationTypeId() { 1036 return android.R.id.text2; 1037 } 1038 1039 /** 1040 * Returns an id for ImageView in an item View for showing photo image for a person. In default 1041 * {@link android.R.id#icon} is returned. 1042 */ 1043 protected int getPhotoId() { 1044 return android.R.id.icon; 1045 } 1046 1047 public Account getAccount() { 1048 return mAccount; 1049 } 1050} 1051