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