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 = 1;
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                 // If there are no local results, in the new result set, cache off what had been
285                 // shown to the user for use until the first directory result is returned
286                 if (defaultFilterResult.entries.size() == 0 &&
287                         defaultFilterResult.paramsList != null) {
288                     cacheCurrentEntries();
289                 }
290 
291                 updateEntries(defaultFilterResult.entries);
292 
293                 // We need to search other remote directories, doing other Filter requests.
294                 if (defaultFilterResult.paramsList != null) {
295                     final int limit = mPreferredMaxResultCount -
296                             defaultFilterResult.existingDestinations.size();
297                     startSearchOtherDirectories(constraint, defaultFilterResult.paramsList, limit);
298                 }
299             } else {
300                 updateEntries(Collections.<RecipientEntry>emptyList());
301             }
302         }
303 
304         @Override
convertResultToString(Object resultValue)305         public CharSequence convertResultToString(Object resultValue) {
306             final RecipientEntry entry = (RecipientEntry)resultValue;
307             final String displayName = entry.getDisplayName();
308             final String emailAddress = entry.getDestination();
309             if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
310                  return emailAddress;
311             } else {
312                 return new Rfc822Token(displayName, emailAddress, null).toString();
313             }
314         }
315     }
316 
searchOtherDirectories(Set<String> existingDestinations)317     protected List<DirectorySearchParams> searchOtherDirectories(Set<String> existingDestinations) {
318         // After having local results, check the size of results. If the results are
319         // not enough, we search remote directories, which will take longer time.
320         final int limit = mPreferredMaxResultCount - existingDestinations.size();
321         if (limit > 0) {
322             if (DEBUG) {
323                 Log.d(TAG, "More entries should be needed (current: "
324                         + existingDestinations.size()
325                         + ", remaining limit: " + limit + ") ");
326             }
327             final Cursor directoryCursor = mContentResolver.query(
328                     DirectoryListQuery.URI, DirectoryListQuery.PROJECTION,
329                     null, null, null);
330             return setupOtherDirectories(mContext, directoryCursor, mAccount);
331         } else {
332             // We don't need to search other directories.
333             return null;
334         }
335     }
336 
337     /**
338      * An asynchronous filter that performs search in a particular directory.
339      */
340     protected class DirectoryFilter extends Filter {
341         private final DirectorySearchParams mParams;
342         private int mLimit;
343 
DirectoryFilter(DirectorySearchParams params)344         public DirectoryFilter(DirectorySearchParams params) {
345             mParams = params;
346         }
347 
setLimit(int limit)348         public synchronized void setLimit(int limit) {
349             this.mLimit = limit;
350         }
351 
getLimit()352         public synchronized int getLimit() {
353             return this.mLimit;
354         }
355 
356         @Override
performFiltering(CharSequence constraint)357         protected FilterResults performFiltering(CharSequence constraint) {
358             if (DEBUG) {
359                 Log.d(TAG, "DirectoryFilter#performFiltering. directoryId: " + mParams.directoryId
360                         + ", constraint: " + constraint + ", thread: " + Thread.currentThread());
361             }
362             final FilterResults results = new FilterResults();
363             results.values = null;
364             results.count = 0;
365 
366             if (!TextUtils.isEmpty(constraint)) {
367                 final ArrayList<TemporaryEntry> tempEntries = new ArrayList<TemporaryEntry>();
368 
369                 Cursor cursor = null;
370                 try {
371                     // We don't want to pass this Cursor object to UI thread (b/5017608).
372                     // Assuming the result should contain fairly small results (at most ~10),
373                     // We just copy everything to local structure.
374                     cursor = doQuery(constraint, getLimit(), mParams.directoryId);
375 
376                     if (cursor != null) {
377                         while (cursor.moveToNext()) {
378                             tempEntries.add(new TemporaryEntry(cursor, mParams.directoryId));
379                         }
380                     }
381                 } finally {
382                     if (cursor != null) {
383                         cursor.close();
384                     }
385                 }
386                 if (!tempEntries.isEmpty()) {
387                     results.values = tempEntries;
388                     results.count = 1;
389                 }
390             }
391 
392             if (DEBUG) {
393                 Log.v(TAG, "finished loading directory \"" + mParams.displayName + "\"" +
394                         " with query " + constraint);
395             }
396 
397             return results;
398         }
399 
400         @Override
publishResults(final CharSequence constraint, FilterResults results)401         protected void publishResults(final CharSequence constraint, FilterResults results) {
402             if (DEBUG) {
403                 Log.d(TAG, "DirectoryFilter#publishResult. constraint: " + constraint
404                         + ", mCurrentConstraint: " + mCurrentConstraint);
405             }
406             mDelayedMessageHandler.removeDelayedLoadMessage();
407             // Check if the received result matches the current constraint
408             // If not - the user must have continued typing after the request was issued, which
409             // means several member variables (like mRemainingDirectoryLoad) are already
410             // overwritten so shouldn't be touched here anymore.
411             if (TextUtils.equals(constraint, mCurrentConstraint)) {
412                 if (results.count > 0) {
413                     @SuppressWarnings("unchecked")
414                     final ArrayList<TemporaryEntry> tempEntries =
415                             (ArrayList<TemporaryEntry>) results.values;
416 
417                     for (TemporaryEntry tempEntry : tempEntries) {
418                         putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT);
419                     }
420                 }
421 
422                 // If there are remaining directories, set up delayed message again.
423                 mRemainingDirectoryCount--;
424                 if (mRemainingDirectoryCount > 0) {
425                     if (DEBUG) {
426                         Log.d(TAG, "Resend delayed load message. Current mRemainingDirectoryLoad: "
427                                 + mRemainingDirectoryCount);
428                     }
429                     mDelayedMessageHandler.sendDelayedLoadMessage();
430                 }
431 
432                 // If this directory result has some items, or there are no more directories that
433                 // we are waiting for, clear the temp results
434                 if (results.count > 0 || mRemainingDirectoryCount == 0) {
435                     // Clear the temp entries
436                     clearTempEntries();
437                 }
438             }
439 
440             // Show the list again without "waiting" message.
441             updateEntries(constructEntryList());
442         }
443     }
444 
445     private final Context mContext;
446     private final ContentResolver mContentResolver;
447     private Account mAccount;
448     protected final int mPreferredMaxResultCount;
449     private DropdownChipLayouter mDropdownChipLayouter;
450 
451     /**
452      * {@link #mEntries} is responsible for showing every result for this Adapter. To
453      * construct it, we use {@link #mEntryMap}, {@link #mNonAggregatedEntries}, and
454      * {@link #mExistingDestinations}.
455      *
456      * First, each destination (an email address or a phone number) with a valid contactId is
457      * inserted into {@link #mEntryMap} and grouped by the contactId. Destinations without valid
458      * contactId (possible if they aren't in local storage) are stored in
459      * {@link #mNonAggregatedEntries}.
460      * Duplicates are removed using {@link #mExistingDestinations}.
461      *
462      * After having all results from Cursor objects, all destinations in mEntryMap are copied to
463      * {@link #mEntries}. If the number of destinations is not enough (i.e. less than
464      * {@link #mPreferredMaxResultCount}), destinations in mNonAggregatedEntries are also used.
465      *
466      * These variables are only used in UI thread, thus should not be touched in
467      * performFiltering() methods.
468      */
469     private LinkedHashMap<Long, List<RecipientEntry>> mEntryMap;
470     private List<RecipientEntry> mNonAggregatedEntries;
471     private Set<String> mExistingDestinations;
472     /** Note: use {@link #updateEntries(List)} to update this variable. */
473     private List<RecipientEntry> mEntries;
474     private List<RecipientEntry> mTempEntries;
475 
476     /** The number of directories this adapter is waiting for results. */
477     private int mRemainingDirectoryCount;
478 
479     /**
480      * Used to ignore asynchronous queries with a different constraint, which may happen when
481      * users type characters quickly.
482      */
483     protected CharSequence mCurrentConstraint;
484 
485     /**
486      * Performs all photo querying as well as caching for repeated lookups.
487      */
488     private PhotoManager mPhotoManager;
489 
490     /**
491      * Handler specific for maintaining "Waiting for more contacts" message, which will be shown
492      * when:
493      * - there are directories to be searched
494      * - results from directories are slow to come
495      */
496     private final class DelayedMessageHandler extends Handler {
497         @Override
handleMessage(Message msg)498         public void handleMessage(Message msg) {
499             if (mRemainingDirectoryCount > 0) {
500                 updateEntries(constructEntryList());
501             }
502         }
503 
sendDelayedLoadMessage()504         public void sendDelayedLoadMessage() {
505             sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null),
506                     MESSAGE_SEARCH_PENDING_DELAY);
507         }
508 
removeDelayedLoadMessage()509         public void removeDelayedLoadMessage() {
510             removeMessages(MESSAGE_SEARCH_PENDING);
511         }
512     }
513 
514     private final DelayedMessageHandler mDelayedMessageHandler = new DelayedMessageHandler();
515 
516     private EntriesUpdatedObserver mEntriesUpdatedObserver;
517 
518     /**
519      * Constructor for email queries.
520      */
BaseRecipientAdapter(Context context)521     public BaseRecipientAdapter(Context context) {
522         this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL);
523     }
524 
BaseRecipientAdapter(Context context, int preferredMaxResultCount)525     public BaseRecipientAdapter(Context context, int preferredMaxResultCount) {
526         this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL);
527     }
528 
BaseRecipientAdapter(int queryMode, Context context)529     public BaseRecipientAdapter(int queryMode, Context context) {
530         this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode);
531     }
532 
BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount)533     public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) {
534         this(context, preferredMaxResultCount, queryMode);
535     }
536 
BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode)537     public BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode) {
538         mContext = context;
539         mContentResolver = context.getContentResolver();
540         mPreferredMaxResultCount = preferredMaxResultCount;
541         mPhotoManager = new DefaultPhotoManager(mContentResolver);
542         mQueryType = queryMode;
543 
544         if (queryMode == QUERY_TYPE_EMAIL) {
545             mQueryMode = Queries.EMAIL;
546         } else if (queryMode == QUERY_TYPE_PHONE) {
547             mQueryMode = Queries.PHONE;
548         } else {
549             mQueryMode = Queries.EMAIL;
550             Log.e(TAG, "Unsupported query type: " + queryMode);
551         }
552     }
553 
getContext()554     public Context getContext() {
555         return mContext;
556     }
557 
getQueryType()558     public int getQueryType() {
559         return mQueryType;
560     }
561 
setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter)562     public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
563         mDropdownChipLayouter = dropdownChipLayouter;
564         mDropdownChipLayouter.setQuery(mQueryMode);
565     }
566 
getDropdownChipLayouter()567     public DropdownChipLayouter getDropdownChipLayouter() {
568         return mDropdownChipLayouter;
569     }
570 
571     /**
572      * Enables overriding the default photo manager that is used.
573      */
setPhotoManager(PhotoManager photoManager)574     public void setPhotoManager(PhotoManager photoManager) {
575         mPhotoManager = photoManager;
576     }
577 
getPhotoManager()578     public PhotoManager getPhotoManager() {
579         return mPhotoManager;
580     }
581 
582     /**
583      * If true, forces using the {@link com.android.ex.chips.SingleRecipientArrayAdapter}
584      * instead of {@link com.android.ex.chips.RecipientAlternatesAdapter} when
585      * clicking on a chip. Default implementation returns {@code false}.
586      */
forceShowAddress()587     public boolean forceShowAddress() {
588         return false;
589     }
590 
591     /**
592      * Used to replace email addresses with chips. Default behavior
593      * queries the ContactsProvider for contact information about the contact.
594      * Derived classes should override this method if they wish to use a
595      * new data source.
596      * @param inAddresses addresses to query
597      * @param callback callback to return results in case of success or failure
598      */
getMatchingRecipients(ArrayList<String> inAddresses, RecipientAlternatesAdapter.RecipientMatchCallback callback)599     public void getMatchingRecipients(ArrayList<String> inAddresses,
600             RecipientAlternatesAdapter.RecipientMatchCallback callback) {
601         RecipientAlternatesAdapter.getMatchingRecipients(
602                 getContext(), this, inAddresses, getAccount(), callback);
603     }
604 
605     /**
606      * Set the account when known. Causes the search to prioritize contacts from that account.
607      */
608     @Override
setAccount(Account account)609     public void setAccount(Account account) {
610         mAccount = account;
611     }
612 
613     /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */
614     @Override
getFilter()615     public Filter getFilter() {
616         return new DefaultFilter();
617     }
618 
619     /**
620      * An extension to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows
621      * additional sources of contacts to be considered as matching recipients.
622      * @param addresses A set of addresses to be matched
623      * @return A list of matches or null if none found
624      */
getMatchingRecipients(Set<String> addresses)625     public Map<String, RecipientEntry> getMatchingRecipients(Set<String> addresses) {
626         return null;
627     }
628 
setupOtherDirectories(Context context, Cursor directoryCursor, Account account)629     public static List<DirectorySearchParams> setupOtherDirectories(Context context,
630             Cursor directoryCursor, Account account) {
631         final PackageManager packageManager = context.getPackageManager();
632         final List<DirectorySearchParams> paramsList = new ArrayList<DirectorySearchParams>();
633         DirectorySearchParams preferredDirectory = null;
634         while (directoryCursor.moveToNext()) {
635             final long id = directoryCursor.getLong(DirectoryListQuery.ID);
636 
637             // Skip the local invisible directory, because the default directory already includes
638             // all local results.
639             if (id == Directory.LOCAL_INVISIBLE) {
640                 continue;
641             }
642 
643             final DirectorySearchParams params = new DirectorySearchParams();
644             final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME);
645             final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID);
646             params.directoryId = id;
647             params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME);
648             params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME);
649             params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE);
650             if (packageName != null && resourceId != 0) {
651                 try {
652                     final Resources resources =
653                             packageManager.getResourcesForApplication(packageName);
654                     params.directoryType = resources.getString(resourceId);
655                     if (params.directoryType == null) {
656                         Log.e(TAG, "Cannot resolve directory name: "
657                                 + resourceId + "@" + packageName);
658                     }
659                 } catch (NameNotFoundException e) {
660                     Log.e(TAG, "Cannot resolve directory name: "
661                             + resourceId + "@" + packageName, e);
662                 }
663             }
664 
665             // If an account has been provided and we found a directory that
666             // corresponds to that account, place that directory second, directly
667             // underneath the local contacts.
668             if (account != null && account.name.equals(params.accountName) &&
669                     account.type.equals(params.accountType)) {
670                 preferredDirectory = params;
671             } else {
672                 paramsList.add(params);
673             }
674         }
675 
676         if (preferredDirectory != null) {
677             paramsList.add(1, preferredDirectory);
678         }
679 
680         return paramsList;
681     }
682 
683     /**
684      * Starts search in other directories using {@link Filter}. Results will be handled in
685      * {@link DirectoryFilter}.
686      */
startSearchOtherDirectories( CharSequence constraint, List<DirectorySearchParams> paramsList, int limit)687     protected void startSearchOtherDirectories(
688             CharSequence constraint, List<DirectorySearchParams> paramsList, int limit) {
689         final int count = paramsList.size();
690         // Note: skipping the default partition (index 0), which has already been loaded
691         for (int i = 1; i < count; i++) {
692             final DirectorySearchParams params = paramsList.get(i);
693             params.constraint = constraint;
694             if (params.filter == null) {
695                 params.filter = new DirectoryFilter(params);
696             }
697             params.filter.setLimit(limit);
698             params.filter.filter(constraint);
699         }
700 
701         // Directory search started. We may show "waiting" message if directory results are slow
702         // enough.
703         mRemainingDirectoryCount = count - 1;
704         mDelayedMessageHandler.sendDelayedLoadMessage();
705     }
706 
707     /**
708      * Called whenever {@link com.android.ex.chips.BaseRecipientAdapter.DirectoryFilter}
709      * wants to add an additional entry to the results. Derived classes should override
710      * this method if they are not using the default data structures provided by
711      * {@link com.android.ex.chips.BaseRecipientAdapter} and are instead using their
712      * own data structures to store and collate data.
713      * @param entry the entry being added
714      * @param isAggregatedEntry
715      */
putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry)716     protected void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry) {
717         putOneEntry(entry, isAggregatedEntry,
718                 mEntryMap, mNonAggregatedEntries, mExistingDestinations);
719     }
720 
putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry, LinkedHashMap<Long, List<RecipientEntry>> entryMap, List<RecipientEntry> nonAggregatedEntries, Set<String> existingDestinations)721     private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry,
722             LinkedHashMap<Long, List<RecipientEntry>> entryMap,
723             List<RecipientEntry> nonAggregatedEntries,
724             Set<String> existingDestinations) {
725         if (existingDestinations.contains(entry.destination)) {
726             return;
727         }
728 
729         existingDestinations.add(entry.destination);
730 
731         if (!isAggregatedEntry) {
732             nonAggregatedEntries.add(RecipientEntry.constructTopLevelEntry(
733                     entry.displayName,
734                     entry.displayNameSource,
735                     entry.destination, entry.destinationType, entry.destinationLabel,
736                     entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
737                     true, entry.lookupKey));
738         } else if (entryMap.containsKey(entry.contactId)) {
739             // We already have a section for the person.
740             final List<RecipientEntry> entryList = entryMap.get(entry.contactId);
741             entryList.add(RecipientEntry.constructSecondLevelEntry(
742                     entry.displayName,
743                     entry.displayNameSource,
744                     entry.destination, entry.destinationType, entry.destinationLabel,
745                     entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
746                     true, entry.lookupKey));
747         } else {
748             final List<RecipientEntry> entryList = new ArrayList<RecipientEntry>();
749             entryList.add(RecipientEntry.constructTopLevelEntry(
750                     entry.displayName,
751                     entry.displayNameSource,
752                     entry.destination, entry.destinationType, entry.destinationLabel,
753                     entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
754                     true, entry.lookupKey));
755             entryMap.put(entry.contactId, entryList);
756         }
757     }
758 
759     /**
760      * Returns the actual list to use for this Adapter. Derived classes
761      * should override this method if overriding how the adapter stores and collates
762      * data.
763      */
constructEntryList()764     protected List<RecipientEntry> constructEntryList() {
765         return constructEntryList(mEntryMap, mNonAggregatedEntries);
766     }
767 
768     /**
769      * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to
770      * fetch a cached photo for each contact entry (other than separators), or request another
771      * thread to get one from directories.
772      */
constructEntryList( LinkedHashMap<Long, List<RecipientEntry>> entryMap, List<RecipientEntry> nonAggregatedEntries)773     private List<RecipientEntry> constructEntryList(
774             LinkedHashMap<Long, List<RecipientEntry>> entryMap,
775             List<RecipientEntry> nonAggregatedEntries) {
776         final List<RecipientEntry> entries = new ArrayList<RecipientEntry>();
777         int validEntryCount = 0;
778         for (Map.Entry<Long, List<RecipientEntry>> mapEntry : entryMap.entrySet()) {
779             final List<RecipientEntry> entryList = mapEntry.getValue();
780             final int size = entryList.size();
781             for (int i = 0; i < size; i++) {
782                 RecipientEntry entry = entryList.get(i);
783                 entries.add(entry);
784                 mPhotoManager.populatePhotoBytesAsync(entry, this);
785                 validEntryCount++;
786             }
787             if (validEntryCount > mPreferredMaxResultCount) {
788                 break;
789             }
790         }
791         if (validEntryCount <= mPreferredMaxResultCount) {
792             for (RecipientEntry entry : nonAggregatedEntries) {
793                 if (validEntryCount > mPreferredMaxResultCount) {
794                     break;
795                 }
796                 entries.add(entry);
797                 mPhotoManager.populatePhotoBytesAsync(entry, this);
798                 validEntryCount++;
799             }
800         }
801 
802         return entries;
803     }
804 
805 
806     public interface EntriesUpdatedObserver {
onChanged(List<RecipientEntry> entries)807         public void onChanged(List<RecipientEntry> entries);
808     }
809 
registerUpdateObserver(EntriesUpdatedObserver observer)810     public void registerUpdateObserver(EntriesUpdatedObserver observer) {
811         mEntriesUpdatedObserver = observer;
812     }
813 
814     /** Resets {@link #mEntries} and notify the event to its parent ListView. */
updateEntries(List<RecipientEntry> newEntries)815     protected void updateEntries(List<RecipientEntry> newEntries) {
816         mEntries = newEntries;
817         mEntriesUpdatedObserver.onChanged(newEntries);
818         notifyDataSetChanged();
819     }
820 
cacheCurrentEntries()821     protected void cacheCurrentEntries() {
822         mTempEntries = mEntries;
823     }
824 
clearTempEntries()825     protected void clearTempEntries() {
826         mTempEntries = null;
827     }
828 
getEntries()829     protected List<RecipientEntry> getEntries() {
830         return mTempEntries != null ? mTempEntries : mEntries;
831     }
832 
fetchPhoto(final RecipientEntry entry, PhotoManager.PhotoManagerCallback cb)833     protected void fetchPhoto(final RecipientEntry entry, PhotoManager.PhotoManagerCallback cb) {
834         mPhotoManager.populatePhotoBytesAsync(entry, cb);
835     }
836 
doQuery(CharSequence constraint, int limit, Long directoryId)837     private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) {
838         final Uri.Builder builder = mQueryMode.getContentFilterUri().buildUpon()
839                 .appendPath(constraint.toString())
840                 .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
841                         String.valueOf(limit + ALLOWANCE_FOR_DUPLICATES));
842         if (directoryId != null) {
843             builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
844                     String.valueOf(directoryId));
845         }
846         if (mAccount != null) {
847             builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name);
848             builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type);
849         }
850         final long start = System.currentTimeMillis();
851         final Cursor cursor = mContentResolver.query(
852                 builder.build(), mQueryMode.getProjection(), null, null, null);
853         final long end = System.currentTimeMillis();
854         if (DEBUG) {
855             Log.d(TAG, "Time for autocomplete (query: " + constraint
856                     + ", directoryId: " + directoryId + ", num_of_results: "
857                     + (cursor != null ? cursor.getCount() : "null") + "): "
858                     + (end - start) + " ms");
859         }
860         return cursor;
861     }
862 
863     // TODO: This won't be used at all. We should find better way to quit the thread..
864     /*public void close() {
865         mEntries = null;
866         mPhotoCacheMap.evictAll();
867         if (!sPhotoHandlerThread.quit()) {
868             Log.w(TAG, "Failed to quit photo handler thread, ignoring it.");
869         }
870     }*/
871 
872     @Override
getCount()873     public int getCount() {
874         final List<RecipientEntry> entries = getEntries();
875         return entries != null ? entries.size() : 0;
876     }
877 
878     @Override
getItem(int position)879     public RecipientEntry getItem(int position) {
880         return getEntries().get(position);
881     }
882 
883     @Override
getItemId(int position)884     public long getItemId(int position) {
885         return position;
886     }
887 
888     @Override
getViewTypeCount()889     public int getViewTypeCount() {
890         return RecipientEntry.ENTRY_TYPE_SIZE;
891     }
892 
893     @Override
getItemViewType(int position)894     public int getItemViewType(int position) {
895         return getEntries().get(position).getEntryType();
896     }
897 
898     @Override
isEnabled(int position)899     public boolean isEnabled(int position) {
900         return getEntries().get(position).isSelectable();
901     }
902 
903     @Override
getView(int position, View convertView, ViewGroup parent)904     public View getView(int position, View convertView, ViewGroup parent) {
905         final RecipientEntry entry = getEntries().get(position);
906 
907         final String constraint = mCurrentConstraint == null ? null :
908                 mCurrentConstraint.toString();
909 
910         return mDropdownChipLayouter.bindView(convertView, parent, entry, position,
911                 AdapterType.BASE_RECIPIENT, constraint);
912     }
913 
getAccount()914     public Account getAccount() {
915         return mAccount;
916     }
917 
918     @Override
onPhotoBytesPopulated()919     public void onPhotoBytesPopulated() {
920         // Default implementation does nothing
921     }
922 
923     @Override
onPhotoBytesAsynchronouslyPopulated()924     public void onPhotoBytesAsynchronouslyPopulated() {
925         notifyDataSetChanged();
926     }
927 
928     @Override
onPhotoBytesAsyncLoadFailed()929     public void onPhotoBytesAsyncLoadFailed() {
930         // Default implementation does nothing
931     }
932 }
933