1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.ex.chips;
18
19import android.accounts.Account;
20import android.app.Activity;
21import android.content.ContentResolver;
22import android.content.Context;
23import android.content.pm.PackageManager;
24import android.content.pm.PackageManager.NameNotFoundException;
25import android.content.res.Resources;
26import android.database.Cursor;
27import android.database.MatrixCursor;
28import android.net.Uri;
29import android.os.Handler;
30import android.os.Message;
31import android.provider.ContactsContract;
32import android.provider.ContactsContract.Directory;
33import android.support.annotation.Nullable;
34import android.text.TextUtils;
35import android.text.util.Rfc822Token;
36import android.util.Log;
37import android.view.View;
38import android.view.ViewGroup;
39import android.widget.AutoCompleteTextView;
40import android.widget.BaseAdapter;
41import android.widget.Filter;
42import android.widget.Filterable;
43
44import com.android.ex.chips.ChipsUtil.PermissionsCheckListener;
45import com.android.ex.chips.DropdownChipLayouter.AdapterType;
46
47import java.util.ArrayList;
48import java.util.Collections;
49import java.util.HashSet;
50import java.util.LinkedHashMap;
51import java.util.List;
52import java.util.Map;
53import 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 */
66public 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
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
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
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
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
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
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
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     */
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
398        public DirectoryFilter(DirectorySearchParams params) {
399            mParams = params;
400        }
401
402        public synchronized void setLimit(int limit) {
403            this.mLimit = limit;
404        }
405
406        public synchronized int getLimit() {
407            return this.mLimit;
408        }
409
410        @Override
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
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
556        public void handleMessage(Message msg) {
557            if (mRemainingDirectoryCount > 0) {
558                updateEntries(constructEntryList());
559            }
560        }
561
562        public void sendDelayedLoadMessage() {
563            sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null),
564                    MESSAGE_SEARCH_PENDING_DELAY);
565        }
566
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     */
579    public BaseRecipientAdapter(Context context) {
580        this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL);
581    }
582
583    public BaseRecipientAdapter(Context context, int preferredMaxResultCount) {
584        this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL);
585    }
586
587    public BaseRecipientAdapter(int queryMode, Context context) {
588        this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode);
589    }
590
591    public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) {
592        this(context, preferredMaxResultCount, queryMode);
593    }
594
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
612    public Context getContext() {
613        return mContext;
614    }
615
616    public int getQueryType() {
617        return mQueryType;
618    }
619
620    public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
621        mDropdownChipLayouter = dropdownChipLayouter;
622        mDropdownChipLayouter.setQuery(mQueryMode);
623    }
624
625    public DropdownChipLayouter getDropdownChipLayouter() {
626        return mDropdownChipLayouter;
627    }
628
629    public void setPermissionsCheckListener(PermissionsCheckListener permissionsCheckListener) {
630        mPermissionsCheckListener = permissionsCheckListener;
631    }
632
633    @Nullable
634    public PermissionsCheckListener getPermissionsCheckListener() {
635        return mPermissionsCheckListener;
636    }
637
638    /**
639     * Enables overriding the default photo manager that is used.
640     */
641    public void setPhotoManager(PhotoManager photoManager) {
642        mPhotoManager = photoManager;
643    }
644
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     */
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     */
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
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     */
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     */
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
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     */
706    public Map<String, RecipientEntry> getMatchingRecipients(Set<String> addresses) {
707        return null;
708    }
709
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     */
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     */
798    protected void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry) {
799        putOneEntry(entry, isAggregatedEntry,
800                mEntryMap, mNonAggregatedEntries, mExistingDestinations);
801    }
802
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     */
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     */
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 {
889        public void onChanged(List<RecipientEntry> entries);
890    }
891
892    public void registerUpdateObserver(EntriesUpdatedObserver observer) {
893        mEntriesUpdatedObserver = observer;
894    }
895
896    /** Resets {@link #mEntries} and notify the event to its parent ListView. */
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     */
910    protected void cacheCurrentEntriesIfNeeded(int newEntryCount, int paramListCount) {
911        if (newEntryCount == 0 && paramListCount > 1) {
912            cacheCurrentEntries();
913        }
914    }
915
916    protected void cacheCurrentEntries() {
917        mTempEntries = mEntries;
918    }
919
920    protected void clearTempEntries() {
921        mTempEntries = null;
922    }
923
924    protected List<RecipientEntry> getEntries() {
925        return mTempEntries != null ? mTempEntries : mEntries;
926    }
927
928    protected void fetchPhoto(final RecipientEntry entry, PhotoManager.PhotoManagerCallback cb) {
929        mPhotoManager.populatePhotoBytesAsync(entry, cb);
930    }
931
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
975    public int getCount() {
976        final List<RecipientEntry> entries = getEntries();
977        return entries != null ? entries.size() : 0;
978    }
979
980    @Override
981    public RecipientEntry getItem(int position) {
982        return getEntries().get(position);
983    }
984
985    @Override
986    public long getItemId(int position) {
987        return position;
988    }
989
990    @Override
991    public int getViewTypeCount() {
992        return RecipientEntry.ENTRY_TYPE_SIZE;
993    }
994
995    @Override
996    public int getItemViewType(int position) {
997        return getEntries().get(position).getEntryType();
998    }
999
1000    @Override
1001    public boolean isEnabled(int position) {
1002        return getEntries().get(position).isSelectable();
1003    }
1004
1005    @Override
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
1016    public Account getAccount() {
1017        return mAccount;
1018    }
1019
1020    @Override
1021    public void onPhotoBytesPopulated() {
1022        // Default implementation does nothing
1023    }
1024
1025    @Override
1026    public void onPhotoBytesAsynchronouslyPopulated() {
1027        notifyDataSetChanged();
1028    }
1029
1030    @Override
1031    public void onPhotoBytesAsyncLoadFailed() {
1032        // Default implementation does nothing
1033    }
1034}
1035