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