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