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