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