BaseEmailAddressAdapter.java revision 17d4817661d16464ffa0fd02cb6d1b362e96b8a1
1/*
2 * Copyright (C) 2010 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.common.contacts;
18
19import com.android.common.widget.CompositeCursorAdapter;
20
21import android.accounts.Account;
22import android.content.ContentResolver;
23import android.content.Context;
24import android.content.pm.PackageManager;
25import android.content.pm.PackageManager.NameNotFoundException;
26import android.content.res.Resources;
27import android.database.Cursor;
28import android.database.MatrixCursor;
29import android.net.Uri;
30import android.os.Handler;
31import android.os.Message;
32import android.provider.ContactsContract;
33import android.provider.ContactsContract.CommonDataKinds.Email;
34import android.provider.ContactsContract.Contacts;
35import android.text.TextUtils;
36import android.text.util.Rfc822Token;
37import android.util.Log;
38import android.view.View;
39import android.view.ViewGroup;
40import android.widget.Filter;
41import android.widget.Filterable;
42
43import java.util.ArrayList;
44import java.util.List;
45
46/**
47 * A base class for email address autocomplete adapters. It uses
48 * {@link Email#CONTENT_FILTER_URI} to search for data rows by email address
49 * and/or contact name. It also searches registered {@link Directory}'s.
50 */
51public abstract class BaseEmailAddressAdapter extends CompositeCursorAdapter implements Filterable {
52
53    private static final String TAG = "BaseEmailAddressAdapter";
54
55    // TODO: revert to references to the Directory class as soon as the
56    // issue with the dependency on SDK 8 is resolved
57
58    // This is Directory.LOCAL_INVISIBLE
59    private static final long DIRECTORY_LOCAL_INVISIBLE = 1;
60
61    // This is ContactsContract.DIRECTORY_PARAM_KEY
62    private static final String DIRECTORY_PARAM_KEY = "directory";
63
64    // This is ContactsContract.LIMIT_PARAM_KEY
65    private static final String LIMIT_PARAM_KEY = "limit";
66
67    /**
68     * The preferred number of results to be retrieved. This number may be
69     * exceeded if there are several directories configured, because we will use
70     * the same limit for all directories.
71     */
72    private static final int DEFAULT_PREFERRED_MAX_RESULT_COUNT = 10;
73
74    /**
75     * The number of extra entries requested to allow for duplicates. Duplicates
76     * are removed from the overall result.
77     */
78    private static final int ALLOWANCE_FOR_DUPLICATES = 5;
79
80    /**
81     * The "Searching..." message will be displayed if search is not complete
82     * within this many milliseconds.
83     */
84    private static final int MESSAGE_SEARCH_PENDING_DELAY = 1000;
85
86    private static final int MESSAGE_SEARCH_PENDING = 1;
87
88    /**
89     * Model object for a {@link Directory} row. There is a partition in the
90     * {@link CompositeCursorAdapter} for every directory (except
91     * {@link Directory#LOCAL_INVISIBLE}.
92     */
93    public final static class DirectoryPartition extends CompositeCursorAdapter.Partition {
94        public long directoryId;
95        public String directoryType;
96        public String displayName;
97        public String accountName;
98        public String accountType;
99        public boolean loading;
100        public CharSequence constraint;
101        public DirectoryPartitionFilter filter;
102
103        public DirectoryPartition() {
104            super(false, false);
105        }
106    }
107
108    private static class EmailQuery {
109        public static final String[] PROJECTION = {
110            Contacts.DISPLAY_NAME,  // 0
111            Email.DATA              // 1
112        };
113
114        public static final int NAME = 0;
115        public static final int ADDRESS = 1;
116    }
117
118    private static class DirectoryListQuery {
119
120        // TODO: revert to references to the Directory class as soon as the
121        // issue with the dependency on SDK 8 is resolved
122        public static final Uri URI =
123                Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories");
124        private static final String DIRECTORY_ID = "_id";
125        private static final String DIRECTORY_ACCOUNT_NAME = "accountName";
126        private static final String DIRECTORY_ACCOUNT_TYPE = "accountType";
127        private static final String DIRECTORY_DISPLAY_NAME = "displayName";
128        private static final String DIRECTORY_PACKAGE_NAME = "packageName";
129        private static final String DIRECTORY_TYPE_RESOURCE_ID = "typeResourceId";
130
131        public static final String[] PROJECTION = {
132            DIRECTORY_ID,               // 0
133            DIRECTORY_ACCOUNT_NAME,     // 1
134            DIRECTORY_ACCOUNT_TYPE,     // 2
135            DIRECTORY_DISPLAY_NAME,     // 3
136            DIRECTORY_PACKAGE_NAME,     // 4
137            DIRECTORY_TYPE_RESOURCE_ID, // 5
138        };
139
140        public static final int ID = 0;
141        public static final int ACCOUNT_NAME = 1;
142        public static final int ACCOUNT_TYPE = 2;
143        public static final int DISPLAY_NAME = 3;
144        public static final int PACKAGE_NAME = 4;
145        public static final int TYPE_RESOURCE_ID = 5;
146    }
147
148    /**
149     * A fake column name that indicates a "Searching..." item in the list.
150     */
151    private static final String SEARCHING_CURSOR_MARKER = "searching";
152
153    /**
154     * An asynchronous filter used for loading two data sets: email rows from the local
155     * contact provider and the list of {@link Directory}'s.
156     */
157    private final class DefaultPartitionFilter extends Filter {
158
159        @Override
160        protected FilterResults performFiltering(CharSequence constraint) {
161            Cursor directoryCursor = null;
162            if (!mDirectoriesLoaded) {
163                directoryCursor = mContentResolver.query(
164                        DirectoryListQuery.URI, DirectoryListQuery.PROJECTION, null, null, null);
165                mDirectoriesLoaded = true;
166            }
167
168            FilterResults results = new FilterResults();
169            Cursor cursor = null;
170            if (!TextUtils.isEmpty(constraint)) {
171                Uri uri = Email.CONTENT_FILTER_URI.buildUpon()
172                        .appendPath(constraint.toString())
173                        .appendQueryParameter(LIMIT_PARAM_KEY,
174                                String.valueOf(mPreferredMaxResultCount))
175                        .build();
176                cursor = mContentResolver.query(uri, EmailQuery.PROJECTION, null, null, null);
177                results.count = cursor.getCount();
178            }
179            results.values = new Cursor[] { directoryCursor, cursor };
180            return results;
181        }
182
183        @Override
184        protected void publishResults(CharSequence constraint, FilterResults results) {
185            if (results.values != null) {
186                Cursor[] cursors = (Cursor[]) results.values;
187                onDirectoryLoadFinished(constraint, cursors[0], cursors[1]);
188            }
189            results.count = getCount();
190        }
191
192        @Override
193        public CharSequence convertResultToString(Object resultValue) {
194            return makeDisplayString((Cursor) resultValue);
195        }
196    }
197
198    /**
199     * An asynchronous filter that performs search in a particular directory.
200     */
201    private final class DirectoryPartitionFilter extends Filter {
202        private final int mPartitionIndex;
203        private final long mDirectoryId;
204        private int mLimit;
205
206        public DirectoryPartitionFilter(int partitionIndex, long directoryId) {
207            this.mPartitionIndex = partitionIndex;
208            this.mDirectoryId = directoryId;
209        }
210
211        public synchronized void setLimit(int limit) {
212            this.mLimit = limit;
213        }
214
215        public synchronized int getLimit() {
216            return this.mLimit;
217        }
218
219        @Override
220        protected FilterResults performFiltering(CharSequence constraint) {
221            FilterResults results = new FilterResults();
222            if (!TextUtils.isEmpty(constraint)) {
223                Uri uri = Email.CONTENT_FILTER_URI.buildUpon()
224                        .appendPath(constraint.toString())
225                        .appendQueryParameter(DIRECTORY_PARAM_KEY, String.valueOf(mDirectoryId))
226                        .appendQueryParameter(LIMIT_PARAM_KEY,
227                                String.valueOf(getLimit() + ALLOWANCE_FOR_DUPLICATES))
228                        .build();
229                Cursor cursor = mContentResolver.query(
230                        uri, EmailQuery.PROJECTION, null, null, null);
231                results.values = cursor;
232            }
233            return results;
234        }
235
236        @Override
237        protected void publishResults(CharSequence constraint, FilterResults results) {
238            Cursor cursor = (Cursor) results.values;
239            onPartitionLoadFinished(constraint, mPartitionIndex, cursor);
240            results.count = getCount();
241        }
242    }
243
244    protected final ContentResolver mContentResolver;
245    private boolean mDirectoriesLoaded;
246    private Account mAccount;
247    private int mPreferredMaxResultCount;
248    private Handler mHandler;
249
250    public BaseEmailAddressAdapter(Context context) {
251        this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT);
252    }
253
254    public BaseEmailAddressAdapter(Context context, int preferredMaxResultCount) {
255        super(context);
256        mContentResolver = context.getContentResolver();
257        mPreferredMaxResultCount = preferredMaxResultCount;
258
259        mHandler = new Handler() {
260
261            @Override
262            public void handleMessage(Message msg) {
263                showSearchPendingIfNotComplete(msg.arg1);
264            }
265        };
266    }
267
268    /**
269     * Set the account when known. Causes the search to prioritize contacts from
270     * that account.
271     */
272    public void setAccount(Account account) {
273        mAccount = account;
274    }
275
276    /**
277     * Override to create a view for line item in the autocomplete suggestion list UI.
278     */
279    protected abstract View inflateItemView(ViewGroup parent);
280
281    /**
282     * Override to populate the autocomplete suggestion line item UI with data.
283     */
284    protected abstract void bindView(View view, String directoryType, String directoryName,
285            String displayName, String emailAddress);
286
287    /**
288     * Override to create a view for a "Searching directory" line item, which is
289     * displayed temporarily while the corresponding filter is running.
290     */
291    protected abstract View inflateItemViewLoading(ViewGroup parent);
292
293    /**
294     * Override to populate the "Searching directory" line item UI with data.
295     */
296    protected abstract void bindViewLoading(View view, String directoryType, String directoryName);
297
298    @Override
299    protected int getItemViewType(int partitionIndex, int position) {
300        DirectoryPartition partition = (DirectoryPartition)getPartition(partitionIndex);
301        return partition.loading ? 1 : 0;
302    }
303
304    @Override
305    protected View newView(Context context, int partitionIndex, Cursor cursor,
306            int position, ViewGroup parent) {
307        DirectoryPartition partition = (DirectoryPartition)getPartition(partitionIndex);
308        if (partition.loading) {
309            return inflateItemViewLoading(parent);
310        } else {
311            return inflateItemView(parent);
312        }
313    }
314
315    @Override
316    protected void bindView(View v, int partition, Cursor cursor, int position) {
317        DirectoryPartition directoryPartition = (DirectoryPartition)getPartition(partition);
318        String directoryType = directoryPartition.directoryType;
319        String directoryName = directoryPartition.displayName;
320        if (directoryPartition.loading) {
321            bindViewLoading(v, directoryType, directoryName);
322        } else {
323            String displayName = cursor.getString(EmailQuery.NAME);
324            String emailAddress = cursor.getString(EmailQuery.ADDRESS);
325            if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
326                displayName = emailAddress;
327                emailAddress = null;
328            }
329            bindView(v, directoryType, directoryName, displayName, emailAddress);
330        }
331    }
332
333    @Override
334    public boolean areAllItemsEnabled() {
335        return false;
336    }
337
338    @Override
339    protected boolean isEnabled(int partitionIndex, int position) {
340        // The "Searching..." item should not be selectable
341        return !isLoading(partitionIndex);
342    }
343
344    private boolean isLoading(int partitionIndex) {
345        return ((DirectoryPartition)getPartition(partitionIndex)).loading;
346    }
347
348    @Override
349    public Filter getFilter() {
350        return new DefaultPartitionFilter();
351    }
352
353    /**
354     * Handles the result of the initial call, which brings back the list of
355     * directories as well as the search results for the local directories.
356     */
357    protected void onDirectoryLoadFinished(
358            CharSequence constraint, Cursor directoryCursor, Cursor defaultPartitionCursor) {
359        if (directoryCursor != null) {
360            PackageManager packageManager = getContext().getPackageManager();
361            DirectoryPartition preferredDirectory = null;
362            List<DirectoryPartition> directories = new ArrayList<DirectoryPartition>();
363            while (directoryCursor.moveToNext()) {
364                long id = directoryCursor.getLong(DirectoryListQuery.ID);
365
366                // Skip the local invisible directory, because the default directory
367                // already includes all local results.
368                if (id == DIRECTORY_LOCAL_INVISIBLE) {
369                    continue;
370                }
371
372                DirectoryPartition partition = new DirectoryPartition();
373                partition.directoryId = id;
374                partition.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME);
375                partition.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME);
376                partition.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE);
377                String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME);
378                int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID);
379                if (packageName != null && resourceId != 0) {
380                    try {
381                        Resources resources =
382                                packageManager.getResourcesForApplication(packageName);
383                        partition.directoryType = resources.getString(resourceId);
384                        if (partition.directoryType == null) {
385                            Log.e(TAG, "Cannot resolve directory name: "
386                                    + resourceId + "@" + packageName);
387                        }
388                    } catch (NameNotFoundException e) {
389                        Log.e(TAG, "Cannot resolve directory name: "
390                                + resourceId + "@" + packageName, e);
391                    }
392                }
393
394                // If an account has been provided and we found a directory that
395                // corresponds to that account, place that directory second, directly
396                // underneath the local contacts.
397                if (mAccount != null && mAccount.name.equals(partition.accountName) &&
398                        mAccount.type.equals(partition.accountType)) {
399                    preferredDirectory = partition;
400                } else {
401                    directories.add(partition);
402                }
403            }
404
405            if (preferredDirectory != null) {
406                directories.add(1, preferredDirectory);
407            }
408
409            for (DirectoryPartition partition : directories) {
410                addPartition(partition);
411            }
412        }
413
414        int count = getPartitionCount();
415        int limit = 0;
416
417        // Since we will be changing several partitions at once, hold the data change
418        // notifications
419        setNotificationsEnabled(false);
420        try {
421            // The filter has loaded results for the default partition too.
422            if (defaultPartitionCursor != null && getPartitionCount() > 0) {
423                changeCursor(0, defaultPartitionCursor);
424            }
425
426            int defaultPartitionCount = (defaultPartitionCursor == null ? 0
427                    : defaultPartitionCursor.getCount());
428
429            limit = mPreferredMaxResultCount - defaultPartitionCount;
430
431            // Show non-default directories as "loading"
432            // Note: skipping the default partition (index 0), which has already been loaded
433            for (int i = 1; i < count; i++) {
434                DirectoryPartition partition = (DirectoryPartition) getPartition(i);
435                partition.constraint = constraint;
436
437                if (limit > 0) {
438                    if (!partition.loading) {
439                        partition.loading = true;
440                        changeCursor(i, null);
441                    }
442                } else {
443                    partition.loading = false;
444                    changeCursor(i, null);
445                }
446            }
447        } finally {
448            setNotificationsEnabled(true);
449        }
450
451        // Start search in other directories
452        // Note: skipping the default partition (index 0), which has already been loaded
453        for (int i = 1; i < count; i++) {
454            DirectoryPartition partition = (DirectoryPartition) getPartition(i);
455            if (partition.loading) {
456                mHandler.removeMessages(MESSAGE_SEARCH_PENDING, partition);
457                Message msg = mHandler.obtainMessage(MESSAGE_SEARCH_PENDING, i, 0, partition);
458                mHandler.sendMessageDelayed(msg, MESSAGE_SEARCH_PENDING_DELAY);
459                if (partition.filter == null) {
460                    partition.filter = new DirectoryPartitionFilter(i, partition.directoryId);
461                }
462                partition.filter.setLimit(limit);
463                partition.filter.filter(constraint);
464            } else {
465                if (partition.filter != null) {
466                    // Cancel any previous loading request
467                    partition.filter.filter(null);
468                }
469            }
470        }
471    }
472
473    void showSearchPendingIfNotComplete(int partitionIndex) {
474        if (partitionIndex < getPartitionCount()) {
475            DirectoryPartition partition = (DirectoryPartition) getPartition(partitionIndex);
476            if (partition.loading) {
477                changeCursor(partitionIndex, createLoadingCursor());
478            }
479        }
480    }
481
482    /**
483     * Creates a dummy cursor to represent the "Searching directory..." item.
484     */
485    private Cursor createLoadingCursor() {
486        MatrixCursor cursor = new MatrixCursor(new String[]{SEARCHING_CURSOR_MARKER});
487        cursor.addRow(new Object[]{""});
488        return cursor;
489    }
490
491    public void onPartitionLoadFinished(
492            CharSequence constraint, int partitionIndex, Cursor cursor) {
493        if (partitionIndex < getPartitionCount()) {
494            DirectoryPartition partition = (DirectoryPartition) getPartition(partitionIndex);
495
496            // Check if the received result matches the current constraint
497            // If not - the user must have continued typing after the request
498            // was issued
499            if (partition.loading && TextUtils.equals(constraint, partition.constraint)) {
500                partition.loading = false;
501                mHandler.removeMessages(MESSAGE_SEARCH_PENDING, partition);
502                changeCursor(partitionIndex, removeDuplicatesAndTruncate(partitionIndex, cursor));
503            } else {
504                // We got the result for an unexpected query (the user is still typing)
505                // Just ignore this result
506                if (cursor != null) {
507                    cursor.close();
508                }
509            }
510        } else if (cursor != null) {
511            cursor.close();
512        }
513    }
514
515    /**
516     * Post-process the cursor to eliminate duplicates.  Closes the original cursor
517     * and returns a new one.
518     */
519    private Cursor removeDuplicatesAndTruncate(int partition, Cursor cursor) {
520        if (cursor == null) {
521            return null;
522        }
523
524        if (cursor.getCount() <= DEFAULT_PREFERRED_MAX_RESULT_COUNT
525                && !hasDuplicates(cursor, partition)) {
526            return cursor;
527        }
528
529        int count = 0;
530        MatrixCursor newCursor = new MatrixCursor(EmailQuery.PROJECTION);
531        cursor.moveToPosition(-1);
532        while (cursor.moveToNext() && count < DEFAULT_PREFERRED_MAX_RESULT_COUNT) {
533            String displayName = cursor.getString(EmailQuery.NAME);
534            String emailAddress = cursor.getString(EmailQuery.ADDRESS);
535            if (!isDuplicate(emailAddress, partition)) {
536                newCursor.addRow(new Object[]{displayName, emailAddress});
537                count++;
538            }
539        }
540        cursor.close();
541
542        return newCursor;
543    }
544
545    private boolean hasDuplicates(Cursor cursor, int partition) {
546        cursor.moveToPosition(-1);
547        while (cursor.moveToNext()) {
548            String emailAddress = cursor.getString(EmailQuery.ADDRESS);
549            if (isDuplicate(emailAddress, partition)) {
550                return true;
551            }
552        }
553        return false;
554    }
555
556    /**
557     * Checks if the supplied email address is already present in partitions other
558     * than the supplied one.
559     */
560    private boolean isDuplicate(String emailAddress, int excludePartition) {
561        int partitionCount = getPartitionCount();
562        for (int partition = 0; partition < partitionCount; partition++) {
563            if (partition != excludePartition && !isLoading(partition)) {
564                Cursor cursor = getCursor(partition);
565                if (cursor != null) {
566                    cursor.moveToPosition(-1);
567                    while (cursor.moveToNext()) {
568                        String address = cursor.getString(EmailQuery.ADDRESS);
569                        if (TextUtils.equals(emailAddress, address)) {
570                            return true;
571                        }
572                    }
573                }
574            }
575        }
576
577        return false;
578    }
579
580    private final String makeDisplayString(Cursor cursor) {
581        if (cursor.getColumnName(0).equals(SEARCHING_CURSOR_MARKER)) {
582            return "";
583        }
584
585        String displayName = cursor.getString(EmailQuery.NAME);
586        String emailAddress = cursor.getString(EmailQuery.ADDRESS);
587        if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
588             return emailAddress;
589        } else {
590            return new Rfc822Token(displayName, emailAddress, null).toString();
591        }
592    }
593}
594