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