/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.common.contacts; import com.android.common.widget.CompositeCursorAdapter; import android.accounts.Account; import android.content.ContentResolver; import android.content.Context; import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Resources; import android.database.Cursor; import android.database.MatrixCursor; import android.net.Uri; import android.os.Handler; import android.os.Message; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Email; import android.provider.ContactsContract.Contacts; import android.text.TextUtils; import android.text.util.Rfc822Token; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.Filter; import android.widget.Filterable; import java.util.ArrayList; import java.util.List; /** * A base class for email address autocomplete adapters. It uses * {@link Email#CONTENT_FILTER_URI} to search for data rows by email address * and/or contact name. It also searches registered {@link Directory}'s. */ public abstract class BaseEmailAddressAdapter extends CompositeCursorAdapter implements Filterable { private static final String TAG = "BaseEmailAddressAdapter"; // TODO: revert to references to the Directory class as soon as the // issue with the dependency on SDK 8 is resolved // This is Directory.LOCAL_INVISIBLE private static final long DIRECTORY_LOCAL_INVISIBLE = 1; // This is ContactsContract.DIRECTORY_PARAM_KEY private static final String DIRECTORY_PARAM_KEY = "directory"; // This is ContactsContract.LIMIT_PARAM_KEY private static final String LIMIT_PARAM_KEY = "limit"; // This is ContactsContract.PRIMARY_ACCOUNT_NAME private static final String PRIMARY_ACCOUNT_NAME = "name_for_primary_account"; // This is ContactsContract.PRIMARY_ACCOUNT_TYPE private static final String PRIMARY_ACCOUNT_TYPE = "type_for_primary_account"; /** * The preferred number of results to be retrieved. This number may be * exceeded if there are several directories configured, because we will use * the same limit for all directories. */ private static final int DEFAULT_PREFERRED_MAX_RESULT_COUNT = 10; /** * The number of extra entries requested to allow for duplicates. Duplicates * are removed from the overall result. */ private static final int ALLOWANCE_FOR_DUPLICATES = 5; /** * The "Searching..." message will be displayed if search is not complete * within this many milliseconds. */ private static final int MESSAGE_SEARCH_PENDING_DELAY = 1000; private static final int MESSAGE_SEARCH_PENDING = 1; /** * Model object for a {@link Directory} row. There is a partition in the * {@link CompositeCursorAdapter} for every directory (except * {@link Directory#LOCAL_INVISIBLE}. */ public final static class DirectoryPartition extends CompositeCursorAdapter.Partition { public long directoryId; public String directoryType; public String displayName; public String accountName; public String accountType; public boolean loading; public CharSequence constraint; public DirectoryPartitionFilter filter; public DirectoryPartition() { super(false, false); } } private static class EmailQuery { public static final String[] PROJECTION = { Contacts.DISPLAY_NAME, // 0 Email.DATA // 1 }; public static final int NAME = 0; public static final int ADDRESS = 1; } private static class DirectoryListQuery { // TODO: revert to references to the Directory class as soon as the // issue with the dependency on SDK 8 is resolved public static final Uri URI = Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories"); private static final String DIRECTORY_ID = "_id"; private static final String DIRECTORY_ACCOUNT_NAME = "accountName"; private static final String DIRECTORY_ACCOUNT_TYPE = "accountType"; private static final String DIRECTORY_DISPLAY_NAME = "displayName"; private static final String DIRECTORY_PACKAGE_NAME = "packageName"; private static final String DIRECTORY_TYPE_RESOURCE_ID = "typeResourceId"; public static final String[] PROJECTION = { DIRECTORY_ID, // 0 DIRECTORY_ACCOUNT_NAME, // 1 DIRECTORY_ACCOUNT_TYPE, // 2 DIRECTORY_DISPLAY_NAME, // 3 DIRECTORY_PACKAGE_NAME, // 4 DIRECTORY_TYPE_RESOURCE_ID, // 5 }; public static final int ID = 0; public static final int ACCOUNT_NAME = 1; public static final int ACCOUNT_TYPE = 2; public static final int DISPLAY_NAME = 3; public static final int PACKAGE_NAME = 4; public static final int TYPE_RESOURCE_ID = 5; } /** * A fake column name that indicates a "Searching..." item in the list. */ private static final String SEARCHING_CURSOR_MARKER = "searching"; /** * An asynchronous filter used for loading two data sets: email rows from the local * contact provider and the list of {@link Directory}'s. */ private final class DefaultPartitionFilter extends Filter { @Override protected FilterResults performFiltering(CharSequence constraint) { Cursor directoryCursor = null; if (!mDirectoriesLoaded) { directoryCursor = mContentResolver.query( DirectoryListQuery.URI, DirectoryListQuery.PROJECTION, null, null, null); mDirectoriesLoaded = true; } FilterResults results = new FilterResults(); Cursor cursor = null; if (!TextUtils.isEmpty(constraint)) { Uri.Builder builder = Email.CONTENT_FILTER_URI.buildUpon() .appendPath(constraint.toString()) .appendQueryParameter(LIMIT_PARAM_KEY, String.valueOf(mPreferredMaxResultCount)); if (mAccount != null) { builder.appendQueryParameter(PRIMARY_ACCOUNT_NAME, mAccount.name); builder.appendQueryParameter(PRIMARY_ACCOUNT_TYPE, mAccount.type); } Uri uri = builder.build(); cursor = mContentResolver.query(uri, EmailQuery.PROJECTION, null, null, null); results.count = cursor.getCount(); } results.values = new Cursor[] { directoryCursor, cursor }; return results; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { if (results.values != null) { Cursor[] cursors = (Cursor[]) results.values; onDirectoryLoadFinished(constraint, cursors[0], cursors[1]); } results.count = getCount(); } @Override public CharSequence convertResultToString(Object resultValue) { return makeDisplayString((Cursor) resultValue); } } /** * An asynchronous filter that performs search in a particular directory. */ private final class DirectoryPartitionFilter extends Filter { private final int mPartitionIndex; private final long mDirectoryId; private int mLimit; public DirectoryPartitionFilter(int partitionIndex, long directoryId) { this.mPartitionIndex = partitionIndex; this.mDirectoryId = directoryId; } public synchronized void setLimit(int limit) { this.mLimit = limit; } public synchronized int getLimit() { return this.mLimit; } @Override protected FilterResults performFiltering(CharSequence constraint) { FilterResults results = new FilterResults(); if (!TextUtils.isEmpty(constraint)) { Uri uri = Email.CONTENT_FILTER_URI.buildUpon() .appendPath(constraint.toString()) .appendQueryParameter(DIRECTORY_PARAM_KEY, String.valueOf(mDirectoryId)) .appendQueryParameter(LIMIT_PARAM_KEY, String.valueOf(getLimit() + ALLOWANCE_FOR_DUPLICATES)) .build(); Cursor cursor = mContentResolver.query( uri, EmailQuery.PROJECTION, null, null, null); results.values = cursor; } return results; } @Override protected void publishResults(CharSequence constraint, FilterResults results) { Cursor cursor = (Cursor) results.values; onPartitionLoadFinished(constraint, mPartitionIndex, cursor); results.count = getCount(); } } protected final ContentResolver mContentResolver; private boolean mDirectoriesLoaded; private Account mAccount; private int mPreferredMaxResultCount; private Handler mHandler; public BaseEmailAddressAdapter(Context context) { this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT); } public BaseEmailAddressAdapter(Context context, int preferredMaxResultCount) { super(context); mContentResolver = context.getContentResolver(); mPreferredMaxResultCount = preferredMaxResultCount; mHandler = new Handler() { @Override public void handleMessage(Message msg) { showSearchPendingIfNotComplete(msg.arg1); } }; } /** * Set the account when known. Causes the search to prioritize contacts from * that account. */ public void setAccount(Account account) { mAccount = account; } /** * Override to create a view for line item in the autocomplete suggestion list UI. */ protected abstract View inflateItemView(ViewGroup parent); /** * Override to populate the autocomplete suggestion line item UI with data. */ protected abstract void bindView(View view, String directoryType, String directoryName, String displayName, String emailAddress); /** * Override to create a view for a "Searching directory" line item, which is * displayed temporarily while the corresponding filter is running. */ protected abstract View inflateItemViewLoading(ViewGroup parent); /** * Override to populate the "Searching directory" line item UI with data. */ protected abstract void bindViewLoading(View view, String directoryType, String directoryName); @Override protected int getItemViewType(int partitionIndex, int position) { DirectoryPartition partition = (DirectoryPartition)getPartition(partitionIndex); return partition.loading ? 1 : 0; } @Override protected View newView(Context context, int partitionIndex, Cursor cursor, int position, ViewGroup parent) { DirectoryPartition partition = (DirectoryPartition)getPartition(partitionIndex); if (partition.loading) { return inflateItemViewLoading(parent); } else { return inflateItemView(parent); } } @Override protected void bindView(View v, int partition, Cursor cursor, int position) { DirectoryPartition directoryPartition = (DirectoryPartition)getPartition(partition); String directoryType = directoryPartition.directoryType; String directoryName = directoryPartition.displayName; if (directoryPartition.loading) { bindViewLoading(v, directoryType, directoryName); } else { String displayName = cursor.getString(EmailQuery.NAME); String emailAddress = cursor.getString(EmailQuery.ADDRESS); if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) { displayName = emailAddress; emailAddress = null; } bindView(v, directoryType, directoryName, displayName, emailAddress); } } @Override public boolean areAllItemsEnabled() { return false; } @Override protected boolean isEnabled(int partitionIndex, int position) { // The "Searching..." item should not be selectable return !isLoading(partitionIndex); } private boolean isLoading(int partitionIndex) { return ((DirectoryPartition)getPartition(partitionIndex)).loading; } @Override public Filter getFilter() { return new DefaultPartitionFilter(); } /** * Handles the result of the initial call, which brings back the list of * directories as well as the search results for the local directories. */ protected void onDirectoryLoadFinished( CharSequence constraint, Cursor directoryCursor, Cursor defaultPartitionCursor) { if (directoryCursor != null) { PackageManager packageManager = getContext().getPackageManager(); DirectoryPartition preferredDirectory = null; List directories = new ArrayList(); while (directoryCursor.moveToNext()) { long id = directoryCursor.getLong(DirectoryListQuery.ID); // Skip the local invisible directory, because the default directory // already includes all local results. if (id == DIRECTORY_LOCAL_INVISIBLE) { continue; } DirectoryPartition partition = new DirectoryPartition(); partition.directoryId = id; partition.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME); partition.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME); partition.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE); String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME); int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID); if (packageName != null && resourceId != 0) { try { Resources resources = packageManager.getResourcesForApplication(packageName); partition.directoryType = resources.getString(resourceId); if (partition.directoryType == null) { Log.e(TAG, "Cannot resolve directory name: " + resourceId + "@" + packageName); } } catch (NameNotFoundException e) { Log.e(TAG, "Cannot resolve directory name: " + resourceId + "@" + packageName, e); } } // If an account has been provided and we found a directory that // corresponds to that account, place that directory second, directly // underneath the local contacts. if (mAccount != null && mAccount.name.equals(partition.accountName) && mAccount.type.equals(partition.accountType)) { preferredDirectory = partition; } else { directories.add(partition); } } if (preferredDirectory != null) { directories.add(1, preferredDirectory); } for (DirectoryPartition partition : directories) { addPartition(partition); } } int count = getPartitionCount(); int limit = 0; // Since we will be changing several partitions at once, hold the data change // notifications setNotificationsEnabled(false); try { // The filter has loaded results for the default partition too. if (defaultPartitionCursor != null && getPartitionCount() > 0) { changeCursor(0, defaultPartitionCursor); } int defaultPartitionCount = (defaultPartitionCursor == null ? 0 : defaultPartitionCursor.getCount()); limit = mPreferredMaxResultCount - defaultPartitionCount; // Show non-default directories as "loading" // Note: skipping the default partition (index 0), which has already been loaded for (int i = 1; i < count; i++) { DirectoryPartition partition = (DirectoryPartition) getPartition(i); partition.constraint = constraint; if (limit > 0) { if (!partition.loading) { partition.loading = true; changeCursor(i, null); } } else { partition.loading = false; changeCursor(i, null); } } } finally { setNotificationsEnabled(true); } // Start search in other directories // Note: skipping the default partition (index 0), which has already been loaded for (int i = 1; i < count; i++) { DirectoryPartition partition = (DirectoryPartition) getPartition(i); if (partition.loading) { mHandler.removeMessages(MESSAGE_SEARCH_PENDING, partition); Message msg = mHandler.obtainMessage(MESSAGE_SEARCH_PENDING, i, 0, partition); mHandler.sendMessageDelayed(msg, MESSAGE_SEARCH_PENDING_DELAY); if (partition.filter == null) { partition.filter = new DirectoryPartitionFilter(i, partition.directoryId); } partition.filter.setLimit(limit); partition.filter.filter(constraint); } else { if (partition.filter != null) { // Cancel any previous loading request partition.filter.filter(null); } } } } void showSearchPendingIfNotComplete(int partitionIndex) { if (partitionIndex < getPartitionCount()) { DirectoryPartition partition = (DirectoryPartition) getPartition(partitionIndex); if (partition.loading) { changeCursor(partitionIndex, createLoadingCursor()); } } } /** * Creates a dummy cursor to represent the "Searching directory..." item. */ private Cursor createLoadingCursor() { MatrixCursor cursor = new MatrixCursor(new String[]{SEARCHING_CURSOR_MARKER}); cursor.addRow(new Object[]{""}); return cursor; } public void onPartitionLoadFinished( CharSequence constraint, int partitionIndex, Cursor cursor) { if (partitionIndex < getPartitionCount()) { DirectoryPartition partition = (DirectoryPartition) getPartition(partitionIndex); // Check if the received result matches the current constraint // If not - the user must have continued typing after the request // was issued if (partition.loading && TextUtils.equals(constraint, partition.constraint)) { partition.loading = false; mHandler.removeMessages(MESSAGE_SEARCH_PENDING, partition); changeCursor(partitionIndex, removeDuplicatesAndTruncate(partitionIndex, cursor)); } else { // We got the result for an unexpected query (the user is still typing) // Just ignore this result if (cursor != null) { cursor.close(); } } } else if (cursor != null) { cursor.close(); } } /** * Post-process the cursor to eliminate duplicates. Closes the original cursor * and returns a new one. */ private Cursor removeDuplicatesAndTruncate(int partition, Cursor cursor) { if (cursor == null) { return null; } if (cursor.getCount() <= DEFAULT_PREFERRED_MAX_RESULT_COUNT && !hasDuplicates(cursor, partition)) { return cursor; } int count = 0; MatrixCursor newCursor = new MatrixCursor(EmailQuery.PROJECTION); cursor.moveToPosition(-1); while (cursor.moveToNext() && count < DEFAULT_PREFERRED_MAX_RESULT_COUNT) { String displayName = cursor.getString(EmailQuery.NAME); String emailAddress = cursor.getString(EmailQuery.ADDRESS); if (!isDuplicate(emailAddress, partition)) { newCursor.addRow(new Object[]{displayName, emailAddress}); count++; } } cursor.close(); return newCursor; } private boolean hasDuplicates(Cursor cursor, int partition) { cursor.moveToPosition(-1); while (cursor.moveToNext()) { String emailAddress = cursor.getString(EmailQuery.ADDRESS); if (isDuplicate(emailAddress, partition)) { return true; } } return false; } /** * Checks if the supplied email address is already present in partitions other * than the supplied one. */ private boolean isDuplicate(String emailAddress, int excludePartition) { int partitionCount = getPartitionCount(); for (int partition = 0; partition < partitionCount; partition++) { if (partition != excludePartition && !isLoading(partition)) { Cursor cursor = getCursor(partition); if (cursor != null) { cursor.moveToPosition(-1); while (cursor.moveToNext()) { String address = cursor.getString(EmailQuery.ADDRESS); if (TextUtils.equals(emailAddress, address)) { return true; } } } } } return false; } private final String makeDisplayString(Cursor cursor) { if (cursor.getColumnName(0).equals(SEARCHING_CURSOR_MARKER)) { return ""; } String displayName = cursor.getString(EmailQuery.NAME); String emailAddress = cursor.getString(EmailQuery.ADDRESS); if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) { return emailAddress; } else { return new Rfc822Token(displayName, emailAddress, null).toString(); } } }