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 androidx.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