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