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