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 package com.android.contacts.list; 17 18 import com.android.contacts.ContactPhotoManager; 19 import com.android.contacts.R; 20 import com.android.contacts.widget.IndexerListAdapter; 21 import com.android.contacts.widget.TextWithHighlightingFactory; 22 23 import android.content.Context; 24 import android.content.CursorLoader; 25 import android.database.Cursor; 26 import android.net.Uri; 27 import android.os.Bundle; 28 import android.provider.ContactsContract; 29 import android.provider.ContactsContract.ContactCounts; 30 import android.provider.ContactsContract.Contacts; 31 import android.provider.ContactsContract.Directory; 32 import android.text.TextUtils; 33 import android.util.Log; 34 import android.view.LayoutInflater; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.widget.QuickContactBadge; 38 import android.widget.SectionIndexer; 39 import android.widget.TextView; 40 41 import java.util.HashSet; 42 43 /** 44 * Common base class for various contact-related lists, e.g. contact list, phone number list 45 * etc. 46 */ 47 public abstract class ContactEntryListAdapter extends IndexerListAdapter { 48 49 private static final String TAG = "ContactEntryListAdapter"; 50 51 /** 52 * Indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should 53 * be included in the search. 54 */ 55 private static final boolean LOCAL_INVISIBLE_DIRECTORY_ENABLED = false; 56 57 /** 58 * The animation is used here to allocate animated name text views. 59 */ 60 private TextWithHighlightingFactory mTextWithHighlightingFactory; 61 private int mDisplayOrder; 62 private int mSortOrder; 63 private boolean mNameHighlightingEnabled; 64 65 private boolean mDisplayPhotos; 66 private boolean mQuickContactEnabled; 67 68 /** 69 * indicates if contact queries include profile 70 */ 71 private boolean mIncludeProfile; 72 73 /** 74 * indicates if query results includes a profile 75 */ 76 private boolean mProfileExists; 77 78 private ContactPhotoManager mPhotoLoader; 79 80 private String mQueryString; 81 private char[] mUpperCaseQueryString; 82 private boolean mSearchMode; 83 private int mDirectorySearchMode; 84 private int mDirectoryResultLimit = Integer.MAX_VALUE; 85 86 private boolean mLoading = true; 87 private boolean mEmptyListEnabled = true; 88 89 private boolean mSelectionVisible; 90 91 private ContactListFilter mFilter; 92 private String mContactsCount = ""; 93 private boolean mDarkTheme = false; 94 95 public ContactEntryListAdapter(Context context) { 96 super(context); 97 addPartitions(); 98 } 99 100 @Override 101 protected View createPinnedSectionHeaderView(Context context, ViewGroup parent) { 102 return new ContactListPinnedHeaderView(context, null); 103 } 104 105 @Override 106 protected void setPinnedSectionTitle(View pinnedHeaderView, String title) { 107 ((ContactListPinnedHeaderView)pinnedHeaderView).setSectionHeader(title); 108 } 109 110 @Override 111 protected void setPinnedHeaderContactsCount(View header) { 112 // Update the header with the contacts count only if a profile header exists 113 // otherwise, the contacts count are shown in the empty profile header view 114 if (mProfileExists) { 115 ((ContactListPinnedHeaderView)header).setCountView(mContactsCount); 116 } else { 117 clearPinnedHeaderContactsCount(header); 118 } 119 } 120 121 @Override 122 protected void clearPinnedHeaderContactsCount(View header) { 123 ((ContactListPinnedHeaderView)header).setCountView(null); 124 } 125 126 protected void addPartitions() { 127 addPartition(createDefaultDirectoryPartition()); 128 } 129 130 protected DirectoryPartition createDefaultDirectoryPartition() { 131 DirectoryPartition partition = new DirectoryPartition(true, true); 132 partition.setDirectoryId(Directory.DEFAULT); 133 partition.setDirectoryType(getContext().getString(R.string.contactsList)); 134 partition.setPriorityDirectory(true); 135 partition.setPhotoSupported(true); 136 return partition; 137 } 138 139 private int getPartitionByDirectoryId(long id) { 140 int count = getPartitionCount(); 141 for (int i = 0; i < count; i++) { 142 Partition partition = getPartition(i); 143 if (partition instanceof DirectoryPartition) { 144 if (((DirectoryPartition)partition).getDirectoryId() == id) { 145 return i; 146 } 147 } 148 } 149 return -1; 150 } 151 152 public abstract String getContactDisplayName(int position); 153 public abstract void configureLoader(CursorLoader loader, long directoryId); 154 155 /** 156 * Marks all partitions as "loading" 157 */ 158 public void onDataReload() { 159 boolean notify = false; 160 int count = getPartitionCount(); 161 for (int i = 0; i < count; i++) { 162 Partition partition = getPartition(i); 163 if (partition instanceof DirectoryPartition) { 164 DirectoryPartition directoryPartition = (DirectoryPartition)partition; 165 if (!directoryPartition.isLoading()) { 166 notify = true; 167 } 168 directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); 169 } 170 } 171 if (notify) { 172 notifyDataSetChanged(); 173 } 174 } 175 176 @Override 177 public void clearPartitions() { 178 int count = getPartitionCount(); 179 for (int i = 0; i < count; i++) { 180 Partition partition = getPartition(i); 181 if (partition instanceof DirectoryPartition) { 182 DirectoryPartition directoryPartition = (DirectoryPartition)partition; 183 directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); 184 } 185 } 186 super.clearPartitions(); 187 } 188 189 public boolean isSearchMode() { 190 return mSearchMode; 191 } 192 193 public void setSearchMode(boolean flag) { 194 mSearchMode = flag; 195 } 196 197 public String getQueryString() { 198 return mQueryString; 199 } 200 201 public void setQueryString(String queryString) { 202 mQueryString = queryString; 203 if (TextUtils.isEmpty(queryString)) { 204 mUpperCaseQueryString = null; 205 } else { 206 mUpperCaseQueryString = queryString.toUpperCase().toCharArray(); 207 } 208 } 209 210 public char[] getUpperCaseQueryString() { 211 return mUpperCaseQueryString; 212 } 213 214 public int getDirectorySearchMode() { 215 return mDirectorySearchMode; 216 } 217 218 public void setDirectorySearchMode(int mode) { 219 mDirectorySearchMode = mode; 220 } 221 222 public int getDirectoryResultLimit() { 223 return mDirectoryResultLimit; 224 } 225 226 public void setDirectoryResultLimit(int limit) { 227 this.mDirectoryResultLimit = limit; 228 } 229 230 public int getContactNameDisplayOrder() { 231 return mDisplayOrder; 232 } 233 234 public void setContactNameDisplayOrder(int displayOrder) { 235 mDisplayOrder = displayOrder; 236 } 237 238 public int getSortOrder() { 239 return mSortOrder; 240 } 241 242 public void setSortOrder(int sortOrder) { 243 mSortOrder = sortOrder; 244 } 245 246 public void setPhotoLoader(ContactPhotoManager photoLoader) { 247 mPhotoLoader = photoLoader; 248 } 249 250 protected ContactPhotoManager getPhotoLoader() { 251 return mPhotoLoader; 252 } 253 254 public boolean getDisplayPhotos() { 255 return mDisplayPhotos; 256 } 257 258 public void setDisplayPhotos(boolean displayPhotos) { 259 mDisplayPhotos = displayPhotos; 260 } 261 262 public boolean isEmptyListEnabled() { 263 return mEmptyListEnabled; 264 } 265 266 public void setEmptyListEnabled(boolean flag) { 267 mEmptyListEnabled = flag; 268 } 269 270 public boolean isSelectionVisible() { 271 return mSelectionVisible; 272 } 273 274 public void setSelectionVisible(boolean flag) { 275 this.mSelectionVisible = flag; 276 } 277 278 public boolean isQuickContactEnabled() { 279 return mQuickContactEnabled; 280 } 281 282 public void setQuickContactEnabled(boolean quickContactEnabled) { 283 mQuickContactEnabled = quickContactEnabled; 284 } 285 286 public boolean shouldIncludeProfile() { 287 return mIncludeProfile; 288 } 289 290 public void setIncludeProfile(boolean includeProfile) { 291 mIncludeProfile = includeProfile; 292 } 293 294 public void setProfileExists(boolean exists) { 295 mProfileExists = exists; 296 // Stick the "ME" header for the profile 297 if (exists) { 298 SectionIndexer indexer = getIndexer(); 299 if (indexer != null) { 300 ((ContactsSectionIndexer) indexer).setProfileHeader( 301 getContext().getString(R.string.user_profile_contacts_list_header)); 302 } 303 } 304 } 305 306 public boolean hasProfile() { 307 return mProfileExists; 308 } 309 310 public void setDarkTheme(boolean value) { 311 mDarkTheme = value; 312 } 313 314 public void configureDirectoryLoader(DirectoryListLoader loader) { 315 loader.setDirectorySearchMode(mDirectorySearchMode); 316 loader.setLocalInvisibleDirectoryEnabled(LOCAL_INVISIBLE_DIRECTORY_ENABLED); 317 } 318 319 /** 320 * Updates partitions according to the directory meta-data contained in the supplied 321 * cursor. 322 */ 323 public void changeDirectories(Cursor cursor) { 324 if (cursor.getCount() == 0) { 325 // Directory table must have at least local directory, without which this adapter will 326 // enter very weird state. 327 Log.e(TAG, "Directory search loader returned an empty cursor, which implies we have " + 328 "no directory entries.", new RuntimeException()); 329 return; 330 } 331 HashSet<Long> directoryIds = new HashSet<Long>(); 332 333 int idColumnIndex = cursor.getColumnIndex(Directory._ID); 334 int directoryTypeColumnIndex = cursor.getColumnIndex(DirectoryListLoader.DIRECTORY_TYPE); 335 int displayNameColumnIndex = cursor.getColumnIndex(Directory.DISPLAY_NAME); 336 int photoSupportColumnIndex = cursor.getColumnIndex(Directory.PHOTO_SUPPORT); 337 338 // TODO preserve the order of partition to match those of the cursor 339 // Phase I: add new directories 340 cursor.moveToPosition(-1); 341 while (cursor.moveToNext()) { 342 long id = cursor.getLong(idColumnIndex); 343 directoryIds.add(id); 344 if (getPartitionByDirectoryId(id) == -1) { 345 DirectoryPartition partition = new DirectoryPartition(false, true); 346 partition.setDirectoryId(id); 347 partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex)); 348 partition.setDisplayName(cursor.getString(displayNameColumnIndex)); 349 int photoSupport = cursor.getInt(photoSupportColumnIndex); 350 partition.setPhotoSupported(photoSupport == Directory.PHOTO_SUPPORT_THUMBNAIL_ONLY 351 || photoSupport == Directory.PHOTO_SUPPORT_FULL); 352 addPartition(partition); 353 } 354 } 355 356 // Phase II: remove deleted directories 357 int count = getPartitionCount(); 358 for (int i = count; --i >= 0; ) { 359 Partition partition = getPartition(i); 360 if (partition instanceof DirectoryPartition) { 361 long id = ((DirectoryPartition)partition).getDirectoryId(); 362 if (!directoryIds.contains(id)) { 363 removePartition(i); 364 } 365 } 366 } 367 368 invalidate(); 369 notifyDataSetChanged(); 370 } 371 372 @Override 373 public void changeCursor(int partitionIndex, Cursor cursor) { 374 if (partitionIndex >= getPartitionCount()) { 375 // There is no partition for this data 376 return; 377 } 378 379 Partition partition = getPartition(partitionIndex); 380 if (partition instanceof DirectoryPartition) { 381 ((DirectoryPartition)partition).setStatus(DirectoryPartition.STATUS_LOADED); 382 } 383 384 if (mDisplayPhotos && mPhotoLoader != null && isPhotoSupported(partitionIndex)) { 385 mPhotoLoader.refreshCache(); 386 } 387 388 super.changeCursor(partitionIndex, cursor); 389 390 if (isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) { 391 updateIndexer(cursor); 392 } 393 } 394 395 public void changeCursor(Cursor cursor) { 396 changeCursor(0, cursor); 397 } 398 399 /** 400 * Updates the indexer, which is used to produce section headers. 401 */ 402 private void updateIndexer(Cursor cursor) { 403 if (cursor == null) { 404 setIndexer(null); 405 return; 406 } 407 408 Bundle bundle = cursor.getExtras(); 409 if (bundle.containsKey(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES)) { 410 String sections[] = 411 bundle.getStringArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); 412 int counts[] = bundle.getIntArray(ContactCounts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); 413 setIndexer(new ContactsSectionIndexer(sections, counts)); 414 } else { 415 setIndexer(null); 416 } 417 } 418 419 @Override 420 public int getViewTypeCount() { 421 // We need a separate view type for each item type, plus another one for 422 // each type with header, plus one for "other". 423 return getItemViewTypeCount() * 2 + 1; 424 } 425 426 @Override 427 public int getItemViewType(int partitionIndex, int position) { 428 int type = super.getItemViewType(partitionIndex, position); 429 if (!isUserProfile(position) 430 && isSectionHeaderDisplayEnabled() 431 && partitionIndex == getIndexedPartition()) { 432 Placement placement = getItemPlacementInSection(position); 433 return placement.firstInSection ? type : getItemViewTypeCount() + type; 434 } else { 435 return type; 436 } 437 } 438 439 @Override 440 public boolean isEmpty() { 441 // TODO 442 // if (contactsListActivity.mProviderStatus != ProviderStatus.STATUS_NORMAL) { 443 // return true; 444 // } 445 446 if (!mEmptyListEnabled) { 447 return false; 448 } else if (isSearchMode()) { 449 return TextUtils.isEmpty(getQueryString()); 450 } else if (mLoading) { 451 // We don't want the empty state to show when loading. 452 return false; 453 } else { 454 return super.isEmpty(); 455 } 456 } 457 458 public boolean isLoading() { 459 int count = getPartitionCount(); 460 for (int i = 0; i < count; i++) { 461 Partition partition = getPartition(i); 462 if (partition instanceof DirectoryPartition 463 && ((DirectoryPartition) partition).isLoading()) { 464 return true; 465 } 466 } 467 return false; 468 } 469 470 public boolean areAllPartitionsEmpty() { 471 int count = getPartitionCount(); 472 for (int i = 0; i < count; i++) { 473 if (!isPartitionEmpty(i)) { 474 return false; 475 } 476 } 477 return true; 478 } 479 480 /** 481 * Changes visibility parameters for the default directory partition. 482 */ 483 public void configureDefaultPartition(boolean showIfEmpty, boolean hasHeader) { 484 int defaultPartitionIndex = -1; 485 int count = getPartitionCount(); 486 for (int i = 0; i < count; i++) { 487 Partition partition = getPartition(i); 488 if (partition instanceof DirectoryPartition && 489 ((DirectoryPartition)partition).getDirectoryId() == Directory.DEFAULT) { 490 defaultPartitionIndex = i; 491 break; 492 } 493 } 494 if (defaultPartitionIndex != -1) { 495 setShowIfEmpty(defaultPartitionIndex, showIfEmpty); 496 setHasHeader(defaultPartitionIndex, hasHeader); 497 } 498 } 499 500 @Override 501 protected View newHeaderView(Context context, int partition, Cursor cursor, 502 ViewGroup parent) { 503 LayoutInflater inflater = LayoutInflater.from(context); 504 return inflater.inflate(R.layout.directory_header, parent, false); 505 } 506 507 @Override 508 protected void bindHeaderView(View view, int partitionIndex, Cursor cursor) { 509 Partition partition = getPartition(partitionIndex); 510 if (!(partition instanceof DirectoryPartition)) { 511 return; 512 } 513 514 DirectoryPartition directoryPartition = (DirectoryPartition)partition; 515 long directoryId = directoryPartition.getDirectoryId(); 516 TextView labelTextView = (TextView)view.findViewById(R.id.label); 517 TextView displayNameTextView = (TextView)view.findViewById(R.id.display_name); 518 if (directoryId == Directory.DEFAULT || directoryId == Directory.LOCAL_INVISIBLE) { 519 labelTextView.setText(R.string.local_search_label); 520 displayNameTextView.setText(null); 521 } else { 522 labelTextView.setText(R.string.directory_search_label); 523 String directoryName = directoryPartition.getDisplayName(); 524 String displayName = !TextUtils.isEmpty(directoryName) 525 ? directoryName 526 : directoryPartition.getDirectoryType(); 527 displayNameTextView.setText(displayName); 528 } 529 530 TextView countText = (TextView)view.findViewById(R.id.count); 531 if (directoryPartition.isLoading()) { 532 countText.setText(R.string.search_results_searching); 533 } else { 534 int count = cursor == null ? 0 : cursor.getCount(); 535 if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE 536 && count >= getDirectoryResultLimit()) { 537 countText.setText(mContext.getString( 538 R.string.foundTooManyContacts, getDirectoryResultLimit())); 539 } else { 540 countText.setText(getQuantityText( 541 count, R.string.listFoundAllContactsZero, R.plurals.searchFoundContacts)); 542 } 543 } 544 } 545 546 /** 547 * Checks whether the contact entry at the given position represents the user's profile. 548 */ 549 protected boolean isUserProfile(int position) { 550 // The profile only ever appears in the first position if it is present. So if the position 551 // is anything beyond 0, it can't be the profile. 552 boolean isUserProfile = false; 553 if (position == 0) { 554 int partition = getPartitionForPosition(position); 555 if (partition >= 0) { 556 // Save the old cursor position - the call to getItem() may modify the cursor 557 // position. 558 int offset = getCursor(partition).getPosition(); 559 Cursor cursor = (Cursor) getItem(position); 560 if (cursor != null) { 561 int profileColumnIndex = cursor.getColumnIndex(Contacts.IS_USER_PROFILE); 562 if (profileColumnIndex != -1) { 563 isUserProfile = cursor.getInt(profileColumnIndex) == 1; 564 } 565 // Restore the old cursor position. 566 cursor.moveToPosition(offset); 567 } 568 } 569 } 570 return isUserProfile; 571 } 572 573 // TODO: fix PluralRules to handle zero correctly and use Resources.getQuantityText directly 574 public String getQuantityText(int count, int zeroResourceId, int pluralResourceId) { 575 if (count == 0) { 576 return getContext().getString(zeroResourceId); 577 } else { 578 String format = getContext().getResources() 579 .getQuantityText(pluralResourceId, count).toString(); 580 return String.format(format, count); 581 } 582 } 583 584 public boolean isPhotoSupported(int partitionIndex) { 585 Partition partition = getPartition(partitionIndex); 586 if (partition instanceof DirectoryPartition) { 587 return ((DirectoryPartition) partition).isPhotoSupported(); 588 } 589 return true; 590 } 591 592 /** 593 * Returns the currently selected filter. 594 */ 595 public ContactListFilter getFilter() { 596 return mFilter; 597 } 598 599 public void setFilter(ContactListFilter filter) { 600 mFilter = filter; 601 } 602 603 // TODO: move sharable logic (bindXX() methods) to here with extra arguments 604 605 protected void bindQuickContact(final ContactListItemView view, int partitionIndex, 606 Cursor cursor, int photoIdColumn, int contactIdColumn, int lookUpKeyColumn) { 607 long photoId = 0; 608 if (!cursor.isNull(photoIdColumn)) { 609 photoId = cursor.getLong(photoIdColumn); 610 } 611 612 QuickContactBadge quickContact = view.getQuickContact(); 613 quickContact.assignContactUri( 614 getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn)); 615 getPhotoLoader().loadPhoto(quickContact, photoId, false, mDarkTheme); 616 } 617 618 protected Uri getContactUri(int partitionIndex, Cursor cursor, 619 int contactIdColumn, int lookUpKeyColumn) { 620 long contactId = cursor.getLong(contactIdColumn); 621 String lookupKey = cursor.getString(lookUpKeyColumn); 622 Uri uri = Contacts.getLookupUri(contactId, lookupKey); 623 long directoryId = ((DirectoryPartition)getPartition(partitionIndex)).getDirectoryId(); 624 if (directoryId != Directory.DEFAULT) { 625 uri = uri.buildUpon().appendQueryParameter( 626 ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)).build(); 627 } 628 return uri; 629 } 630 631 public void setContactsCount(String count) { 632 mContactsCount = count; 633 } 634 635 public String getContactsCount() { 636 return mContactsCount; 637 } 638 } 639