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