Home | History | Annotate | Download | only in contacts
      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 
     17 package com.android.common.contacts;
     18 
     19 import com.android.common.widget.CompositeCursorAdapter;
     20 
     21 import android.accounts.Account;
     22 import android.content.ContentResolver;
     23 import android.content.Context;
     24 import android.content.pm.PackageManager;
     25 import android.content.pm.PackageManager.NameNotFoundException;
     26 import android.content.res.Resources;
     27 import android.database.Cursor;
     28 import android.database.MatrixCursor;
     29 import android.net.Uri;
     30 import android.os.Handler;
     31 import android.os.Message;
     32 import android.provider.ContactsContract;
     33 import android.provider.ContactsContract.CommonDataKinds.Email;
     34 import android.provider.ContactsContract.Contacts;
     35 import android.text.TextUtils;
     36 import android.text.util.Rfc822Token;
     37 import android.util.Log;
     38 import android.view.View;
     39 import android.view.ViewGroup;
     40 import android.widget.Filter;
     41 import android.widget.Filterable;
     42 
     43 import java.util.ArrayList;
     44 import 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  */
     51 public 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