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