blob: bfe5bafcd4e21a6211d5bfb7baac0c1009881320 [file] [log] [blame]
/*
* 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<DirectoryPartition> directories = new ArrayList<DirectoryPartition>();
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();
}
}
}