BaseRecipientAdapter.java revision 7a4e67708498ec46c2e9b3bad69d3807d88c064e
1/* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.ex.chips; 18 19import android.accounts.Account; 20import android.content.ContentResolver; 21import android.content.Context; 22import android.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.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 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 } 339 340 } 341 342 @Override 343 public CharSequence convertResultToString(Object resultValue) { 344 final RecipientEntry entry = (RecipientEntry)resultValue; 345 final String displayName = entry.getDisplayName(); 346 final String emailAddress = entry.getDestination(); 347 if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) { 348 return emailAddress; 349 } else { 350 return new Rfc822Token(displayName, emailAddress, null).toString(); 351 } 352 } 353 } 354 355 /** 356 * An asynchronous filter that performs search in a particular directory. 357 */ 358 protected class DirectoryFilter extends Filter { 359 private final DirectorySearchParams mParams; 360 private int mLimit; 361 362 public DirectoryFilter(DirectorySearchParams params) { 363 mParams = params; 364 } 365 366 public synchronized void setLimit(int limit) { 367 this.mLimit = limit; 368 } 369 370 public synchronized int getLimit() { 371 return this.mLimit; 372 } 373 374 @Override 375 protected FilterResults performFiltering(CharSequence constraint) { 376 if (DEBUG) { 377 Log.d(TAG, "DirectoryFilter#performFiltering. directoryId: " + mParams.directoryId 378 + ", constraint: " + constraint + ", thread: " + Thread.currentThread()); 379 } 380 final FilterResults results = new FilterResults(); 381 results.values = null; 382 results.count = 0; 383 384 if (!TextUtils.isEmpty(constraint)) { 385 final ArrayList<TemporaryEntry> tempEntries = new ArrayList<TemporaryEntry>(); 386 387 Cursor cursor = null; 388 try { 389 // We don't want to pass this Cursor object to UI thread (b/5017608). 390 // Assuming the result should contain fairly small results (at most ~10), 391 // We just copy everything to local structure. 392 cursor = doQuery(constraint, getLimit(), mParams.directoryId); 393 394 if (cursor != null) { 395 while (cursor.moveToNext()) { 396 tempEntries.add(new TemporaryEntry(cursor, mParams.directoryId)); 397 } 398 } 399 } finally { 400 if (cursor != null) { 401 cursor.close(); 402 } 403 } 404 if (!tempEntries.isEmpty()) { 405 results.values = tempEntries; 406 results.count = 1; 407 } 408 } 409 410 if (DEBUG) { 411 Log.v(TAG, "finished loading directory \"" + mParams.displayName + "\"" + 412 " with query " + constraint); 413 } 414 415 return results; 416 } 417 418 @Override 419 protected void publishResults(final CharSequence constraint, FilterResults results) { 420 if (DEBUG) { 421 Log.d(TAG, "DirectoryFilter#publishResult. constraint: " + constraint 422 + ", mCurrentConstraint: " + mCurrentConstraint); 423 } 424 mDelayedMessageHandler.removeDelayedLoadMessage(); 425 // Check if the received result matches the current constraint 426 // If not - the user must have continued typing after the request was issued, which 427 // means several member variables (like mRemainingDirectoryLoad) are already 428 // overwritten so shouldn't be touched here anymore. 429 if (TextUtils.equals(constraint, mCurrentConstraint)) { 430 if (results.count > 0) { 431 @SuppressWarnings("unchecked") 432 final ArrayList<TemporaryEntry> tempEntries = 433 (ArrayList<TemporaryEntry>) results.values; 434 435 for (TemporaryEntry tempEntry : tempEntries) { 436 putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT, 437 mEntryMap, mNonAggregatedEntries, mExistingDestinations); 438 } 439 } 440 441 // If there are remaining directories, set up delayed message again. 442 mRemainingDirectoryCount--; 443 if (mRemainingDirectoryCount > 0) { 444 if (DEBUG) { 445 Log.d(TAG, "Resend delayed load message. Current mRemainingDirectoryLoad: " 446 + mRemainingDirectoryCount); 447 } 448 mDelayedMessageHandler.sendDelayedLoadMessage(); 449 } 450 451 // If this directory result has some items, or there are no more directories that 452 // we are waiting for, clear the temp results 453 if (results.count > 0 || mRemainingDirectoryCount == 0) { 454 // Clear the temp entries 455 clearTempEntries(); 456 } 457 } 458 459 // Show the list again without "waiting" message. 460 updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries)); 461 } 462 } 463 464 private final Context mContext; 465 private final ContentResolver mContentResolver; 466 private final LayoutInflater mInflater; 467 private Account mAccount; 468 private final int mPreferredMaxResultCount; 469 private DropdownChipLayouter mDropdownChipLayouter; 470 471 /** 472 * {@link #mEntries} is responsible for showing every result for this Adapter. To 473 * construct it, we use {@link #mEntryMap}, {@link #mNonAggregatedEntries}, and 474 * {@link #mExistingDestinations}. 475 * 476 * First, each destination (an email address or a phone number) with a valid contactId is 477 * inserted into {@link #mEntryMap} and grouped by the contactId. Destinations without valid 478 * contactId (possible if they aren't in local storage) are stored in 479 * {@link #mNonAggregatedEntries}. 480 * Duplicates are removed using {@link #mExistingDestinations}. 481 * 482 * After having all results from Cursor objects, all destinations in mEntryMap are copied to 483 * {@link #mEntries}. If the number of destinations is not enough (i.e. less than 484 * {@link #mPreferredMaxResultCount}), destinations in mNonAggregatedEntries are also used. 485 * 486 * These variables are only used in UI thread, thus should not be touched in 487 * performFiltering() methods. 488 */ 489 private LinkedHashMap<Long, List<RecipientEntry>> mEntryMap; 490 private List<RecipientEntry> mNonAggregatedEntries; 491 private Set<String> mExistingDestinations; 492 /** Note: use {@link #updateEntries(List)} to update this variable. */ 493 private List<RecipientEntry> mEntries; 494 private List<RecipientEntry> mTempEntries; 495 496 /** The number of directories this adapter is waiting for results. */ 497 private int mRemainingDirectoryCount; 498 499 /** 500 * Used to ignore asynchronous queries with a different constraint, which may happen when 501 * users type characters quickly. 502 */ 503 private CharSequence mCurrentConstraint; 504 505 private final LruCache<Uri, byte[]> mPhotoCacheMap; 506 507 /** 508 * Handler specific for maintaining "Waiting for more contacts" message, which will be shown 509 * when: 510 * - there are directories to be searched 511 * - results from directories are slow to come 512 */ 513 private final class DelayedMessageHandler extends Handler { 514 @Override 515 public void handleMessage(Message msg) { 516 if (mRemainingDirectoryCount > 0) { 517 updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries)); 518 } 519 } 520 521 public void sendDelayedLoadMessage() { 522 sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null), 523 MESSAGE_SEARCH_PENDING_DELAY); 524 } 525 526 public void removeDelayedLoadMessage() { 527 removeMessages(MESSAGE_SEARCH_PENDING); 528 } 529 } 530 531 private final DelayedMessageHandler mDelayedMessageHandler = new DelayedMessageHandler(); 532 533 private EntriesUpdatedObserver mEntriesUpdatedObserver; 534 535 /** 536 * Constructor for email queries. 537 */ 538 public BaseRecipientAdapter(Context context) { 539 this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL); 540 } 541 542 public BaseRecipientAdapter(Context context, int preferredMaxResultCount) { 543 this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL); 544 } 545 546 public BaseRecipientAdapter(int queryMode, Context context) { 547 this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode); 548 } 549 550 public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) { 551 this(context, preferredMaxResultCount, queryMode); 552 } 553 554 public BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode) { 555 mContext = context; 556 mContentResolver = context.getContentResolver(); 557 mInflater = LayoutInflater.from(context); 558 mPreferredMaxResultCount = preferredMaxResultCount; 559 mPhotoCacheMap = new LruCache<Uri, byte[]>(PHOTO_CACHE_SIZE); 560 mQueryType = queryMode; 561 562 if (queryMode == QUERY_TYPE_EMAIL) { 563 mQuery = Queries.EMAIL; 564 } else if (queryMode == QUERY_TYPE_PHONE) { 565 mQuery = Queries.PHONE; 566 } else { 567 mQuery = Queries.EMAIL; 568 Log.e(TAG, "Unsupported query type: " + queryMode); 569 } 570 } 571 572 public Context getContext() { 573 return mContext; 574 } 575 576 public int getQueryType() { 577 return mQueryType; 578 } 579 580 public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) { 581 mDropdownChipLayouter = dropdownChipLayouter; 582 mDropdownChipLayouter.setQuery(mQuery); 583 } 584 585 public DropdownChipLayouter getDropdownChipLayouter() { 586 return mDropdownChipLayouter; 587 } 588 589 /** 590 * Set the account when known. Causes the search to prioritize contacts from that account. 591 */ 592 @Override 593 public void setAccount(Account account) { 594 mAccount = account; 595 } 596 597 /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */ 598 @Override 599 public Filter getFilter() { 600 return new DefaultFilter(); 601 } 602 603 /** 604 * An extesion to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows 605 * additional sources of contacts to be considered as matching recipients. 606 * @param addresses A set of addresses to be matched 607 * @return A list of matches or null if none found 608 */ 609 public Map<String, RecipientEntry> getMatchingRecipients(Set<String> addresses) { 610 return null; 611 } 612 613 public static List<DirectorySearchParams> setupOtherDirectories(Context context, 614 Cursor directoryCursor, Account account) { 615 final PackageManager packageManager = context.getPackageManager(); 616 final List<DirectorySearchParams> paramsList = new ArrayList<DirectorySearchParams>(); 617 DirectorySearchParams preferredDirectory = null; 618 while (directoryCursor.moveToNext()) { 619 final long id = directoryCursor.getLong(DirectoryListQuery.ID); 620 621 // Skip the local invisible directory, because the default directory already includes 622 // all local results. 623 if (id == Directory.LOCAL_INVISIBLE) { 624 continue; 625 } 626 627 final DirectorySearchParams params = new DirectorySearchParams(); 628 final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME); 629 final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID); 630 params.directoryId = id; 631 params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME); 632 params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME); 633 params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE); 634 if (packageName != null && resourceId != 0) { 635 try { 636 final Resources resources = 637 packageManager.getResourcesForApplication(packageName); 638 params.directoryType = resources.getString(resourceId); 639 if (params.directoryType == null) { 640 Log.e(TAG, "Cannot resolve directory name: " 641 + resourceId + "@" + packageName); 642 } 643 } catch (NameNotFoundException e) { 644 Log.e(TAG, "Cannot resolve directory name: " 645 + resourceId + "@" + packageName, e); 646 } 647 } 648 649 // If an account has been provided and we found a directory that 650 // corresponds to that account, place that directory second, directly 651 // underneath the local contacts. 652 if (account != null && account.name.equals(params.accountName) && 653 account.type.equals(params.accountType)) { 654 preferredDirectory = params; 655 } else { 656 paramsList.add(params); 657 } 658 } 659 660 if (preferredDirectory != null) { 661 paramsList.add(1, preferredDirectory); 662 } 663 664 return paramsList; 665 } 666 667 /** 668 * Starts search in other directories using {@link Filter}. Results will be handled in 669 * {@link DirectoryFilter}. 670 */ 671 protected void startSearchOtherDirectories( 672 CharSequence constraint, List<DirectorySearchParams> paramsList, int limit) { 673 final int count = paramsList.size(); 674 // Note: skipping the default partition (index 0), which has already been loaded 675 for (int i = 1; i < count; i++) { 676 final DirectorySearchParams params = paramsList.get(i); 677 params.constraint = constraint; 678 if (params.filter == null) { 679 params.filter = new DirectoryFilter(params); 680 } 681 params.filter.setLimit(limit); 682 params.filter.filter(constraint); 683 } 684 685 // Directory search started. We may show "waiting" message if directory results are slow 686 // enough. 687 mRemainingDirectoryCount = count - 1; 688 mDelayedMessageHandler.sendDelayedLoadMessage(); 689 } 690 691 private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry, 692 LinkedHashMap<Long, List<RecipientEntry>> entryMap, 693 List<RecipientEntry> nonAggregatedEntries, 694 Set<String> existingDestinations) { 695 if (existingDestinations.contains(entry.destination)) { 696 return; 697 } 698 699 existingDestinations.add(entry.destination); 700 701 if (!isAggregatedEntry) { 702 nonAggregatedEntries.add(RecipientEntry.constructTopLevelEntry( 703 entry.displayName, 704 entry.displayNameSource, 705 entry.destination, entry.destinationType, entry.destinationLabel, 706 entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString, 707 true, entry.lookupKey)); 708 } else if (entryMap.containsKey(entry.contactId)) { 709 // We already have a section for the person. 710 final List<RecipientEntry> entryList = entryMap.get(entry.contactId); 711 entryList.add(RecipientEntry.constructSecondLevelEntry( 712 entry.displayName, 713 entry.displayNameSource, 714 entry.destination, entry.destinationType, entry.destinationLabel, 715 entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString, 716 true, entry.lookupKey)); 717 } else { 718 final List<RecipientEntry> entryList = new ArrayList<RecipientEntry>(); 719 entryList.add(RecipientEntry.constructTopLevelEntry( 720 entry.displayName, 721 entry.displayNameSource, 722 entry.destination, entry.destinationType, entry.destinationLabel, 723 entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString, 724 true, entry.lookupKey)); 725 entryMap.put(entry.contactId, entryList); 726 } 727 } 728 729 /** 730 * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to 731 * fetch a cached photo for each contact entry (other than separators), or request another 732 * thread to get one from directories. 733 */ 734 private List<RecipientEntry> constructEntryList( 735 LinkedHashMap<Long, List<RecipientEntry>> entryMap, 736 List<RecipientEntry> nonAggregatedEntries) { 737 final List<RecipientEntry> entries = new ArrayList<RecipientEntry>(); 738 int validEntryCount = 0; 739 for (Map.Entry<Long, List<RecipientEntry>> mapEntry : entryMap.entrySet()) { 740 final List<RecipientEntry> entryList = mapEntry.getValue(); 741 final int size = entryList.size(); 742 for (int i = 0; i < size; i++) { 743 RecipientEntry entry = entryList.get(i); 744 entries.add(entry); 745 tryFetchPhoto(entry); 746 validEntryCount++; 747 } 748 if (validEntryCount > mPreferredMaxResultCount) { 749 break; 750 } 751 } 752 if (validEntryCount <= mPreferredMaxResultCount) { 753 for (RecipientEntry entry : nonAggregatedEntries) { 754 if (validEntryCount > mPreferredMaxResultCount) { 755 break; 756 } 757 entries.add(entry); 758 tryFetchPhoto(entry); 759 760 validEntryCount++; 761 } 762 } 763 764 return entries; 765 } 766 767 768 protected interface EntriesUpdatedObserver { 769 public void onChanged(List<RecipientEntry> entries); 770 } 771 772 public void registerUpdateObserver(EntriesUpdatedObserver observer) { 773 mEntriesUpdatedObserver = observer; 774 } 775 776 /** Resets {@link #mEntries} and notify the event to its parent ListView. */ 777 private void updateEntries(List<RecipientEntry> newEntries) { 778 mEntries = newEntries; 779 mEntriesUpdatedObserver.onChanged(newEntries); 780 notifyDataSetChanged(); 781 } 782 783 private void cacheCurrentEntries() { 784 mTempEntries = mEntries; 785 } 786 787 private void clearTempEntries() { 788 mTempEntries = null; 789 } 790 791 protected List<RecipientEntry> getEntries() { 792 return mTempEntries != null ? mTempEntries : mEntries; 793 } 794 795 private void tryFetchPhoto(final RecipientEntry entry) { 796 final Uri photoThumbnailUri = entry.getPhotoThumbnailUri(); 797 if (photoThumbnailUri != null) { 798 final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri); 799 if (photoBytes != null) { 800 entry.setPhotoBytes(photoBytes); 801 // notifyDataSetChanged() should be called by a caller. 802 } else { 803 if (DEBUG) { 804 Log.d(TAG, "No photo cache for " + entry.getDisplayName() 805 + ". Fetch one asynchronously"); 806 } 807 fetchPhotoAsync(entry, photoThumbnailUri); 808 } 809 } 810 } 811 812 // For reading photos for directory contacts, this is the chunksize for 813 // copying from the inputstream to the output stream. 814 private static final int BUFFER_SIZE = 1024*16; 815 816 private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri) { 817 final AsyncTask<Void, Void, byte[]> photoLoadTask = new AsyncTask<Void, Void, byte[]>() { 818 @Override 819 protected byte[] doInBackground(Void... params) { 820 // First try running a query. Images for local contacts are 821 // loaded by sending a query to the ContactsProvider. 822 final Cursor photoCursor = mContentResolver.query( 823 photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null); 824 if (photoCursor != null) { 825 try { 826 if (photoCursor.moveToFirst()) { 827 return photoCursor.getBlob(PhotoQuery.PHOTO); 828 } 829 } finally { 830 photoCursor.close(); 831 } 832 } else { 833 // If the query fails, try streaming the URI directly. 834 // For remote directory images, this URI resolves to the 835 // directory provider and the images are loaded by sending 836 // an openFile call to the provider. 837 try { 838 InputStream is = mContentResolver.openInputStream( 839 photoThumbnailUri); 840 if (is != null) { 841 byte[] buffer = new byte[BUFFER_SIZE]; 842 ByteArrayOutputStream baos = new ByteArrayOutputStream(); 843 try { 844 int size; 845 while ((size = is.read(buffer)) != -1) { 846 baos.write(buffer, 0, size); 847 } 848 } finally { 849 is.close(); 850 } 851 return baos.toByteArray(); 852 } 853 } catch (IOException ex) { 854 // ignore 855 } 856 } 857 return null; 858 } 859 860 @Override 861 protected void onPostExecute(final byte[] photoBytes) { 862 entry.setPhotoBytes(photoBytes); 863 if (photoBytes != null) { 864 mPhotoCacheMap.put(photoThumbnailUri, photoBytes); 865 notifyDataSetChanged(); 866 } 867 } 868 }; 869 photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR); 870 } 871 872 protected void fetchPhoto(final RecipientEntry entry, final Uri photoThumbnailUri) { 873 byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri); 874 if (photoBytes != null) { 875 entry.setPhotoBytes(photoBytes); 876 return; 877 } 878 final Cursor photoCursor = mContentResolver.query(photoThumbnailUri, PhotoQuery.PROJECTION, 879 null, null, null); 880 if (photoCursor != null) { 881 try { 882 if (photoCursor.moveToFirst()) { 883 photoBytes = photoCursor.getBlob(PhotoQuery.PHOTO); 884 entry.setPhotoBytes(photoBytes); 885 mPhotoCacheMap.put(photoThumbnailUri, photoBytes); 886 } 887 } finally { 888 photoCursor.close(); 889 } 890 } else { 891 InputStream inputStream = null; 892 ByteArrayOutputStream outputStream = null; 893 try { 894 inputStream = mContentResolver.openInputStream(photoThumbnailUri); 895 final Bitmap bitmap = BitmapFactory.decodeStream(inputStream); 896 897 if (bitmap != null) { 898 outputStream = new ByteArrayOutputStream(); 899 bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream); 900 photoBytes = outputStream.toByteArray(); 901 902 entry.setPhotoBytes(photoBytes); 903 mPhotoCacheMap.put(photoThumbnailUri, photoBytes); 904 } 905 } catch (final FileNotFoundException e) { 906 Log.w(TAG, "Error opening InputStream for photo", e); 907 } finally { 908 try { 909 if (inputStream != null) { 910 inputStream.close(); 911 } 912 } catch (IOException e) { 913 Log.e(TAG, "Error closing photo input stream", e); 914 } 915 try { 916 if (outputStream != null) { 917 outputStream.close(); 918 } 919 } catch (IOException e) { 920 Log.e(TAG, "Error closing photo output stream", e); 921 } 922 } 923 } 924 } 925 926 private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) { 927 final Uri.Builder builder = mQuery.getContentFilterUri().buildUpon() 928 .appendPath(constraint.toString()) 929 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY, 930 String.valueOf(limit + ALLOWANCE_FOR_DUPLICATES)); 931 if (directoryId != null) { 932 builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, 933 String.valueOf(directoryId)); 934 } 935 if (mAccount != null) { 936 builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name); 937 builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type); 938 } 939 final long start = System.currentTimeMillis(); 940 final Cursor cursor = mContentResolver.query( 941 builder.build(), mQuery.getProjection(), null, null, null); 942 final long end = System.currentTimeMillis(); 943 if (DEBUG) { 944 Log.d(TAG, "Time for autocomplete (query: " + constraint 945 + ", directoryId: " + directoryId + ", num_of_results: " 946 + (cursor != null ? cursor.getCount() : "null") + "): " 947 + (end - start) + " ms"); 948 } 949 return cursor; 950 } 951 952 // TODO: This won't be used at all. We should find better way to quit the thread.. 953 /*public void close() { 954 mEntries = null; 955 mPhotoCacheMap.evictAll(); 956 if (!sPhotoHandlerThread.quit()) { 957 Log.w(TAG, "Failed to quit photo handler thread, ignoring it."); 958 } 959 }*/ 960 961 @Override 962 public int getCount() { 963 final List<RecipientEntry> entries = getEntries(); 964 return entries != null ? entries.size() : 0; 965 } 966 967 @Override 968 public RecipientEntry getItem(int position) { 969 return getEntries().get(position); 970 } 971 972 @Override 973 public long getItemId(int position) { 974 return position; 975 } 976 977 @Override 978 public int getViewTypeCount() { 979 return RecipientEntry.ENTRY_TYPE_SIZE; 980 } 981 982 @Override 983 public int getItemViewType(int position) { 984 return getEntries().get(position).getEntryType(); 985 } 986 987 @Override 988 public boolean isEnabled(int position) { 989 return getEntries().get(position).isSelectable(); 990 } 991 992 @Override 993 public View getView(int position, View convertView, ViewGroup parent) { 994 final RecipientEntry entry = getEntries().get(position); 995 996 final String constraint = mCurrentConstraint == null ? null : 997 mCurrentConstraint.toString(); 998 999 return mDropdownChipLayouter.bindView(convertView, parent, entry, position, 1000 AdapterType.BASE_RECIPIENT, constraint); 1001 } 1002 1003 public Account getAccount() { 1004 return mAccount; 1005 } 1006} 1007