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