/* * 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.contacts.common.list; import android.content.Context; import android.content.CursorLoader; import android.content.res.Resources; import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds.Phone; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.Directory; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.QuickContactBadge; import android.widget.SectionIndexer; import android.widget.TextView; import com.android.contacts.common.ContactsUtils; import com.android.contacts.common.R; import com.android.contacts.common.compat.DirectoryCompat; import com.android.contacts.common.util.SearchUtil; import com.android.dialer.common.LogUtil; import com.android.dialer.compat.CompatUtils; import com.android.dialer.configprovider.ConfigProviderBindings; import com.android.dialer.contactphoto.ContactPhotoManager; import com.android.dialer.contactphoto.ContactPhotoManager.DefaultImageRequest; import com.android.dialer.logging.InteractionEvent; import com.android.dialer.logging.Logger; import java.util.HashSet; /** * Common base class for various contact-related lists, e.g. contact list, phone number list etc. */ public abstract class ContactEntryListAdapter extends IndexerListAdapter { /** * Indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should be included in the * search. */ public static final boolean LOCAL_INVISIBLE_DIRECTORY_ENABLED = false; private int mDisplayOrder; private int mSortOrder; private boolean mDisplayPhotos; private boolean mCircularPhotos = true; private boolean mQuickContactEnabled; private boolean mAdjustSelectionBoundsEnabled; /** The root view of the fragment that this adapter is associated with. */ private View mFragmentRootView; private ContactPhotoManager mPhotoLoader; private String mQueryString; private String mUpperCaseQueryString; private boolean mSearchMode; private int mDirectorySearchMode; private int mDirectoryResultLimit = Integer.MAX_VALUE; private boolean mEmptyListEnabled = true; private boolean mSelectionVisible; private ContactListFilter mFilter; private boolean mDarkTheme = false; public static final int SUGGESTIONS_LOADER_ID = 0; /** Resource used to provide header-text for default filter. */ private CharSequence mDefaultFilterHeaderText; public ContactEntryListAdapter(Context context) { super(context); setDefaultFilterHeaderText(R.string.local_search_label); addPartitions(); } /** * @param fragmentRootView Root view of the fragment. This is used to restrict the scope of image * loading requests that get cancelled on cursor changes. */ protected void setFragmentRootView(View fragmentRootView) { mFragmentRootView = fragmentRootView; } protected void setDefaultFilterHeaderText(int resourceId) { mDefaultFilterHeaderText = getContext().getResources().getText(resourceId); } @Override protected ContactListItemView newView( Context context, int partition, Cursor cursor, int position, ViewGroup parent) { final ContactListItemView view = new ContactListItemView(context, null); view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled()); view.setAdjustSelectionBoundsEnabled(isAdjustSelectionBoundsEnabled()); return view; } @Override protected void bindView(View itemView, int partition, Cursor cursor, int position) { final ContactListItemView view = (ContactListItemView) itemView; view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled()); bindWorkProfileIcon(view, partition); } @Override protected View createPinnedSectionHeaderView(Context context, ViewGroup parent) { return new ContactListPinnedHeaderView(context, null, parent); } @Override protected void setPinnedSectionTitle(View pinnedHeaderView, String title) { ((ContactListPinnedHeaderView) pinnedHeaderView).setSectionHeaderTitle(title); } protected void addPartitions() { if (ConfigProviderBindings.get(getContext()).getBoolean("p13n_ranker_should_enable", false)) { addPartition(createSuggestionsDirectoryPartition()); } addPartition(createDefaultDirectoryPartition()); } protected DirectoryPartition createSuggestionsDirectoryPartition() { DirectoryPartition partition = new DirectoryPartition(true, true); partition.setDirectoryId(SUGGESTIONS_LOADER_ID); partition.setDirectoryType(getContext().getString(R.string.contact_suggestions)); partition.setPriorityDirectory(true); partition.setPhotoSupported(true); partition.setLabel(getContext().getString(R.string.local_suggestions_search_label)); return partition; } protected DirectoryPartition createDefaultDirectoryPartition() { DirectoryPartition partition = new DirectoryPartition(true, true); partition.setDirectoryId(Directory.DEFAULT); partition.setDirectoryType(getContext().getString(R.string.contactsList)); partition.setPriorityDirectory(true); partition.setPhotoSupported(true); partition.setLabel(mDefaultFilterHeaderText.toString()); return partition; } /** * Remove all directories after the default directory. This is typically used when contacts list * screens are asked to exit the search mode and thus need to remove all remote directory results * for the search. * *

This code assumes that the default directory and directories before that should not be * deleted (e.g. Join screen has "suggested contacts" directory before the default director, and * we should not remove the directory). */ public void removeDirectoriesAfterDefault() { final int partitionCount = getPartitionCount(); for (int i = partitionCount - 1; i >= 0; i--) { final Partition partition = getPartition(i); if ((partition instanceof DirectoryPartition) && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) { break; } else { removePartition(i); } } } protected int getPartitionByDirectoryId(long id) { int count = getPartitionCount(); for (int i = 0; i < count; i++) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition) { if (((DirectoryPartition) partition).getDirectoryId() == id) { return i; } } } return -1; } protected DirectoryPartition getDirectoryById(long id) { int count = getPartitionCount(); for (int i = 0; i < count; i++) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition) { final DirectoryPartition directoryPartition = (DirectoryPartition) partition; if (directoryPartition.getDirectoryId() == id) { return directoryPartition; } } } return null; } public abstract void configureLoader(CursorLoader loader, long directoryId); /** Marks all partitions as "loading" */ public void onDataReload() { boolean notify = false; int count = getPartitionCount(); for (int i = 0; i < count; i++) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition) { DirectoryPartition directoryPartition = (DirectoryPartition) partition; if (!directoryPartition.isLoading()) { notify = true; } directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); } } if (notify) { notifyDataSetChanged(); } } @Override public void clearPartitions() { int count = getPartitionCount(); for (int i = 0; i < count; i++) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition) { DirectoryPartition directoryPartition = (DirectoryPartition) partition; directoryPartition.setStatus(DirectoryPartition.STATUS_NOT_LOADED); } } super.clearPartitions(); } public boolean isSearchMode() { return mSearchMode; } public void setSearchMode(boolean flag) { mSearchMode = flag; } public String getQueryString() { return mQueryString; } public void setQueryString(String queryString) { mQueryString = queryString; if (TextUtils.isEmpty(queryString)) { mUpperCaseQueryString = null; } else { mUpperCaseQueryString = SearchUtil.cleanStartAndEndOfSearchQuery(queryString.toUpperCase()); } // Enable default partition header if in search mode (including zero-suggest). if (mQueryString != null) { setDefaultPartitionHeader(true); } } public String getUpperCaseQueryString() { return mUpperCaseQueryString; } public int getDirectorySearchMode() { return mDirectorySearchMode; } public void setDirectorySearchMode(int mode) { mDirectorySearchMode = mode; } public int getDirectoryResultLimit() { return mDirectoryResultLimit; } public void setDirectoryResultLimit(int limit) { this.mDirectoryResultLimit = limit; } public int getDirectoryResultLimit(DirectoryPartition directoryPartition) { final int limit = directoryPartition.getResultLimit(); return limit == DirectoryPartition.RESULT_LIMIT_DEFAULT ? mDirectoryResultLimit : limit; } public int getContactNameDisplayOrder() { return mDisplayOrder; } public void setContactNameDisplayOrder(int displayOrder) { mDisplayOrder = displayOrder; } public int getSortOrder() { return mSortOrder; } public void setSortOrder(int sortOrder) { mSortOrder = sortOrder; } protected ContactPhotoManager getPhotoLoader() { return mPhotoLoader; } public void setPhotoLoader(ContactPhotoManager photoLoader) { mPhotoLoader = photoLoader; } public boolean getDisplayPhotos() { return mDisplayPhotos; } public void setDisplayPhotos(boolean displayPhotos) { mDisplayPhotos = displayPhotos; } public boolean getCircularPhotos() { return mCircularPhotos; } public boolean isSelectionVisible() { return mSelectionVisible; } public void setSelectionVisible(boolean flag) { this.mSelectionVisible = flag; } public boolean isQuickContactEnabled() { return mQuickContactEnabled; } public void setQuickContactEnabled(boolean quickContactEnabled) { mQuickContactEnabled = quickContactEnabled; } public boolean isAdjustSelectionBoundsEnabled() { return mAdjustSelectionBoundsEnabled; } public void setAdjustSelectionBoundsEnabled(boolean enabled) { mAdjustSelectionBoundsEnabled = enabled; } public void setProfileExists(boolean exists) { // Stick the "ME" header for the profile if (exists) { setSectionHeader(R.string.user_profile_contacts_list_header, /* # of ME */ 1); } } private void setSectionHeader(int resId, int numberOfItems) { SectionIndexer indexer = getIndexer(); if (indexer != null) { ((ContactsSectionIndexer) indexer) .setProfileAndFavoritesHeader(getContext().getString(resId), numberOfItems); } } public void setDarkTheme(boolean value) { mDarkTheme = value; } /** Updates partitions according to the directory meta-data contained in the supplied cursor. */ public void changeDirectories(Cursor cursor) { if (cursor.getCount() == 0) { // Directory table must have at least local directory, without which this adapter will // enter very weird state. LogUtil.i( "ContactEntryListAdapter.changeDirectories", "directory search loader returned an empty cursor, which implies we have " + "no directory entries.", new RuntimeException()); return; } HashSet directoryIds = new HashSet(); int idColumnIndex = cursor.getColumnIndex(Directory._ID); int directoryTypeColumnIndex = cursor.getColumnIndex(DirectoryListLoader.DIRECTORY_TYPE); int displayNameColumnIndex = cursor.getColumnIndex(Directory.DISPLAY_NAME); int photoSupportColumnIndex = cursor.getColumnIndex(Directory.PHOTO_SUPPORT); // TODO preserve the order of partition to match those of the cursor // Phase I: add new directories cursor.moveToPosition(-1); while (cursor.moveToNext()) { long id = cursor.getLong(idColumnIndex); directoryIds.add(id); if (getPartitionByDirectoryId(id) == -1) { DirectoryPartition partition = new DirectoryPartition(false, true); partition.setDirectoryId(id); if (DirectoryCompat.isRemoteDirectoryId(id)) { if (DirectoryCompat.isEnterpriseDirectoryId(id)) { partition.setLabel(mContext.getString(R.string.directory_search_label_work)); } else { partition.setLabel(mContext.getString(R.string.directory_search_label)); } } else { if (DirectoryCompat.isEnterpriseDirectoryId(id)) { partition.setLabel(mContext.getString(R.string.list_filter_phones_work)); } else { partition.setLabel(mDefaultFilterHeaderText.toString()); } } partition.setDirectoryType(cursor.getString(directoryTypeColumnIndex)); partition.setDisplayName(cursor.getString(displayNameColumnIndex)); int photoSupport = cursor.getInt(photoSupportColumnIndex); partition.setPhotoSupported( photoSupport == Directory.PHOTO_SUPPORT_THUMBNAIL_ONLY || photoSupport == Directory.PHOTO_SUPPORT_FULL); addPartition(partition); } } // Phase II: remove deleted directories int count = getPartitionCount(); for (int i = count; --i >= 0; ) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition) { long id = ((DirectoryPartition) partition).getDirectoryId(); if (!directoryIds.contains(id)) { removePartition(i); } } } invalidate(); notifyDataSetChanged(); } @Override public void changeCursor(int partitionIndex, Cursor cursor) { if (partitionIndex >= getPartitionCount()) { // There is no partition for this data return; } Partition partition = getPartition(partitionIndex); if (partition instanceof DirectoryPartition) { ((DirectoryPartition) partition).setStatus(DirectoryPartition.STATUS_LOADED); } if (mDisplayPhotos && mPhotoLoader != null && isPhotoSupported(partitionIndex)) { mPhotoLoader.refreshCache(); } super.changeCursor(partitionIndex, cursor); if (isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) { updateIndexer(cursor); } // When the cursor changes, cancel any pending asynchronous photo loads. mPhotoLoader.cancelPendingRequests(mFragmentRootView); } public void changeCursor(Cursor cursor) { changeCursor(0, cursor); } /** Updates the indexer, which is used to produce section headers. */ private void updateIndexer(Cursor cursor) { if (cursor == null || cursor.isClosed()) { setIndexer(null); return; } Bundle bundle = cursor.getExtras(); if (bundle.containsKey(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES) && bundle.containsKey(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS)) { String[] sections = bundle.getStringArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); int[] counts = bundle.getIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); if (getExtraStartingSection()) { // Insert an additional unnamed section at the top of the list. String[] allSections = new String[sections.length + 1]; int[] allCounts = new int[counts.length + 1]; for (int i = 0; i < sections.length; i++) { allSections[i + 1] = sections[i]; allCounts[i + 1] = counts[i]; } allCounts[0] = 1; allSections[0] = ""; setIndexer(new ContactsSectionIndexer(allSections, allCounts)); } else { setIndexer(new ContactsSectionIndexer(sections, counts)); } } else { setIndexer(null); } } protected boolean getExtraStartingSection() { return false; } @Override public int getViewTypeCount() { // We need a separate view type for each item type, plus another one for // each type with header, plus one for "other". return getItemViewTypeCount() * 2 + 1; } @Override public int getItemViewType(int partitionIndex, int position) { int type = super.getItemViewType(partitionIndex, position); if (!isUserProfile(position) && isSectionHeaderDisplayEnabled() && partitionIndex == getIndexedPartition()) { Placement placement = getItemPlacementInSection(position); return placement.firstInSection ? type : getItemViewTypeCount() + type; } else { return type; } } @Override public boolean isEmpty() { // TODO // if (contactsListActivity.mProviderStatus != ProviderStatus.STATUS_NORMAL) { // return true; // } if (!mEmptyListEnabled) { return false; } else if (isSearchMode()) { return TextUtils.isEmpty(getQueryString()); } else { return super.isEmpty(); } } public boolean isLoading() { int count = getPartitionCount(); for (int i = 0; i < count; i++) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition && ((DirectoryPartition) partition).isLoading()) { return true; } } return false; } /** Configures visibility parameters for the directory partitions. */ public void configurePartitionsVisibility(boolean isInSearchMode) { for (int i = 0; i < getPartitionCount(); i++) { setShowIfEmpty(i, false); setHasHeader(i, isInSearchMode); } } // Sets header for the default partition. private void setDefaultPartitionHeader(boolean setHeader) { // Iterate in reverse here to ensure the first DEFAULT directory has header. // Both "Suggestions" and "All Contacts" directories have DEFAULT id. int defaultPartitionIndex = -1; for (int i = getPartitionCount() - 1; i >= 0; i--) { Partition partition = getPartition(i); if (partition instanceof DirectoryPartition && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) { defaultPartitionIndex = i; } } setHasHeader(defaultPartitionIndex, setHeader); } @Override protected View newHeaderView(Context context, int partition, Cursor cursor, ViewGroup parent) { LayoutInflater inflater = LayoutInflater.from(context); View view = inflater.inflate(R.layout.directory_header, parent, false); if (!getPinnedPartitionHeadersEnabled()) { // If the headers are unpinned, there is no need for their background // color to be non-transparent. Setting this transparent reduces maintenance for // non-pinned headers. We don't need to bother synchronizing the activity's // background color with the header background color. view.setBackground(null); } return view; } protected void bindWorkProfileIcon(final ContactListItemView view, int partitionId) { final Partition partition = getPartition(partitionId); if (partition instanceof DirectoryPartition) { final DirectoryPartition directoryPartition = (DirectoryPartition) partition; final long directoryId = directoryPartition.getDirectoryId(); final long userType = ContactsUtils.determineUserType(directoryId, null); view.setWorkProfileIconEnabled(userType == ContactsUtils.USER_TYPE_WORK); } } @Override protected void bindHeaderView(View view, int partitionIndex, Cursor cursor) { Partition partition = getPartition(partitionIndex); if (!(partition instanceof DirectoryPartition)) { return; } DirectoryPartition directoryPartition = (DirectoryPartition) partition; long directoryId = directoryPartition.getDirectoryId(); TextView labelTextView = (TextView) view.findViewById(R.id.label); TextView displayNameTextView = (TextView) view.findViewById(R.id.display_name); labelTextView.setText(directoryPartition.getLabel()); if (!DirectoryCompat.isRemoteDirectoryId(directoryId)) { displayNameTextView.setText(null); } else { String directoryName = directoryPartition.getDisplayName(); String displayName = !TextUtils.isEmpty(directoryName) ? directoryName : directoryPartition.getDirectoryType(); displayNameTextView.setText(displayName); } final Resources res = getContext().getResources(); final int headerPaddingTop = partitionIndex == 1 && getPartition(0).isEmpty() ? 0 : res.getDimensionPixelOffset(R.dimen.directory_header_extra_top_padding); // There should be no extra padding at the top of the first directory header view.setPaddingRelative( view.getPaddingStart(), headerPaddingTop, view.getPaddingEnd(), view.getPaddingBottom()); } /** Checks whether the contact entry at the given position represents the user's profile. */ protected boolean isUserProfile(int position) { // The profile only ever appears in the first position if it is present. So if the position // is anything beyond 0, it can't be the profile. boolean isUserProfile = false; if (position == 0) { int partition = getPartitionForPosition(position); if (partition >= 0) { // Save the old cursor position - the call to getItem() may modify the cursor // position. int offset = getCursor(partition).getPosition(); Cursor cursor = (Cursor) getItem(position); if (cursor != null) { int profileColumnIndex = cursor.getColumnIndex(Contacts.IS_USER_PROFILE); if (profileColumnIndex != -1) { isUserProfile = cursor.getInt(profileColumnIndex) == 1; } // Restore the old cursor position. cursor.moveToPosition(offset); } } } return isUserProfile; } public boolean isPhotoSupported(int partitionIndex) { Partition partition = getPartition(partitionIndex); if (partition instanceof DirectoryPartition) { return ((DirectoryPartition) partition).isPhotoSupported(); } return true; } /** Returns the currently selected filter. */ public ContactListFilter getFilter() { return mFilter; } public void setFilter(ContactListFilter filter) { mFilter = filter; } // TODO: move sharable logic (bindXX() methods) to here with extra arguments /** * Loads the photo for the quick contact view and assigns the contact uri. * * @param photoIdColumn Index of the photo id column * @param photoUriColumn Index of the photo uri column. Optional: Can be -1 * @param contactIdColumn Index of the contact id column * @param lookUpKeyColumn Index of the lookup key column * @param displayNameColumn Index of the display name column */ protected void bindQuickContact( final ContactListItemView view, int partitionIndex, Cursor cursor, int photoIdColumn, int photoUriColumn, int contactIdColumn, int lookUpKeyColumn, int displayNameColumn) { long photoId = 0; if (!cursor.isNull(photoIdColumn)) { photoId = cursor.getLong(photoIdColumn); } QuickContactBadge quickContact = view.getQuickContact(); quickContact.assignContactUri( getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn)); if (CompatUtils.hasPrioritizedMimeType()) { // The Contacts app never uses the QuickContactBadge. Therefore, it is safe to assume // that only Dialer will use this QuickContact badge. This means prioritizing the phone // mimetype here is reasonable. quickContact.setPrioritizedMimeType(Phone.CONTENT_ITEM_TYPE); } Logger.get(mContext) .logQuickContactOnTouch( quickContact, InteractionEvent.Type.OPEN_QUICK_CONTACT_FROM_SEARCH, true); if (photoId != 0 || photoUriColumn == -1) { getPhotoLoader().loadThumbnail(quickContact, photoId, mDarkTheme, mCircularPhotos, null); } else { final String photoUriString = cursor.getString(photoUriColumn); final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); DefaultImageRequest request = null; if (photoUri == null) { request = getDefaultImageRequestFromCursor(cursor, displayNameColumn, lookUpKeyColumn); } getPhotoLoader().loadPhoto(quickContact, photoUri, -1, mDarkTheme, mCircularPhotos, request); } } @Override public boolean hasStableIds() { // Whenever bindViewId() is called, the values passed into setId() are stable or // stable-ish. For example, when one contact is modified we don't expect a second // contact's Contact._ID values to change. return true; } protected void bindViewId(final ContactListItemView view, Cursor cursor, int idColumn) { // Set a semi-stable id, so that talkback won't get confused when the list gets // refreshed. There is little harm in inserting the same ID twice. long contactId = cursor.getLong(idColumn); view.setId((int) (contactId % Integer.MAX_VALUE)); } protected Uri getContactUri( int partitionIndex, Cursor cursor, int contactIdColumn, int lookUpKeyColumn) { long contactId = cursor.getLong(contactIdColumn); String lookupKey = cursor.getString(lookUpKeyColumn); long directoryId = ((DirectoryPartition) getPartition(partitionIndex)).getDirectoryId(); Uri uri = Contacts.getLookupUri(contactId, lookupKey); if (uri != null && directoryId != Directory.DEFAULT) { uri = uri.buildUpon() .appendQueryParameter( ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) .build(); } return uri; } /** * Retrieves the lookup key and display name from a cursor, and returns a {@link * DefaultImageRequest} containing these contact details * * @param cursor Contacts cursor positioned at the current row to retrieve contact details for * @param displayNameColumn Column index of the display name * @param lookupKeyColumn Column index of the lookup key * @return {@link DefaultImageRequest} with the displayName and identifier fields set to the * display name and lookup key of the contact. */ public DefaultImageRequest getDefaultImageRequestFromCursor( Cursor cursor, int displayNameColumn, int lookupKeyColumn) { final String displayName = cursor.getString(displayNameColumn); final String lookupKey = cursor.getString(lookupKeyColumn); return new DefaultImageRequest(displayName, lookupKey, mCircularPhotos); } }