BaseRecipientAdapter.java revision fa7b0fb73f80ceafdf3ff3260b345b61d2766f93
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.Collections;
55import java.util.HashSet;
56import java.util.LinkedHashMap;
57import java.util.List;
58import java.util.Map;
59import java.util.Set;
60
61/**
62 * Adapter for showing a recipient list.
63 */
64public class BaseRecipientAdapter extends BaseAdapter implements Filterable, 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            } else {
339                updateEntries(Collections.<RecipientEntry>emptyList());
340            }
341        }
342
343        @Override
344        public CharSequence convertResultToString(Object resultValue) {
345            final RecipientEntry entry = (RecipientEntry)resultValue;
346            final String displayName = entry.getDisplayName();
347            final String emailAddress = entry.getDestination();
348            if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
349                 return emailAddress;
350            } else {
351                return new Rfc822Token(displayName, emailAddress, null).toString();
352            }
353        }
354    }
355
356    /**
357     * An asynchronous filter that performs search in a particular directory.
358     */
359    protected class DirectoryFilter extends Filter {
360        private final DirectorySearchParams mParams;
361        private int mLimit;
362
363        public DirectoryFilter(DirectorySearchParams params) {
364            mParams = params;
365        }
366
367        public synchronized void setLimit(int limit) {
368            this.mLimit = limit;
369        }
370
371        public synchronized int getLimit() {
372            return this.mLimit;
373        }
374
375        @Override
376        protected FilterResults performFiltering(CharSequence constraint) {
377            if (DEBUG) {
378                Log.d(TAG, "DirectoryFilter#performFiltering. directoryId: " + mParams.directoryId
379                        + ", constraint: " + constraint + ", thread: " + Thread.currentThread());
380            }
381            final FilterResults results = new FilterResults();
382            results.values = null;
383            results.count = 0;
384
385            if (!TextUtils.isEmpty(constraint)) {
386                final ArrayList<TemporaryEntry> tempEntries = new ArrayList<TemporaryEntry>();
387
388                Cursor cursor = null;
389                try {
390                    // We don't want to pass this Cursor object to UI thread (b/5017608).
391                    // Assuming the result should contain fairly small results (at most ~10),
392                    // We just copy everything to local structure.
393                    cursor = doQuery(constraint, getLimit(), mParams.directoryId);
394
395                    if (cursor != null) {
396                        while (cursor.moveToNext()) {
397                            tempEntries.add(new TemporaryEntry(cursor, mParams.directoryId));
398                        }
399                    }
400                } finally {
401                    if (cursor != null) {
402                        cursor.close();
403                    }
404                }
405                if (!tempEntries.isEmpty()) {
406                    results.values = tempEntries;
407                    results.count = 1;
408                }
409            }
410
411            if (DEBUG) {
412                Log.v(TAG, "finished loading directory \"" + mParams.displayName + "\"" +
413                        " with query " + constraint);
414            }
415
416            return results;
417        }
418
419        @Override
420        protected void publishResults(final CharSequence constraint, FilterResults results) {
421            if (DEBUG) {
422                Log.d(TAG, "DirectoryFilter#publishResult. constraint: " + constraint
423                        + ", mCurrentConstraint: " + mCurrentConstraint);
424            }
425            mDelayedMessageHandler.removeDelayedLoadMessage();
426            // Check if the received result matches the current constraint
427            // If not - the user must have continued typing after the request was issued, which
428            // means several member variables (like mRemainingDirectoryLoad) are already
429            // overwritten so shouldn't be touched here anymore.
430            if (TextUtils.equals(constraint, mCurrentConstraint)) {
431                if (results.count > 0) {
432                    @SuppressWarnings("unchecked")
433                    final ArrayList<TemporaryEntry> tempEntries =
434                            (ArrayList<TemporaryEntry>) results.values;
435
436                    for (TemporaryEntry tempEntry : tempEntries) {
437                        putOneEntry(tempEntry, mParams.directoryId == Directory.DEFAULT,
438                                mEntryMap, mNonAggregatedEntries, mExistingDestinations);
439                    }
440                }
441
442                // If there are remaining directories, set up delayed message again.
443                mRemainingDirectoryCount--;
444                if (mRemainingDirectoryCount > 0) {
445                    if (DEBUG) {
446                        Log.d(TAG, "Resend delayed load message. Current mRemainingDirectoryLoad: "
447                                + mRemainingDirectoryCount);
448                    }
449                    mDelayedMessageHandler.sendDelayedLoadMessage();
450                }
451
452                // If this directory result has some items, or there are no more directories that
453                // we are waiting for, clear the temp results
454                if (results.count > 0 || mRemainingDirectoryCount == 0) {
455                    // Clear the temp entries
456                    clearTempEntries();
457                }
458            }
459
460            // Show the list again without "waiting" message.
461            updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries));
462        }
463    }
464
465    private final Context mContext;
466    private final ContentResolver mContentResolver;
467    private final LayoutInflater mInflater;
468    private Account mAccount;
469    private final int mPreferredMaxResultCount;
470    private DropdownChipLayouter mDropdownChipLayouter;
471
472    /**
473     * {@link #mEntries} is responsible for showing every result for this Adapter. To
474     * construct it, we use {@link #mEntryMap}, {@link #mNonAggregatedEntries}, and
475     * {@link #mExistingDestinations}.
476     *
477     * First, each destination (an email address or a phone number) with a valid contactId is
478     * inserted into {@link #mEntryMap} and grouped by the contactId. Destinations without valid
479     * contactId (possible if they aren't in local storage) are stored in
480     * {@link #mNonAggregatedEntries}.
481     * Duplicates are removed using {@link #mExistingDestinations}.
482     *
483     * After having all results from Cursor objects, all destinations in mEntryMap are copied to
484     * {@link #mEntries}. If the number of destinations is not enough (i.e. less than
485     * {@link #mPreferredMaxResultCount}), destinations in mNonAggregatedEntries are also used.
486     *
487     * These variables are only used in UI thread, thus should not be touched in
488     * performFiltering() methods.
489     */
490    private LinkedHashMap<Long, List<RecipientEntry>> mEntryMap;
491    private List<RecipientEntry> mNonAggregatedEntries;
492    private Set<String> mExistingDestinations;
493    /** Note: use {@link #updateEntries(List)} to update this variable. */
494    private List<RecipientEntry> mEntries;
495    private List<RecipientEntry> mTempEntries;
496
497    /** The number of directories this adapter is waiting for results. */
498    private int mRemainingDirectoryCount;
499
500    /**
501     * Used to ignore asynchronous queries with a different constraint, which may happen when
502     * users type characters quickly.
503     */
504    private CharSequence mCurrentConstraint;
505
506    private final LruCache<Uri, byte[]> mPhotoCacheMap;
507
508    /**
509     * Handler specific for maintaining "Waiting for more contacts" message, which will be shown
510     * when:
511     * - there are directories to be searched
512     * - results from directories are slow to come
513     */
514    private final class DelayedMessageHandler extends Handler {
515        @Override
516        public void handleMessage(Message msg) {
517            if (mRemainingDirectoryCount > 0) {
518                updateEntries(constructEntryList(mEntryMap, mNonAggregatedEntries));
519            }
520        }
521
522        public void sendDelayedLoadMessage() {
523            sendMessageDelayed(obtainMessage(MESSAGE_SEARCH_PENDING, 0, 0, null),
524                    MESSAGE_SEARCH_PENDING_DELAY);
525        }
526
527        public void removeDelayedLoadMessage() {
528            removeMessages(MESSAGE_SEARCH_PENDING);
529        }
530    }
531
532    private final DelayedMessageHandler mDelayedMessageHandler = new DelayedMessageHandler();
533
534    private EntriesUpdatedObserver mEntriesUpdatedObserver;
535
536    /**
537     * Constructor for email queries.
538     */
539    public BaseRecipientAdapter(Context context) {
540        this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, QUERY_TYPE_EMAIL);
541    }
542
543    public BaseRecipientAdapter(Context context, int preferredMaxResultCount) {
544        this(context, preferredMaxResultCount, QUERY_TYPE_EMAIL);
545    }
546
547    public BaseRecipientAdapter(int queryMode, Context context) {
548        this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT, queryMode);
549    }
550
551    public BaseRecipientAdapter(int queryMode, Context context, int preferredMaxResultCount) {
552        this(context, preferredMaxResultCount, queryMode);
553    }
554
555    public BaseRecipientAdapter(Context context, int preferredMaxResultCount, int queryMode) {
556        mContext = context;
557        mContentResolver = context.getContentResolver();
558        mInflater = LayoutInflater.from(context);
559        mPreferredMaxResultCount = preferredMaxResultCount;
560        mPhotoCacheMap = new LruCache<Uri, byte[]>(PHOTO_CACHE_SIZE);
561        mQueryType = queryMode;
562
563        if (queryMode == QUERY_TYPE_EMAIL) {
564            mQuery = Queries.EMAIL;
565        } else if (queryMode == QUERY_TYPE_PHONE) {
566            mQuery = Queries.PHONE;
567        } else {
568            mQuery = Queries.EMAIL;
569            Log.e(TAG, "Unsupported query type: " + queryMode);
570        }
571    }
572
573    public Context getContext() {
574        return mContext;
575    }
576
577    public int getQueryType() {
578        return mQueryType;
579    }
580
581    public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
582        mDropdownChipLayouter = dropdownChipLayouter;
583        mDropdownChipLayouter.setQuery(mQuery);
584    }
585
586    public DropdownChipLayouter getDropdownChipLayouter() {
587        return mDropdownChipLayouter;
588    }
589
590    /**
591     * Set the account when known. Causes the search to prioritize contacts from that account.
592     */
593    @Override
594    public void setAccount(Account account) {
595        mAccount = account;
596    }
597
598    /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */
599    @Override
600    public Filter getFilter() {
601        return new DefaultFilter();
602    }
603
604    /**
605     * An extesion to {@link RecipientAlternatesAdapter#getMatchingRecipients} that allows
606     * additional sources of contacts to be considered as matching recipients.
607     * @param addresses A set of addresses to be matched
608     * @return A list of matches or null if none found
609     */
610    public Map<String, RecipientEntry> getMatchingRecipients(Set<String> addresses) {
611        return null;
612    }
613
614    public static List<DirectorySearchParams> setupOtherDirectories(Context context,
615            Cursor directoryCursor, Account account) {
616        final PackageManager packageManager = context.getPackageManager();
617        final List<DirectorySearchParams> paramsList = new ArrayList<DirectorySearchParams>();
618        DirectorySearchParams preferredDirectory = null;
619        while (directoryCursor.moveToNext()) {
620            final long id = directoryCursor.getLong(DirectoryListQuery.ID);
621
622            // Skip the local invisible directory, because the default directory already includes
623            // all local results.
624            if (id == Directory.LOCAL_INVISIBLE) {
625                continue;
626            }
627
628            final DirectorySearchParams params = new DirectorySearchParams();
629            final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME);
630            final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID);
631            params.directoryId = id;
632            params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME);
633            params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME);
634            params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE);
635            if (packageName != null && resourceId != 0) {
636                try {
637                    final Resources resources =
638                            packageManager.getResourcesForApplication(packageName);
639                    params.directoryType = resources.getString(resourceId);
640                    if (params.directoryType == null) {
641                        Log.e(TAG, "Cannot resolve directory name: "
642                                + resourceId + "@" + packageName);
643                    }
644                } catch (NameNotFoundException e) {
645                    Log.e(TAG, "Cannot resolve directory name: "
646                            + resourceId + "@" + packageName, e);
647                }
648            }
649
650            // If an account has been provided and we found a directory that
651            // corresponds to that account, place that directory second, directly
652            // underneath the local contacts.
653            if (account != null && account.name.equals(params.accountName) &&
654                    account.type.equals(params.accountType)) {
655                preferredDirectory = params;
656            } else {
657                paramsList.add(params);
658            }
659        }
660
661        if (preferredDirectory != null) {
662            paramsList.add(1, preferredDirectory);
663        }
664
665        return paramsList;
666    }
667
668    /**
669     * Starts search in other directories using {@link Filter}. Results will be handled in
670     * {@link DirectoryFilter}.
671     */
672    protected void startSearchOtherDirectories(
673            CharSequence constraint, List<DirectorySearchParams> paramsList, int limit) {
674        final int count = paramsList.size();
675        // Note: skipping the default partition (index 0), which has already been loaded
676        for (int i = 1; i < count; i++) {
677            final DirectorySearchParams params = paramsList.get(i);
678            params.constraint = constraint;
679            if (params.filter == null) {
680                params.filter = new DirectoryFilter(params);
681            }
682            params.filter.setLimit(limit);
683            params.filter.filter(constraint);
684        }
685
686        // Directory search started. We may show "waiting" message if directory results are slow
687        // enough.
688        mRemainingDirectoryCount = count - 1;
689        mDelayedMessageHandler.sendDelayedLoadMessage();
690    }
691
692    private static void putOneEntry(TemporaryEntry entry, boolean isAggregatedEntry,
693            LinkedHashMap<Long, List<RecipientEntry>> entryMap,
694            List<RecipientEntry> nonAggregatedEntries,
695            Set<String> existingDestinations) {
696        if (existingDestinations.contains(entry.destination)) {
697            return;
698        }
699
700        existingDestinations.add(entry.destination);
701
702        if (!isAggregatedEntry) {
703            nonAggregatedEntries.add(RecipientEntry.constructTopLevelEntry(
704                    entry.displayName,
705                    entry.displayNameSource,
706                    entry.destination, entry.destinationType, entry.destinationLabel,
707                    entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
708                    true, entry.lookupKey));
709        } else if (entryMap.containsKey(entry.contactId)) {
710            // We already have a section for the person.
711            final List<RecipientEntry> entryList = entryMap.get(entry.contactId);
712            entryList.add(RecipientEntry.constructSecondLevelEntry(
713                    entry.displayName,
714                    entry.displayNameSource,
715                    entry.destination, entry.destinationType, entry.destinationLabel,
716                    entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
717                    true, entry.lookupKey));
718        } else {
719            final List<RecipientEntry> entryList = new ArrayList<RecipientEntry>();
720            entryList.add(RecipientEntry.constructTopLevelEntry(
721                    entry.displayName,
722                    entry.displayNameSource,
723                    entry.destination, entry.destinationType, entry.destinationLabel,
724                    entry.contactId, entry.directoryId, entry.dataId, entry.thumbnailUriString,
725                    true, entry.lookupKey));
726            entryMap.put(entry.contactId, entryList);
727        }
728    }
729
730    /**
731     * Constructs an actual list for this Adapter using {@link #mEntryMap}. Also tries to
732     * fetch a cached photo for each contact entry (other than separators), or request another
733     * thread to get one from directories.
734     */
735    private List<RecipientEntry> constructEntryList(
736            LinkedHashMap<Long, List<RecipientEntry>> entryMap,
737            List<RecipientEntry> nonAggregatedEntries) {
738        final List<RecipientEntry> entries = new ArrayList<RecipientEntry>();
739        int validEntryCount = 0;
740        for (Map.Entry<Long, List<RecipientEntry>> mapEntry : entryMap.entrySet()) {
741            final List<RecipientEntry> entryList = mapEntry.getValue();
742            final int size = entryList.size();
743            for (int i = 0; i < size; i++) {
744                RecipientEntry entry = entryList.get(i);
745                entries.add(entry);
746                tryFetchPhoto(entry);
747                validEntryCount++;
748            }
749            if (validEntryCount > mPreferredMaxResultCount) {
750                break;
751            }
752        }
753        if (validEntryCount <= mPreferredMaxResultCount) {
754            for (RecipientEntry entry : nonAggregatedEntries) {
755                if (validEntryCount > mPreferredMaxResultCount) {
756                    break;
757                }
758                entries.add(entry);
759                tryFetchPhoto(entry);
760
761                validEntryCount++;
762            }
763        }
764
765        return entries;
766    }
767
768
769    public interface EntriesUpdatedObserver {
770        public void onChanged(List<RecipientEntry> entries);
771    }
772
773    public void registerUpdateObserver(EntriesUpdatedObserver observer) {
774        mEntriesUpdatedObserver = observer;
775    }
776
777    /** Resets {@link #mEntries} and notify the event to its parent ListView. */
778    private void updateEntries(List<RecipientEntry> newEntries) {
779        mEntries = newEntries;
780        mEntriesUpdatedObserver.onChanged(newEntries);
781        notifyDataSetChanged();
782    }
783
784    private void cacheCurrentEntries() {
785        mTempEntries = mEntries;
786    }
787
788    private void clearTempEntries() {
789        mTempEntries = null;
790    }
791
792    protected List<RecipientEntry> getEntries() {
793        return mTempEntries != null ? mTempEntries : mEntries;
794    }
795
796    private void tryFetchPhoto(final RecipientEntry entry) {
797        final Uri photoThumbnailUri = entry.getPhotoThumbnailUri();
798        if (photoThumbnailUri != null) {
799            final byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
800            if (photoBytes != null) {
801                entry.setPhotoBytes(photoBytes);
802                // notifyDataSetChanged() should be called by a caller.
803            } else {
804                if (DEBUG) {
805                    Log.d(TAG, "No photo cache for " + entry.getDisplayName()
806                            + ". Fetch one asynchronously");
807                }
808                fetchPhotoAsync(entry, photoThumbnailUri);
809            }
810        }
811    }
812
813    // For reading photos for directory contacts, this is the chunksize for
814    // copying from the inputstream to the output stream.
815    private static final int BUFFER_SIZE = 1024*16;
816
817    private void fetchPhotoAsync(final RecipientEntry entry, final Uri photoThumbnailUri) {
818        final AsyncTask<Void, Void, byte[]> photoLoadTask = new AsyncTask<Void, Void, byte[]>() {
819            @Override
820            protected byte[] doInBackground(Void... params) {
821                // First try running a query. Images for local contacts are
822                // loaded by sending a query to the ContactsProvider.
823                final Cursor photoCursor = mContentResolver.query(
824                        photoThumbnailUri, PhotoQuery.PROJECTION, null, null, null);
825                if (photoCursor != null) {
826                    try {
827                        if (photoCursor.moveToFirst()) {
828                            return photoCursor.getBlob(PhotoQuery.PHOTO);
829                        }
830                    } finally {
831                        photoCursor.close();
832                    }
833                } else {
834                    // If the query fails, try streaming the URI directly.
835                    // For remote directory images, this URI resolves to the
836                    // directory provider and the images are loaded by sending
837                    // an openFile call to the provider.
838                    try {
839                        InputStream is = mContentResolver.openInputStream(
840                                photoThumbnailUri);
841                        if (is != null) {
842                            byte[] buffer = new byte[BUFFER_SIZE];
843                            ByteArrayOutputStream baos = new ByteArrayOutputStream();
844                            try {
845                                int size;
846                                while ((size = is.read(buffer)) != -1) {
847                                    baos.write(buffer, 0, size);
848                                }
849                            } finally {
850                                is.close();
851                            }
852                            return baos.toByteArray();
853                        }
854                    } catch (IOException ex) {
855                        // ignore
856                    }
857                }
858                return null;
859            }
860
861            @Override
862            protected void onPostExecute(final byte[] photoBytes) {
863                entry.setPhotoBytes(photoBytes);
864                if (photoBytes != null) {
865                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
866                    notifyDataSetChanged();
867                }
868            }
869        };
870        photoLoadTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
871    }
872
873    protected void fetchPhoto(final RecipientEntry entry, final Uri photoThumbnailUri) {
874        byte[] photoBytes = mPhotoCacheMap.get(photoThumbnailUri);
875        if (photoBytes != null) {
876            entry.setPhotoBytes(photoBytes);
877            return;
878        }
879        final Cursor photoCursor = mContentResolver.query(photoThumbnailUri, PhotoQuery.PROJECTION,
880                null, null, null);
881        if (photoCursor != null) {
882            try {
883                if (photoCursor.moveToFirst()) {
884                    photoBytes = photoCursor.getBlob(PhotoQuery.PHOTO);
885                    entry.setPhotoBytes(photoBytes);
886                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
887                }
888            } finally {
889                photoCursor.close();
890            }
891        } else {
892            InputStream inputStream = null;
893            ByteArrayOutputStream outputStream = null;
894            try {
895                inputStream = mContentResolver.openInputStream(photoThumbnailUri);
896                final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
897
898                if (bitmap != null) {
899                    outputStream = new ByteArrayOutputStream();
900                    bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
901                    photoBytes = outputStream.toByteArray();
902
903                    entry.setPhotoBytes(photoBytes);
904                    mPhotoCacheMap.put(photoThumbnailUri, photoBytes);
905                }
906            } catch (final FileNotFoundException e) {
907                Log.w(TAG, "Error opening InputStream for photo", e);
908            } finally {
909                try {
910                    if (inputStream != null) {
911                        inputStream.close();
912                    }
913                } catch (IOException e) {
914                    Log.e(TAG, "Error closing photo input stream", e);
915                }
916                try {
917                    if (outputStream != null) {
918                        outputStream.close();
919                    }
920                } catch (IOException e) {
921                    Log.e(TAG, "Error closing photo output stream", e);
922                }
923            }
924        }
925    }
926
927    private Cursor doQuery(CharSequence constraint, int limit, Long directoryId) {
928        final Uri.Builder builder = mQuery.getContentFilterUri().buildUpon()
929                .appendPath(constraint.toString())
930                .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
931                        String.valueOf(limit + ALLOWANCE_FOR_DUPLICATES));
932        if (directoryId != null) {
933            builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
934                    String.valueOf(directoryId));
935        }
936        if (mAccount != null) {
937            builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name);
938            builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type);
939        }
940        final long start = System.currentTimeMillis();
941        final Cursor cursor = mContentResolver.query(
942                builder.build(), mQuery.getProjection(), null, null, null);
943        final long end = System.currentTimeMillis();
944        if (DEBUG) {
945            Log.d(TAG, "Time for autocomplete (query: " + constraint
946                    + ", directoryId: " + directoryId + ", num_of_results: "
947                    + (cursor != null ? cursor.getCount() : "null") + "): "
948                    + (end - start) + " ms");
949        }
950        return cursor;
951    }
952
953    // TODO: This won't be used at all. We should find better way to quit the thread..
954    /*public void close() {
955        mEntries = null;
956        mPhotoCacheMap.evictAll();
957        if (!sPhotoHandlerThread.quit()) {
958            Log.w(TAG, "Failed to quit photo handler thread, ignoring it.");
959        }
960    }*/
961
962    @Override
963    public int getCount() {
964        final List<RecipientEntry> entries = getEntries();
965        return entries != null ? entries.size() : 0;
966    }
967
968    @Override
969    public RecipientEntry getItem(int position) {
970        return getEntries().get(position);
971    }
972
973    @Override
974    public long getItemId(int position) {
975        return position;
976    }
977
978    @Override
979    public int getViewTypeCount() {
980        return RecipientEntry.ENTRY_TYPE_SIZE;
981    }
982
983    @Override
984    public int getItemViewType(int position) {
985        return getEntries().get(position).getEntryType();
986    }
987
988    @Override
989    public boolean isEnabled(int position) {
990        return getEntries().get(position).isSelectable();
991    }
992
993    @Override
994    public View getView(int position, View convertView, ViewGroup parent) {
995        final RecipientEntry entry = getEntries().get(position);
996
997        final String constraint = mCurrentConstraint == null ? null :
998                mCurrentConstraint.toString();
999
1000        return mDropdownChipLayouter.bindView(convertView, parent, entry, position,
1001                AdapterType.BASE_RECIPIENT, constraint);
1002    }
1003
1004    public Account getAccount() {
1005        return mAccount;
1006    }
1007}
1008