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