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