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