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