diff options
Diffstat (limited to 'java/com/android/contacts/common/list')
22 files changed, 7340 insertions, 0 deletions
diff --git a/java/com/android/contacts/common/list/AutoScrollListView.java b/java/com/android/contacts/common/list/AutoScrollListView.java new file mode 100644 index 000000000..601abf528 --- /dev/null +++ b/java/com/android/contacts/common/list/AutoScrollListView.java @@ -0,0 +1,125 @@ +/* + * 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.os.Build; +import android.util.AttributeSet; +import android.widget.ListView; + +/** + * A ListView that can be asked to scroll (smoothly or otherwise) to a specific position. This class + * takes advantage of similar functionality that exists in {@link ListView} and enhances it. + */ +public class AutoScrollListView extends ListView { + + /** Position the element at about 1/3 of the list height */ + private static final float PREFERRED_SELECTION_OFFSET_FROM_TOP = 0.33f; + + private int mRequestedScrollPosition = -1; + private boolean mSmoothScrollRequested; + + public AutoScrollListView(Context context) { + super(context); + } + + public AutoScrollListView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AutoScrollListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + /** + * Brings the specified position to view by optionally performing a jump-scroll maneuver: first it + * jumps to some position near the one requested and then does a smooth scroll to the requested + * position. This creates an impression of full smooth scrolling without actually traversing the + * entire list. If smooth scrolling is not requested, instantly positions the requested item at a + * preferred offset. + */ + public void requestPositionToScreen(int position, boolean smoothScroll) { + mRequestedScrollPosition = position; + mSmoothScrollRequested = smoothScroll; + requestLayout(); + } + + @Override + protected void layoutChildren() { + super.layoutChildren(); + if (mRequestedScrollPosition == -1) { + return; + } + + final int position = mRequestedScrollPosition; + mRequestedScrollPosition = -1; + + int firstPosition = getFirstVisiblePosition() + 1; + int lastPosition = getLastVisiblePosition(); + if (position >= firstPosition && position <= lastPosition) { + return; // Already on screen + } + + final int offset = (int) (getHeight() * PREFERRED_SELECTION_OFFSET_FROM_TOP); + if (!mSmoothScrollRequested) { + setSelectionFromTop(position, offset); + + // Since we have changed the scrolling position, we need to redo child layout + // Calling "requestLayout" in the middle of a layout pass has no effect, + // so we call layoutChildren explicitly + super.layoutChildren(); + + } else { + // We will first position the list a couple of screens before or after + // the new selection and then scroll smoothly to it. + int twoScreens = (lastPosition - firstPosition) * 2; + int preliminaryPosition; + if (position < firstPosition) { + preliminaryPosition = position + twoScreens; + if (preliminaryPosition >= getCount()) { + preliminaryPosition = getCount() - 1; + } + if (preliminaryPosition < firstPosition) { + setSelection(preliminaryPosition); + super.layoutChildren(); + } + } else { + preliminaryPosition = position - twoScreens; + if (preliminaryPosition < 0) { + preliminaryPosition = 0; + } + if (preliminaryPosition > lastPosition) { + setSelection(preliminaryPosition); + super.layoutChildren(); + } + } + + smoothScrollToPositionFromTop(position, offset); + } + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + + // Workaround for b/31160338 and b/32778636. + if (android.os.Build.VERSION.SDK_INT == Build.VERSION_CODES.N + || android.os.Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1) { + layoutChildren(); + } + } +} diff --git a/java/com/android/contacts/common/list/ContactEntry.java b/java/com/android/contacts/common/list/ContactEntry.java new file mode 100644 index 000000000..e33165e45 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactEntry.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2013 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.net.Uri; +import android.provider.ContactsContract.PinnedPositions; +import android.text.TextUtils; +import com.android.contacts.common.preference.ContactsPreferences; + +/** Class to hold contact information */ +public class ContactEntry { + + public static final ContactEntry BLANK_ENTRY = new ContactEntry(); + private static final int UNSET_DISPLAY_ORDER_PREFERENCE = -1; + /** Primary name for a Contact */ + public String namePrimary; + /** Alternative name for a Contact, e.g. last name first */ + public String nameAlternative; + /** + * The user's preference on name display order, last name first or first time first. {@see + * ContactsPreferences} + */ + public int nameDisplayOrder = UNSET_DISPLAY_ORDER_PREFERENCE; + + public String phoneLabel; + public String phoneNumber; + public Uri photoUri; + public Uri lookupUri; + public String lookupKey; + public long id; + public int pinned = PinnedPositions.UNPINNED; + public boolean isFavorite = false; + public boolean isDefaultNumber = false; + + public String getPreferredDisplayName() { + if (nameDisplayOrder == UNSET_DISPLAY_ORDER_PREFERENCE + || nameDisplayOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY + || TextUtils.isEmpty(nameAlternative)) { + return namePrimary; + } + return nameAlternative; + } +} diff --git a/java/com/android/contacts/common/list/ContactEntryListAdapter.java b/java/com/android/contacts/common/list/ContactEntryListAdapter.java new file mode 100644 index 000000000..18bbae382 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactEntryListAdapter.java @@ -0,0 +1,742 @@ +/* + * 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.util.Log; +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.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +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.compat.CompatUtils; +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 static final String TAG = "ContactEntryListAdapter"; + 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; + + /** 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() { + addPartition(createDefaultDirectoryPartition()); + } + + 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. + * + * <p>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()); + } + } + + 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. + Log.e( + TAG, + "Directory search loader returned an empty cursor, which implies we have " + + "no directory entries.", + new RuntimeException()); + return; + } + HashSet<Long> directoryIds = new HashSet<Long>(); + + 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; + } + + /** Changes visibility parameters for the default directory partition. */ + public void configureDefaultPartition(boolean showIfEmpty, boolean hasHeader) { + int defaultPartitionIndex = -1; + int count = getPartitionCount(); + for (int i = 0; i < count; i++) { + Partition partition = getPartition(i); + if (partition instanceof DirectoryPartition + && ((DirectoryPartition) partition).getDirectoryId() == Directory.DEFAULT) { + defaultPartitionIndex = i; + break; + } + } + if (defaultPartitionIndex != -1) { + setShowIfEmpty(defaultPartitionIndex, showIfEmpty); + setHasHeader(defaultPartitionIndex, hasHeader); + } + } + + @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); + } + + 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); + } +} diff --git a/java/com/android/contacts/common/list/ContactEntryListFragment.java b/java/com/android/contacts/common/list/ContactEntryListFragment.java new file mode 100644 index 000000000..a8d9b55ba --- /dev/null +++ b/java/com/android/contacts/common/list/ContactEntryListFragment.java @@ -0,0 +1,862 @@ +/* + * 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.app.Activity; +import android.app.Fragment; +import android.app.LoaderManager; +import android.app.LoaderManager.LoaderCallbacks; +import android.content.Context; +import android.content.CursorLoader; +import android.content.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.Parcelable; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnFocusChangeListener; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemClickListener; +import android.widget.AdapterView.OnItemLongClickListener; +import android.widget.ListView; +import com.android.common.widget.CompositeCursorAdapter.Partition; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.ContactListViewUtils; +import java.util.Locale; + +/** Common base class for various contact-related list fragments. */ +public abstract class ContactEntryListFragment<T extends ContactEntryListAdapter> extends Fragment + implements OnItemClickListener, + OnScrollListener, + OnFocusChangeListener, + OnTouchListener, + OnItemLongClickListener, + LoaderCallbacks<Cursor> { + private static final String TAG = "ContactEntryListFragment"; + private static final String KEY_LIST_STATE = "liststate"; + private static final String KEY_SECTION_HEADER_DISPLAY_ENABLED = "sectionHeaderDisplayEnabled"; + private static final String KEY_PHOTO_LOADER_ENABLED = "photoLoaderEnabled"; + private static final String KEY_QUICK_CONTACT_ENABLED = "quickContactEnabled"; + private static final String KEY_ADJUST_SELECTION_BOUNDS_ENABLED = "adjustSelectionBoundsEnabled"; + private static final String KEY_INCLUDE_PROFILE = "includeProfile"; + private static final String KEY_SEARCH_MODE = "searchMode"; + private static final String KEY_VISIBLE_SCROLLBAR_ENABLED = "visibleScrollbarEnabled"; + private static final String KEY_SCROLLBAR_POSITION = "scrollbarPosition"; + private static final String KEY_QUERY_STRING = "queryString"; + private static final String KEY_DIRECTORY_SEARCH_MODE = "directorySearchMode"; + private static final String KEY_SELECTION_VISIBLE = "selectionVisible"; + private static final String KEY_DARK_THEME = "darkTheme"; + private static final String KEY_LEGACY_COMPATIBILITY = "legacyCompatibility"; + private static final String KEY_DIRECTORY_RESULT_LIMIT = "directoryResultLimit"; + + private static final String DIRECTORY_ID_ARG_KEY = "directoryId"; + + private static final int DIRECTORY_LOADER_ID = -1; + + private static final int DIRECTORY_SEARCH_DELAY_MILLIS = 300; + private static final int DIRECTORY_SEARCH_MESSAGE = 1; + + private static final int DEFAULT_DIRECTORY_RESULT_LIMIT = 20; + private static final int STATUS_NOT_LOADED = 0; + private static final int STATUS_LOADING = 1; + private static final int STATUS_LOADED = 2; + protected boolean mUserProfileExists; + private boolean mSectionHeaderDisplayEnabled; + private boolean mPhotoLoaderEnabled; + private boolean mQuickContactEnabled = true; + private boolean mAdjustSelectionBoundsEnabled = true; + private boolean mIncludeProfile; + private boolean mSearchMode; + private boolean mVisibleScrollbarEnabled; + private boolean mShowEmptyListForEmptyQuery; + private int mVerticalScrollbarPosition = getDefaultVerticalScrollbarPosition(); + private String mQueryString; + private int mDirectorySearchMode = DirectoryListLoader.SEARCH_MODE_NONE; + private boolean mSelectionVisible; + private boolean mLegacyCompatibility; + private boolean mEnabled = true; + private T mAdapter; + private View mView; + private ListView mListView; + /** Used to save the scrolling state of the list when the fragment is not recreated. */ + private int mListViewTopIndex; + + private int mListViewTopOffset; + /** Used for keeping track of the scroll state of the list. */ + private Parcelable mListState; + + private int mDisplayOrder; + private int mSortOrder; + private int mDirectoryResultLimit = DEFAULT_DIRECTORY_RESULT_LIMIT; + private ContactPhotoManager mPhotoManager; + private ContactsPreferences mContactsPrefs; + private boolean mForceLoad; + private boolean mDarkTheme; + private int mDirectoryListStatus = STATUS_NOT_LOADED; + + /** + * Indicates whether we are doing the initial complete load of data (false) or a refresh caused by + * a change notification (true) + */ + private boolean mLoadPriorityDirectoriesOnly; + + private Context mContext; + + private LoaderManager mLoaderManager; + + private Handler mDelayedDirectorySearchHandler = + new Handler() { + @Override + public void handleMessage(Message msg) { + if (msg.what == DIRECTORY_SEARCH_MESSAGE) { + loadDirectoryPartition(msg.arg1, (DirectoryPartition) msg.obj); + } + } + }; + private ContactsPreferences.ChangeListener mPreferencesChangeListener = + new ContactsPreferences.ChangeListener() { + @Override + public void onChange() { + loadPreferences(); + reloadData(); + } + }; + + protected abstract View inflateView(LayoutInflater inflater, ViewGroup container); + + protected abstract T createListAdapter(); + + /** + * @param position Please note that the position is already adjusted for header views, so "0" + * means the first list item below header views. + */ + protected abstract void onItemClick(int position, long id); + + /** + * @param position Please note that the position is already adjusted for header views, so "0" + * means the first list item below header views. + */ + protected boolean onItemLongClick(int position, long id) { + return false; + } + + @Override + public void onAttach(Activity activity) { + super.onAttach(activity); + setContext(activity); + setLoaderManager(super.getLoaderManager()); + } + + @Override + public Context getContext() { + return mContext; + } + + /** Sets a context for the fragment in the unit test environment. */ + public void setContext(Context context) { + mContext = context; + configurePhotoLoader(); + } + + public void setEnabled(boolean enabled) { + if (mEnabled != enabled) { + mEnabled = enabled; + if (mAdapter != null) { + if (mEnabled) { + reloadData(); + } else { + mAdapter.clearPartitions(); + } + } + } + } + + @Override + public LoaderManager getLoaderManager() { + return mLoaderManager; + } + + /** Overrides a loader manager for use in unit tests. */ + public void setLoaderManager(LoaderManager loaderManager) { + mLoaderManager = loaderManager; + } + + public T getAdapter() { + return mAdapter; + } + + @Override + public View getView() { + return mView; + } + + public ListView getListView() { + return mListView; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(KEY_SECTION_HEADER_DISPLAY_ENABLED, mSectionHeaderDisplayEnabled); + outState.putBoolean(KEY_PHOTO_LOADER_ENABLED, mPhotoLoaderEnabled); + outState.putBoolean(KEY_QUICK_CONTACT_ENABLED, mQuickContactEnabled); + outState.putBoolean(KEY_ADJUST_SELECTION_BOUNDS_ENABLED, mAdjustSelectionBoundsEnabled); + outState.putBoolean(KEY_INCLUDE_PROFILE, mIncludeProfile); + outState.putBoolean(KEY_SEARCH_MODE, mSearchMode); + outState.putBoolean(KEY_VISIBLE_SCROLLBAR_ENABLED, mVisibleScrollbarEnabled); + outState.putInt(KEY_SCROLLBAR_POSITION, mVerticalScrollbarPosition); + outState.putInt(KEY_DIRECTORY_SEARCH_MODE, mDirectorySearchMode); + outState.putBoolean(KEY_SELECTION_VISIBLE, mSelectionVisible); + outState.putBoolean(KEY_LEGACY_COMPATIBILITY, mLegacyCompatibility); + outState.putString(KEY_QUERY_STRING, mQueryString); + outState.putInt(KEY_DIRECTORY_RESULT_LIMIT, mDirectoryResultLimit); + outState.putBoolean(KEY_DARK_THEME, mDarkTheme); + + if (mListView != null) { + outState.putParcelable(KEY_LIST_STATE, mListView.onSaveInstanceState()); + } + } + + @Override + public void onCreate(Bundle savedState) { + super.onCreate(savedState); + restoreSavedState(savedState); + mAdapter = createListAdapter(); + mContactsPrefs = new ContactsPreferences(mContext); + } + + public void restoreSavedState(Bundle savedState) { + if (savedState == null) { + return; + } + + mSectionHeaderDisplayEnabled = savedState.getBoolean(KEY_SECTION_HEADER_DISPLAY_ENABLED); + mPhotoLoaderEnabled = savedState.getBoolean(KEY_PHOTO_LOADER_ENABLED); + mQuickContactEnabled = savedState.getBoolean(KEY_QUICK_CONTACT_ENABLED); + mAdjustSelectionBoundsEnabled = savedState.getBoolean(KEY_ADJUST_SELECTION_BOUNDS_ENABLED); + mIncludeProfile = savedState.getBoolean(KEY_INCLUDE_PROFILE); + mSearchMode = savedState.getBoolean(KEY_SEARCH_MODE); + mVisibleScrollbarEnabled = savedState.getBoolean(KEY_VISIBLE_SCROLLBAR_ENABLED); + mVerticalScrollbarPosition = savedState.getInt(KEY_SCROLLBAR_POSITION); + mDirectorySearchMode = savedState.getInt(KEY_DIRECTORY_SEARCH_MODE); + mSelectionVisible = savedState.getBoolean(KEY_SELECTION_VISIBLE); + mLegacyCompatibility = savedState.getBoolean(KEY_LEGACY_COMPATIBILITY); + mQueryString = savedState.getString(KEY_QUERY_STRING); + mDirectoryResultLimit = savedState.getInt(KEY_DIRECTORY_RESULT_LIMIT); + mDarkTheme = savedState.getBoolean(KEY_DARK_THEME); + + // Retrieve list state. This will be applied in onLoadFinished + mListState = savedState.getParcelable(KEY_LIST_STATE); + } + + @Override + public void onStart() { + super.onStart(); + + mContactsPrefs.registerChangeListener(mPreferencesChangeListener); + + mForceLoad = loadPreferences(); + + mDirectoryListStatus = STATUS_NOT_LOADED; + mLoadPriorityDirectoriesOnly = true; + + startLoading(); + } + + protected void startLoading() { + if (mAdapter == null) { + // The method was called before the fragment was started + return; + } + + configureAdapter(); + int partitionCount = mAdapter.getPartitionCount(); + for (int i = 0; i < partitionCount; i++) { + Partition partition = mAdapter.getPartition(i); + if (partition instanceof DirectoryPartition) { + DirectoryPartition directoryPartition = (DirectoryPartition) partition; + if (directoryPartition.getStatus() == DirectoryPartition.STATUS_NOT_LOADED) { + if (directoryPartition.isPriorityDirectory() || !mLoadPriorityDirectoriesOnly) { + startLoadingDirectoryPartition(i); + } + } + } else { + getLoaderManager().initLoader(i, null, this); + } + } + + // Next time this method is called, we should start loading non-priority directories + mLoadPriorityDirectoriesOnly = false; + } + + @Override + public Loader<Cursor> onCreateLoader(int id, Bundle args) { + if (id == DIRECTORY_LOADER_ID) { + DirectoryListLoader loader = new DirectoryListLoader(mContext); + loader.setDirectorySearchMode(mAdapter.getDirectorySearchMode()); + loader.setLocalInvisibleDirectoryEnabled( + ContactEntryListAdapter.LOCAL_INVISIBLE_DIRECTORY_ENABLED); + return loader; + } else { + CursorLoader loader = createCursorLoader(mContext); + long directoryId = + args != null && args.containsKey(DIRECTORY_ID_ARG_KEY) + ? args.getLong(DIRECTORY_ID_ARG_KEY) + : Directory.DEFAULT; + mAdapter.configureLoader(loader, directoryId); + return loader; + } + } + + public CursorLoader createCursorLoader(Context context) { + return new CursorLoader(context, null, null, null, null, null) { + @Override + protected Cursor onLoadInBackground() { + try { + return super.onLoadInBackground(); + } catch (RuntimeException e) { + // We don't even know what the projection should be, so no point trying to + // return an empty MatrixCursor with the correct projection here. + Log.w(TAG, "RuntimeException while trying to query ContactsProvider."); + return null; + } + } + }; + } + + private void startLoadingDirectoryPartition(int partitionIndex) { + DirectoryPartition partition = (DirectoryPartition) mAdapter.getPartition(partitionIndex); + partition.setStatus(DirectoryPartition.STATUS_LOADING); + long directoryId = partition.getDirectoryId(); + if (mForceLoad) { + if (directoryId == Directory.DEFAULT) { + loadDirectoryPartition(partitionIndex, partition); + } else { + loadDirectoryPartitionDelayed(partitionIndex, partition); + } + } else { + Bundle args = new Bundle(); + args.putLong(DIRECTORY_ID_ARG_KEY, directoryId); + getLoaderManager().initLoader(partitionIndex, args, this); + } + } + + /** + * Queues up a delayed request to search the specified directory. Since directory search will + * likely introduce a lot of network traffic, we want to wait for a pause in the user's typing + * before sending a directory request. + */ + private void loadDirectoryPartitionDelayed(int partitionIndex, DirectoryPartition partition) { + mDelayedDirectorySearchHandler.removeMessages(DIRECTORY_SEARCH_MESSAGE, partition); + Message msg = + mDelayedDirectorySearchHandler.obtainMessage( + DIRECTORY_SEARCH_MESSAGE, partitionIndex, 0, partition); + mDelayedDirectorySearchHandler.sendMessageDelayed(msg, DIRECTORY_SEARCH_DELAY_MILLIS); + } + + /** Loads the directory partition. */ + protected void loadDirectoryPartition(int partitionIndex, DirectoryPartition partition) { + Bundle args = new Bundle(); + args.putLong(DIRECTORY_ID_ARG_KEY, partition.getDirectoryId()); + getLoaderManager().restartLoader(partitionIndex, args, this); + } + + /** Cancels all queued directory loading requests. */ + private void removePendingDirectorySearchRequests() { + mDelayedDirectorySearchHandler.removeMessages(DIRECTORY_SEARCH_MESSAGE); + } + + @Override + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + if (!mEnabled) { + return; + } + + int loaderId = loader.getId(); + if (loaderId == DIRECTORY_LOADER_ID) { + mDirectoryListStatus = STATUS_LOADED; + mAdapter.changeDirectories(data); + startLoading(); + } else { + onPartitionLoaded(loaderId, data); + if (isSearchMode()) { + int directorySearchMode = getDirectorySearchMode(); + if (directorySearchMode != DirectoryListLoader.SEARCH_MODE_NONE) { + if (mDirectoryListStatus == STATUS_NOT_LOADED) { + mDirectoryListStatus = STATUS_LOADING; + getLoaderManager().initLoader(DIRECTORY_LOADER_ID, null, this); + } else { + startLoading(); + } + } + } else { + mDirectoryListStatus = STATUS_NOT_LOADED; + getLoaderManager().destroyLoader(DIRECTORY_LOADER_ID); + } + } + } + + @Override + public void onLoaderReset(Loader<Cursor> loader) {} + + protected void onPartitionLoaded(int partitionIndex, Cursor data) { + if (partitionIndex >= mAdapter.getPartitionCount()) { + // When we get unsolicited data, ignore it. This could happen + // when we are switching from search mode to the default mode. + return; + } + + mAdapter.changeCursor(partitionIndex, data); + setProfileHeader(); + + if (!isLoading()) { + completeRestoreInstanceState(); + } + } + + public boolean isLoading() { + if (mAdapter != null && mAdapter.isLoading()) { + return true; + } + + return isLoadingDirectoryList(); + + } + + public boolean isLoadingDirectoryList() { + return isSearchMode() + && getDirectorySearchMode() != DirectoryListLoader.SEARCH_MODE_NONE + && (mDirectoryListStatus == STATUS_NOT_LOADED || mDirectoryListStatus == STATUS_LOADING); + } + + @Override + public void onStop() { + super.onStop(); + mContactsPrefs.unregisterChangeListener(); + mAdapter.clearPartitions(); + } + + protected void reloadData() { + removePendingDirectorySearchRequests(); + mAdapter.onDataReload(); + mLoadPriorityDirectoriesOnly = true; + mForceLoad = true; + startLoading(); + } + + /** + * Shows a view at the top of the list with a pseudo local profile prompting the user to add a + * local profile. Default implementation does nothing. + */ + protected void setProfileHeader() { + mUserProfileExists = false; + } + + /** Provides logic that dismisses this fragment. The default implementation does nothing. */ + protected void finish() {} + + public boolean isSectionHeaderDisplayEnabled() { + return mSectionHeaderDisplayEnabled; + } + + public void setSectionHeaderDisplayEnabled(boolean flag) { + if (mSectionHeaderDisplayEnabled != flag) { + mSectionHeaderDisplayEnabled = flag; + if (mAdapter != null) { + mAdapter.setSectionHeaderDisplayEnabled(flag); + } + configureVerticalScrollbar(); + } + } + + public boolean isVisibleScrollbarEnabled() { + return mVisibleScrollbarEnabled; + } + + public void setVisibleScrollbarEnabled(boolean flag) { + if (mVisibleScrollbarEnabled != flag) { + mVisibleScrollbarEnabled = flag; + configureVerticalScrollbar(); + } + } + + private void configureVerticalScrollbar() { + boolean hasScrollbar = isVisibleScrollbarEnabled() && isSectionHeaderDisplayEnabled(); + + if (mListView != null) { + mListView.setFastScrollEnabled(hasScrollbar); + mListView.setFastScrollAlwaysVisible(hasScrollbar); + mListView.setVerticalScrollbarPosition(mVerticalScrollbarPosition); + mListView.setScrollBarStyle(ListView.SCROLLBARS_OUTSIDE_OVERLAY); + } + } + + public boolean isPhotoLoaderEnabled() { + return mPhotoLoaderEnabled; + } + + public void setPhotoLoaderEnabled(boolean flag) { + mPhotoLoaderEnabled = flag; + configurePhotoLoader(); + } + + public void setQuickContactEnabled(boolean flag) { + this.mQuickContactEnabled = flag; + } + + public void setAdjustSelectionBoundsEnabled(boolean flag) { + mAdjustSelectionBoundsEnabled = flag; + } + + public final boolean isSearchMode() { + return mSearchMode; + } + + /** + * Enter/exit search mode. This is method is tightly related to the current query, and should only + * be called by {@link #setQueryString}. + * + * <p>Also note this method doesn't call {@link #reloadData()}; {@link #setQueryString} does it. + */ + protected void setSearchMode(boolean flag) { + if (mSearchMode != flag) { + mSearchMode = flag; + setSectionHeaderDisplayEnabled(!mSearchMode); + + if (!flag) { + mDirectoryListStatus = STATUS_NOT_LOADED; + getLoaderManager().destroyLoader(DIRECTORY_LOADER_ID); + } + + if (mAdapter != null) { + mAdapter.setSearchMode(flag); + + mAdapter.clearPartitions(); + if (!flag) { + // If we are switching from search to regular display, remove all directory + // partitions after default one, assuming they are remote directories which + // should be cleaned up on exiting the search mode. + mAdapter.removeDirectoriesAfterDefault(); + } + mAdapter.configureDefaultPartition(false, flag); + } + + if (mListView != null) { + mListView.setFastScrollEnabled(!flag); + } + } + } + + public final String getQueryString() { + return mQueryString; + } + + public void setQueryString(String queryString) { + if (!TextUtils.equals(mQueryString, queryString)) { + if (mShowEmptyListForEmptyQuery && mAdapter != null && mListView != null) { + if (TextUtils.isEmpty(mQueryString)) { + // Restore the adapter if the query used to be empty. + mListView.setAdapter(mAdapter); + } else if (TextUtils.isEmpty(queryString)) { + // Instantly clear the list view if the new query is empty. + mListView.setAdapter(null); + } + } + + mQueryString = queryString; + setSearchMode(!TextUtils.isEmpty(mQueryString) || mShowEmptyListForEmptyQuery); + + if (mAdapter != null) { + mAdapter.setQueryString(queryString); + reloadData(); + } + } + } + + public void setShowEmptyListForNullQuery(boolean show) { + mShowEmptyListForEmptyQuery = show; + } + + public boolean getShowEmptyListForNullQuery() { + return mShowEmptyListForEmptyQuery; + } + + public int getDirectoryLoaderId() { + return DIRECTORY_LOADER_ID; + } + + public int getDirectorySearchMode() { + return mDirectorySearchMode; + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + protected int getContactNameDisplayOrder() { + return mDisplayOrder; + } + + protected void setContactNameDisplayOrder(int displayOrder) { + mDisplayOrder = displayOrder; + if (mAdapter != null) { + mAdapter.setContactNameDisplayOrder(displayOrder); + } + } + + public int getSortOrder() { + return mSortOrder; + } + + public void setSortOrder(int sortOrder) { + mSortOrder = sortOrder; + if (mAdapter != null) { + mAdapter.setSortOrder(sortOrder); + } + } + + public void setDirectoryResultLimit(int limit) { + mDirectoryResultLimit = limit; + } + + protected boolean loadPreferences() { + boolean changed = false; + if (getContactNameDisplayOrder() != mContactsPrefs.getDisplayOrder()) { + setContactNameDisplayOrder(mContactsPrefs.getDisplayOrder()); + changed = true; + } + + if (getSortOrder() != mContactsPrefs.getSortOrder()) { + setSortOrder(mContactsPrefs.getSortOrder()); + changed = true; + } + + return changed; + } + + @Override + public View onCreateView( + LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + onCreateView(inflater, container); + + boolean searchMode = isSearchMode(); + mAdapter.setSearchMode(searchMode); + mAdapter.configureDefaultPartition(false, searchMode); + mAdapter.setPhotoLoader(mPhotoManager); + mListView.setAdapter(mAdapter); + + if (!isSearchMode()) { + mListView.setFocusableInTouchMode(true); + mListView.requestFocus(); + } + + return mView; + } + + protected void onCreateView(LayoutInflater inflater, ViewGroup container) { + mView = inflateView(inflater, container); + + mListView = (ListView) mView.findViewById(android.R.id.list); + if (mListView == null) { + throw new RuntimeException( + "Your content must have a ListView whose id attribute is " + "'android.R.id.list'"); + } + + View emptyView = mView.findViewById(android.R.id.empty); + if (emptyView != null) { + mListView.setEmptyView(emptyView); + } + + mListView.setOnItemClickListener(this); + mListView.setOnItemLongClickListener(this); + mListView.setOnFocusChangeListener(this); + mListView.setOnTouchListener(this); + mListView.setFastScrollEnabled(!isSearchMode()); + + // Tell list view to not show dividers. We'll do it ourself so that we can *not* show + // them when an A-Z headers is visible. + mListView.setDividerHeight(0); + + // We manually save/restore the listview state + mListView.setSaveEnabled(false); + + configureVerticalScrollbar(); + configurePhotoLoader(); + + getAdapter().setFragmentRootView(getView()); + + ContactListViewUtils.applyCardPaddingToView(getResources(), mListView, mView); + } + + @Override + public void onHiddenChanged(boolean hidden) { + super.onHiddenChanged(hidden); + if (getActivity() != null && getView() != null && !hidden) { + // If the padding was last applied when in a hidden state, it may have been applied + // incorrectly. Therefore we need to reapply it. + ContactListViewUtils.applyCardPaddingToView(getResources(), mListView, getView()); + } + } + + protected void configurePhotoLoader() { + if (isPhotoLoaderEnabled() && mContext != null) { + if (mPhotoManager == null) { + mPhotoManager = ContactPhotoManager.getInstance(mContext); + } + if (mListView != null) { + mListView.setOnScrollListener(this); + } + if (mAdapter != null) { + mAdapter.setPhotoLoader(mPhotoManager); + } + } + } + + protected void configureAdapter() { + if (mAdapter == null) { + return; + } + + mAdapter.setQuickContactEnabled(mQuickContactEnabled); + mAdapter.setAdjustSelectionBoundsEnabled(mAdjustSelectionBoundsEnabled); + mAdapter.setQueryString(mQueryString); + mAdapter.setDirectorySearchMode(mDirectorySearchMode); + mAdapter.setPinnedPartitionHeadersEnabled(false); + mAdapter.setContactNameDisplayOrder(mDisplayOrder); + mAdapter.setSortOrder(mSortOrder); + mAdapter.setSectionHeaderDisplayEnabled(mSectionHeaderDisplayEnabled); + mAdapter.setSelectionVisible(mSelectionVisible); + mAdapter.setDirectoryResultLimit(mDirectoryResultLimit); + mAdapter.setDarkTheme(mDarkTheme); + } + + @Override + public void onScroll( + AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {} + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + if (scrollState == OnScrollListener.SCROLL_STATE_FLING) { + mPhotoManager.pause(); + } else if (isPhotoLoaderEnabled()) { + mPhotoManager.resume(); + } + } + + @Override + public void onItemClick(AdapterView<?> parent, View view, int position, long id) { + hideSoftKeyboard(); + + int adjPosition = position - mListView.getHeaderViewsCount(); + if (adjPosition >= 0) { + onItemClick(adjPosition, id); + } + } + + @Override + public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { + int adjPosition = position - mListView.getHeaderViewsCount(); + + if (adjPosition >= 0) { + return onItemLongClick(adjPosition, id); + } + return false; + } + + private void hideSoftKeyboard() { + // Hide soft keyboard, if visible + InputMethodManager inputMethodManager = + (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + inputMethodManager.hideSoftInputFromWindow(mListView.getWindowToken(), 0); + } + + /** Dismisses the soft keyboard when the list takes focus. */ + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (view == mListView && hasFocus) { + hideSoftKeyboard(); + } + } + + /** Dismisses the soft keyboard when the list is touched. */ + @Override + public boolean onTouch(View view, MotionEvent event) { + if (view == mListView) { + hideSoftKeyboard(); + } + return false; + } + + @Override + public void onPause() { + // Save the scrolling state of the list view + mListViewTopIndex = mListView.getFirstVisiblePosition(); + View v = mListView.getChildAt(0); + mListViewTopOffset = (v == null) ? 0 : (v.getTop() - mListView.getPaddingTop()); + + super.onPause(); + removePendingDirectorySearchRequests(); + } + + @Override + public void onResume() { + super.onResume(); + // Restore the selection of the list view. See b/19982820. + // This has to be done manually because if the list view has its emptyView set, + // the scrolling state will be reset when clearPartitions() is called on the adapter. + mListView.setSelectionFromTop(mListViewTopIndex, mListViewTopOffset); + } + + /** Restore the list state after the adapter is populated. */ + protected void completeRestoreInstanceState() { + if (mListState != null) { + mListView.onRestoreInstanceState(mListState); + mListState = null; + } + } + + public void setDarkTheme(boolean value) { + mDarkTheme = value; + if (mAdapter != null) { + mAdapter.setDarkTheme(value); + } + } + + private int getDefaultVerticalScrollbarPosition() { + final Locale locale = Locale.getDefault(); + final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale); + switch (layoutDirection) { + case View.LAYOUT_DIRECTION_RTL: + return View.SCROLLBAR_POSITION_LEFT; + case View.LAYOUT_DIRECTION_LTR: + default: + return View.SCROLLBAR_POSITION_RIGHT; + } + } +} diff --git a/java/com/android/contacts/common/list/ContactListAdapter.java b/java/com/android/contacts/common/list/ContactListAdapter.java new file mode 100644 index 000000000..6cd311811 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListAdapter.java @@ -0,0 +1,232 @@ +/* + * 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.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.SearchSnippets; +import android.view.ViewGroup; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.R; +import com.android.contacts.common.preference.ContactsPreferences; + +/** + * A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type. Also + * includes support for including the {@link ContactsContract.Profile} record in the list. + */ +public abstract class ContactListAdapter extends ContactEntryListAdapter { + + private CharSequence mUnknownNameText; + + public ContactListAdapter(Context context) { + super(context); + + mUnknownNameText = context.getText(R.string.missing_name); + } + + protected static Uri buildSectionIndexerUri(Uri uri) { + return uri.buildUpon().appendQueryParameter(Contacts.EXTRA_ADDRESS_BOOK_INDEX, "true").build(); + } + + public Uri getContactUri(int partitionIndex, Cursor cursor) { + long contactId = cursor.getLong(ContactQuery.CONTACT_ID); + String lookupKey = cursor.getString(ContactQuery.CONTACT_LOOKUP_KEY); + Uri uri = Contacts.getLookupUri(contactId, lookupKey); + long directoryId = ((DirectoryPartition) getPartition(partitionIndex)).getDirectoryId(); + if (uri != null && directoryId != Directory.DEFAULT) { + uri = + uri.buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .build(); + } + return uri; + } + + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + ContactListItemView view = super.newView(context, partition, cursor, position, parent); + view.setUnknownNameText(mUnknownNameText); + view.setQuickContactEnabled(isQuickContactEnabled()); + view.setAdjustSelectionBoundsEnabled(isAdjustSelectionBoundsEnabled()); + view.setActivatedStateSupported(isSelectionVisible()); + return view; + } + + protected void bindSectionHeaderAndDivider( + ContactListItemView view, int position, Cursor cursor) { + view.setIsSectionHeaderEnabled(isSectionHeaderDisplayEnabled()); + if (isSectionHeaderDisplayEnabled()) { + Placement placement = getItemPlacementInSection(position); + view.setSectionHeader(placement.sectionHeader); + } else { + view.setSectionHeader(null); + } + } + + protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) { + if (!isPhotoSupported(partitionIndex)) { + view.removePhotoView(); + return; + } + + // Set the photo, if available + long photoId = 0; + if (!cursor.isNull(ContactQuery.CONTACT_PHOTO_ID)) { + photoId = cursor.getLong(ContactQuery.CONTACT_PHOTO_ID); + } + + if (photoId != 0) { + getPhotoLoader() + .loadThumbnail(view.getPhotoView(), photoId, false, getCircularPhotos(), null); + } else { + final String photoUriString = cursor.getString(ContactQuery.CONTACT_PHOTO_URI); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + DefaultImageRequest request = null; + if (photoUri == null) { + request = + getDefaultImageRequestFromCursor( + cursor, ContactQuery.CONTACT_DISPLAY_NAME, ContactQuery.CONTACT_LOOKUP_KEY); + } + getPhotoLoader() + .loadDirectoryPhoto(view.getPhotoView(), photoUri, false, getCircularPhotos(), request); + } + } + + protected void bindNameAndViewId(final ContactListItemView view, Cursor cursor) { + view.showDisplayName(cursor, ContactQuery.CONTACT_DISPLAY_NAME); + // Note: we don't show phonetic any more (See issue 5265330) + + bindViewId(view, cursor, ContactQuery.CONTACT_ID); + } + + protected void bindPresenceAndStatusMessage(final ContactListItemView view, Cursor cursor) { + view.showPresenceAndStatusMessage( + cursor, ContactQuery.CONTACT_PRESENCE_STATUS, ContactQuery.CONTACT_CONTACT_STATUS); + } + + protected void bindSearchSnippet(final ContactListItemView view, Cursor cursor) { + view.showSnippet(cursor, ContactQuery.CONTACT_SNIPPET); + } + + @Override + public void changeCursor(int partitionIndex, Cursor cursor) { + super.changeCursor(partitionIndex, cursor); + + if (cursor == null || !cursor.moveToFirst()) { + return; + } + + // hasProfile tells whether the first row is a profile + final boolean hasProfile = cursor.getInt(ContactQuery.CONTACT_IS_USER_PROFILE) == 1; + + // Add ME profile on top of favorites + cursor.moveToFirst(); + setProfileExists(hasProfile); + } + + /** @return Projection useful for children. */ + protected final String[] getProjection(boolean forSearch) { + final int sortOrder = getContactNameDisplayOrder(); + if (forSearch) { + if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + return ContactQuery.FILTER_PROJECTION_PRIMARY; + } else { + return ContactQuery.FILTER_PROJECTION_ALTERNATIVE; + } + } else { + if (sortOrder == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + return ContactQuery.CONTACT_PROJECTION_PRIMARY; + } else { + return ContactQuery.CONTACT_PROJECTION_ALTERNATIVE; + } + } + } + + protected static class ContactQuery { + + public static final int CONTACT_ID = 0; + public static final int CONTACT_DISPLAY_NAME = 1; + public static final int CONTACT_PRESENCE_STATUS = 2; + public static final int CONTACT_CONTACT_STATUS = 3; + public static final int CONTACT_PHOTO_ID = 4; + public static final int CONTACT_PHOTO_URI = 5; + public static final int CONTACT_LOOKUP_KEY = 6; + public static final int CONTACT_IS_USER_PROFILE = 7; + public static final int CONTACT_PHONETIC_NAME = 8; + public static final int CONTACT_STARRED = 9; + public static final int CONTACT_SNIPPET = 10; + private static final String[] CONTACT_PROJECTION_PRIMARY = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_PRIMARY, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + }; + private static final String[] CONTACT_PROJECTION_ALTERNATIVE = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_ALTERNATIVE, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + }; + private static final String[] FILTER_PROJECTION_PRIMARY = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_PRIMARY, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + SearchSnippets.SNIPPET, // 10 + }; + private static final String[] FILTER_PROJECTION_ALTERNATIVE = + new String[] { + Contacts._ID, // 0 + Contacts.DISPLAY_NAME_ALTERNATIVE, // 1 + Contacts.CONTACT_PRESENCE, // 2 + Contacts.CONTACT_STATUS, // 3 + Contacts.PHOTO_ID, // 4 + Contacts.PHOTO_THUMBNAIL_URI, // 5 + Contacts.LOOKUP_KEY, // 6 + Contacts.IS_USER_PROFILE, // 7 + Contacts.PHONETIC_NAME, // 8 + Contacts.STARRED, // 9 + SearchSnippets.SNIPPET, // 10 + }; + } +} diff --git a/java/com/android/contacts/common/list/ContactListFilter.java b/java/com/android/contacts/common/list/ContactListFilter.java new file mode 100644 index 000000000..1a03bb64c --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListFilter.java @@ -0,0 +1,297 @@ +/* + * 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.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.ContactsContract.RawContacts; +import android.text.TextUtils; + +/** Contact list filter parameters. */ +public final class ContactListFilter implements Comparable<ContactListFilter>, Parcelable { + + public static final int FILTER_TYPE_DEFAULT = -1; + public static final int FILTER_TYPE_ALL_ACCOUNTS = -2; + public static final int FILTER_TYPE_CUSTOM = -3; + public static final int FILTER_TYPE_STARRED = -4; + public static final int FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY = -5; + public static final int FILTER_TYPE_SINGLE_CONTACT = -6; + + public static final int FILTER_TYPE_ACCOUNT = 0; + public static final Parcelable.Creator<ContactListFilter> CREATOR = + new Parcelable.Creator<ContactListFilter>() { + @Override + public ContactListFilter createFromParcel(Parcel source) { + int filterType = source.readInt(); + String accountName = source.readString(); + String accountType = source.readString(); + String dataSet = source.readString(); + return new ContactListFilter(filterType, accountType, accountName, dataSet, null); + } + + @Override + public ContactListFilter[] newArray(int size) { + return new ContactListFilter[size]; + } + }; + /** + * Obsolete filter which had been used in Honeycomb. This may be stored in {@link + * SharedPreferences}, but should be replaced with ALL filter when it is found. + * + * <p>TODO: "group" filter and relevant variables are all obsolete. Remove them. + */ + private static final int FILTER_TYPE_GROUP = 1; + + private static final String KEY_FILTER_TYPE = "filter.type"; + private static final String KEY_ACCOUNT_NAME = "filter.accountName"; + private static final String KEY_ACCOUNT_TYPE = "filter.accountType"; + private static final String KEY_DATA_SET = "filter.dataSet"; + public final int filterType; + public final String accountType; + public final String accountName; + public final String dataSet; + public final Drawable icon; + private String mId; + + public ContactListFilter( + int filterType, String accountType, String accountName, String dataSet, Drawable icon) { + this.filterType = filterType; + this.accountType = accountType; + this.accountName = accountName; + this.dataSet = dataSet; + this.icon = icon; + } + + public static ContactListFilter createFilterWithType(int filterType) { + return new ContactListFilter(filterType, null, null, null, null); + } + + public static ContactListFilter createAccountFilter( + String accountType, String accountName, String dataSet, Drawable icon) { + return new ContactListFilter( + ContactListFilter.FILTER_TYPE_ACCOUNT, accountType, accountName, dataSet, icon); + } + + /** + * Store the given {@link ContactListFilter} to preferences. If the requested filter is of type + * {@link #FILTER_TYPE_SINGLE_CONTACT} then do not save it to preferences because it is a + * temporary state. + */ + public static void storeToPreferences(SharedPreferences prefs, ContactListFilter filter) { + if (filter != null && filter.filterType == FILTER_TYPE_SINGLE_CONTACT) { + return; + } + prefs + .edit() + .putInt(KEY_FILTER_TYPE, filter == null ? FILTER_TYPE_DEFAULT : filter.filterType) + .putString(KEY_ACCOUNT_NAME, filter == null ? null : filter.accountName) + .putString(KEY_ACCOUNT_TYPE, filter == null ? null : filter.accountType) + .putString(KEY_DATA_SET, filter == null ? null : filter.dataSet) + .apply(); + } + + /** + * Try to obtain ContactListFilter object saved in SharedPreference. If there's no info there, + * return ALL filter instead. + */ + public static ContactListFilter restoreDefaultPreferences(SharedPreferences prefs) { + ContactListFilter filter = restoreFromPreferences(prefs); + if (filter == null) { + filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS); + } + // "Group" filter is obsolete and thus is not exposed anymore. The "single contact mode" + // should also not be stored in preferences anymore since it is a temporary state. + if (filter.filterType == FILTER_TYPE_GROUP || filter.filterType == FILTER_TYPE_SINGLE_CONTACT) { + filter = ContactListFilter.createFilterWithType(FILTER_TYPE_ALL_ACCOUNTS); + } + return filter; + } + + private static ContactListFilter restoreFromPreferences(SharedPreferences prefs) { + int filterType = prefs.getInt(KEY_FILTER_TYPE, FILTER_TYPE_DEFAULT); + if (filterType == FILTER_TYPE_DEFAULT) { + return null; + } + + String accountName = prefs.getString(KEY_ACCOUNT_NAME, null); + String accountType = prefs.getString(KEY_ACCOUNT_TYPE, null); + String dataSet = prefs.getString(KEY_DATA_SET, null); + return new ContactListFilter(filterType, accountType, accountName, dataSet, null); + } + + public static final String filterTypeToString(int filterType) { + switch (filterType) { + case FILTER_TYPE_DEFAULT: + return "FILTER_TYPE_DEFAULT"; + case FILTER_TYPE_ALL_ACCOUNTS: + return "FILTER_TYPE_ALL_ACCOUNTS"; + case FILTER_TYPE_CUSTOM: + return "FILTER_TYPE_CUSTOM"; + case FILTER_TYPE_STARRED: + return "FILTER_TYPE_STARRED"; + case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + return "FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY"; + case FILTER_TYPE_SINGLE_CONTACT: + return "FILTER_TYPE_SINGLE_CONTACT"; + case FILTER_TYPE_ACCOUNT: + return "FILTER_TYPE_ACCOUNT"; + default: + return "(unknown)"; + } + } + + /** Returns true if this filter is based on data and may become invalid over time. */ + public boolean isValidationRequired() { + return filterType == FILTER_TYPE_ACCOUNT; + } + + @Override + public String toString() { + switch (filterType) { + case FILTER_TYPE_DEFAULT: + return "default"; + case FILTER_TYPE_ALL_ACCOUNTS: + return "all_accounts"; + case FILTER_TYPE_CUSTOM: + return "custom"; + case FILTER_TYPE_STARRED: + return "starred"; + case FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + return "with_phones"; + case FILTER_TYPE_SINGLE_CONTACT: + return "single"; + case FILTER_TYPE_ACCOUNT: + return "account: " + + accountType + + (dataSet != null ? "/" + dataSet : "") + + " " + + accountName; + } + return super.toString(); + } + + @Override + public int compareTo(ContactListFilter another) { + int res = accountName.compareTo(another.accountName); + if (res != 0) { + return res; + } + + res = accountType.compareTo(another.accountType); + if (res != 0) { + return res; + } + + return filterType - another.filterType; + } + + @Override + public int hashCode() { + int code = filterType; + if (accountType != null) { + code = code * 31 + accountType.hashCode(); + code = code * 31 + accountName.hashCode(); + } + if (dataSet != null) { + code = code * 31 + dataSet.hashCode(); + } + return code; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof ContactListFilter)) { + return false; + } + + ContactListFilter otherFilter = (ContactListFilter) other; + return filterType == otherFilter.filterType + && TextUtils.equals(accountName, otherFilter.accountName) + && TextUtils.equals(accountType, otherFilter.accountType) + && TextUtils.equals(dataSet, otherFilter.dataSet); + + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(filterType); + dest.writeString(accountName); + dest.writeString(accountType); + dest.writeString(dataSet); + } + + @Override + public int describeContents() { + return 0; + } + + /** Returns a string that can be used as a stable persistent identifier for this filter. */ + public String getId() { + if (mId == null) { + StringBuilder sb = new StringBuilder(); + sb.append(filterType); + if (accountType != null) { + sb.append('-').append(accountType); + } + if (dataSet != null) { + sb.append('/').append(dataSet); + } + if (accountName != null) { + sb.append('-').append(accountName.replace('-', '_')); + } + mId = sb.toString(); + } + return mId; + } + + /** + * Adds the account query parameters to the given {@code uriBuilder}. + * + * @throws IllegalStateException if the filter type is not {@link #FILTER_TYPE_ACCOUNT}. + */ + public Uri.Builder addAccountQueryParameterToUrl(Uri.Builder uriBuilder) { + if (filterType != FILTER_TYPE_ACCOUNT) { + throw new IllegalStateException("filterType must be FILTER_TYPE_ACCOUNT"); + } + uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_NAME, accountName); + uriBuilder.appendQueryParameter(RawContacts.ACCOUNT_TYPE, accountType); + if (!TextUtils.isEmpty(dataSet)) { + uriBuilder.appendQueryParameter(RawContacts.DATA_SET, dataSet); + } + return uriBuilder; + } + + public String toDebugString() { + final StringBuilder builder = new StringBuilder(); + builder.append("[filter type: " + filterType + " (" + filterTypeToString(filterType) + ")"); + if (filterType == FILTER_TYPE_ACCOUNT) { + builder + .append(", accountType: " + accountType) + .append(", accountName: " + accountName) + .append(", dataSet: " + dataSet); + } + builder.append(", icon: " + icon + "]"); + return builder.toString(); + } +} diff --git a/java/com/android/contacts/common/list/ContactListFilterController.java b/java/com/android/contacts/common/list/ContactListFilterController.java new file mode 100644 index 000000000..d2168f3f2 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListFilterController.java @@ -0,0 +1,170 @@ +/* + * 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.SharedPreferences; +import android.preference.PreferenceManager; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountWithDataSet; +import java.util.ArrayList; +import java.util.List; + +/** Manages {@link ContactListFilter}. All methods must be called from UI thread. */ +public abstract class ContactListFilterController { + + // singleton to cache the filter controller + private static ContactListFilterControllerImpl sFilterController = null; + + public static ContactListFilterController getInstance(Context context) { + // We may need to synchronize this in the future if background task will call this. + if (sFilterController == null) { + sFilterController = new ContactListFilterControllerImpl(context); + } + return sFilterController; + } + + public abstract void addListener(ContactListFilterListener listener); + + public abstract void removeListener(ContactListFilterListener listener); + + /** Return the currently-active filter. */ + public abstract ContactListFilter getFilter(); + + /** + * @param filter the filter + * @param persistent True when the given filter should be saved soon. False when the filter should + * not be saved. The latter case may happen when some Intent requires a certain type of UI + * (e.g. single contact) temporarily. + */ + public abstract void setContactListFilter(ContactListFilter filter, boolean persistent); + + public abstract void selectCustomFilter(); + + /** + * Checks if the current filter is valid and reset the filter if not. It may happen when an + * account is removed while the filter points to the account with {@link + * ContactListFilter#FILTER_TYPE_ACCOUNT} type, for example. It may also happen if the current + * filter is {@link ContactListFilter#FILTER_TYPE_SINGLE_CONTACT}, in which case, we should switch + * to the last saved filter in {@link SharedPreferences}. + */ + public abstract void checkFilterValidity(boolean notifyListeners); + + public interface ContactListFilterListener { + + void onContactListFilterChanged(); + } +} + +/** + * Stores the {@link ContactListFilter} selected by the user and saves it to {@link + * SharedPreferences} if necessary. + */ +class ContactListFilterControllerImpl extends ContactListFilterController { + + private final Context mContext; + private final List<ContactListFilterListener> mListeners = + new ArrayList<ContactListFilterListener>(); + private ContactListFilter mFilter; + + public ContactListFilterControllerImpl(Context context) { + mContext = context; + mFilter = ContactListFilter.restoreDefaultPreferences(getSharedPreferences()); + checkFilterValidity(true /* notify listeners */); + } + + @Override + public void addListener(ContactListFilterListener listener) { + mListeners.add(listener); + } + + @Override + public void removeListener(ContactListFilterListener listener) { + mListeners.remove(listener); + } + + @Override + public ContactListFilter getFilter() { + return mFilter; + } + + private SharedPreferences getSharedPreferences() { + return PreferenceManager.getDefaultSharedPreferences(mContext); + } + + @Override + public void setContactListFilter(ContactListFilter filter, boolean persistent) { + setContactListFilter(filter, persistent, true); + } + + private void setContactListFilter( + ContactListFilter filter, boolean persistent, boolean notifyListeners) { + if (!filter.equals(mFilter)) { + mFilter = filter; + if (persistent) { + ContactListFilter.storeToPreferences(getSharedPreferences(), mFilter); + } + if (notifyListeners && !mListeners.isEmpty()) { + notifyContactListFilterChanged(); + } + } + } + + @Override + public void selectCustomFilter() { + setContactListFilter( + ContactListFilter.createFilterWithType(ContactListFilter.FILTER_TYPE_CUSTOM), true); + } + + private void notifyContactListFilterChanged() { + for (ContactListFilterListener listener : mListeners) { + listener.onContactListFilterChanged(); + } + } + + @Override + public void checkFilterValidity(boolean notifyListeners) { + if (mFilter == null) { + return; + } + + switch (mFilter.filterType) { + case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: + setContactListFilter( + ContactListFilter.restoreDefaultPreferences(getSharedPreferences()), + false, + notifyListeners); + break; + case ContactListFilter.FILTER_TYPE_ACCOUNT: + if (!filterAccountExists()) { + // The current account filter points to invalid account. Use "all" filter + // instead. + setContactListFilter( + ContactListFilter.createFilterWithType(ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS), + true, + notifyListeners); + } + } + } + + /** @return true if the Account for the current filter exists. */ + private boolean filterAccountExists() { + final AccountTypeManager accountTypeManager = AccountTypeManager.getInstance(mContext); + final AccountWithDataSet filterAccount = + new AccountWithDataSet(mFilter.accountName, mFilter.accountType, mFilter.dataSet); + return accountTypeManager.contains(filterAccount, false); + } +} diff --git a/java/com/android/contacts/common/list/ContactListItemView.java b/java/com/android/contacts/common/list/ContactListItemView.java new file mode 100644 index 000000000..76842483a --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListItemView.java @@ -0,0 +1,1513 @@ +/* + * 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.res.ColorStateList; +import android.content.res.TypedArray; +import android.database.Cursor; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.SearchSnippets; +import android.support.v4.content.ContextCompat; +import android.support.v4.graphics.drawable.DrawableCompat; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView.SelectionBoundsAdjuster; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import android.widget.QuickContactBadge; +import android.widget.TextView; +import com.android.contacts.common.ContactPresenceIconUtil; +import com.android.contacts.common.ContactStatusUtil; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.PhoneNumberUtilsCompat; +import com.android.contacts.common.format.TextHighlighter; +import com.android.contacts.common.util.ContactDisplayUtils; +import com.android.contacts.common.util.SearchUtil; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.ViewUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A custom view for an item in the contact list. The view contains the contact's photo, a set of + * text views (for name, status, etc...) and icons for presence and call. The view uses no XML file + * for layout and all the measurements and layouts are done in the onMeasure and onLayout methods. + * + * <p>The layout puts the contact's photo on the right side of the view, the call icon (if present) + * to the left of the photo, the text lines are aligned to the left and the presence icon (if + * present) is set to the left of the status line. + * + * <p>The layout also supports a header (used as a header of a group of contacts) that is above the + * contact's data and a divider between contact view. + */ +public class ContactListItemView extends ViewGroup implements SelectionBoundsAdjuster { + private static final Pattern SPLIT_PATTERN = + Pattern.compile("([\\w-\\.]+)@((?:[\\w]+\\.)+)([a-zA-Z]{2,4})|[\\w]+"); + static final char SNIPPET_START_MATCH = '['; + static final char SNIPPET_END_MATCH = ']'; + /** A helper used to highlight a prefix in a text field. */ + private final TextHighlighter mTextHighlighter; + // Style values for layout and appearance + // The initialized values are defaults if none is provided through xml. + private int mPreferredHeight = 0; + private int mGapBetweenImageAndText = 0; + private int mGapBetweenLabelAndData = 0; + private int mPresenceIconMargin = 4; + private int mPresenceIconSize = 16; + private int mTextIndent = 0; + private int mTextOffsetTop; + private int mNameTextViewTextSize; + private int mHeaderWidth; + private Drawable mActivatedBackgroundDrawable; + private int mVideoCallIconSize = 32; + private int mVideoCallIconMargin = 16; + // Set in onLayout. Represent left and right position of the View on the screen. + private int mLeftOffset; + private int mRightOffset; + /** Used with {@link #mLabelView}, specifying the width ratio between label and data. */ + private int mLabelViewWidthWeight = 3; + /** Used with {@link #mDataView}, specifying the width ratio between label and data. */ + private int mDataViewWidthWeight = 5; + + private ArrayList<HighlightSequence> mNameHighlightSequence; + private ArrayList<HighlightSequence> mNumberHighlightSequence; + // Highlighting prefix for names. + private String mHighlightedPrefix; + /** Used to notify listeners when a video call icon is clicked. */ + private PhoneNumberListAdapter.Listener mPhoneNumberListAdapterListener; + /** Indicates whether to show the "video call" icon, used to initiate a video call. */ + private boolean mShowVideoCallIcon = false; + /** Indicates whether the view should leave room for the "video call" icon. */ + private boolean mSupportVideoCallIcon = false; + + private PhotoPosition mPhotoPosition = getDefaultPhotoPosition(false /* normal/non opposite */); + // Header layout data + private TextView mHeaderTextView; + private boolean mIsSectionHeaderEnabled; + // The views inside the contact view + private boolean mQuickContactEnabled = true; + private QuickContactBadge mQuickContact; + private ImageView mPhotoView; + private TextView mNameTextView; + private TextView mLabelView; + private TextView mDataView; + private TextView mSnippetView; + private TextView mStatusView; + private ImageView mPresenceIcon; + private ImageView mVideoCallIcon; + private ImageView mWorkProfileIcon; + private ColorStateList mSecondaryTextColor; + private int mDefaultPhotoViewSize = 0; + /** + * Can be effective even when {@link #mPhotoView} is null, as we want to have horizontal padding + * to align other data in this View. + */ + private int mPhotoViewWidth; + /** + * Can be effective even when {@link #mPhotoView} is null, as we want to have vertical padding. + */ + private int mPhotoViewHeight; + /** + * Only effective when {@link #mPhotoView} is null. When true all the Views on the right side of + * the photo should have horizontal padding on those left assuming there is a photo. + */ + private boolean mKeepHorizontalPaddingForPhotoView; + /** Only effective when {@link #mPhotoView} is null. */ + private boolean mKeepVerticalPaddingForPhotoView; + /** + * True when {@link #mPhotoViewWidth} and {@link #mPhotoViewHeight} are ready for being used. + * False indicates those values should be updated before being used in position calculation. + */ + private boolean mPhotoViewWidthAndHeightAreReady = false; + + private int mNameTextViewHeight; + private int mNameTextViewTextColor = Color.BLACK; + private int mPhoneticNameTextViewHeight; + private int mLabelViewHeight; + private int mDataViewHeight; + private int mSnippetTextViewHeight; + private int mStatusTextViewHeight; + private int mCheckBoxWidth; + // Holds Math.max(mLabelTextViewHeight, mDataViewHeight), assuming Label and Data share the + // same row. + private int mLabelAndDataViewMaxHeight; + private boolean mActivatedStateSupported; + private boolean mAdjustSelectionBoundsEnabled = true; + private Rect mBoundsWithoutHeader = new Rect(); + private CharSequence mUnknownNameText; + private int mPosition; + + public ContactListItemView(Context context) { + super(context); + + mTextHighlighter = new TextHighlighter(Typeface.BOLD); + mNameHighlightSequence = new ArrayList<HighlightSequence>(); + mNumberHighlightSequence = new ArrayList<HighlightSequence>(); + } + + public ContactListItemView(Context context, AttributeSet attrs, boolean supportVideoCallIcon) { + this(context, attrs); + + mSupportVideoCallIcon = supportVideoCallIcon; + } + + public ContactListItemView(Context context, AttributeSet attrs) { + super(context, attrs); + + TypedArray a; + + if (R.styleable.ContactListItemView != null) { + // Read all style values + a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView); + mPreferredHeight = + a.getDimensionPixelSize( + R.styleable.ContactListItemView_list_item_height, mPreferredHeight); + mActivatedBackgroundDrawable = + a.getDrawable(R.styleable.ContactListItemView_activated_background); + + mGapBetweenImageAndText = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_gap_between_image_and_text, + mGapBetweenImageAndText); + mGapBetweenLabelAndData = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_gap_between_label_and_data, + mGapBetweenLabelAndData); + mPresenceIconMargin = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_presence_icon_margin, mPresenceIconMargin); + mPresenceIconSize = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_presence_icon_size, mPresenceIconSize); + mDefaultPhotoViewSize = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_photo_size, mDefaultPhotoViewSize); + mTextIndent = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_text_indent, mTextIndent); + mTextOffsetTop = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_text_offset_top, mTextOffsetTop); + mDataViewWidthWeight = + a.getInteger( + R.styleable.ContactListItemView_list_item_data_width_weight, mDataViewWidthWeight); + mLabelViewWidthWeight = + a.getInteger( + R.styleable.ContactListItemView_list_item_label_width_weight, mLabelViewWidthWeight); + mNameTextViewTextColor = + a.getColor( + R.styleable.ContactListItemView_list_item_name_text_color, mNameTextViewTextColor); + mNameTextViewTextSize = + (int) + a.getDimension( + R.styleable.ContactListItemView_list_item_name_text_size, + (int) getResources().getDimension(R.dimen.contact_browser_list_item_text_size)); + mVideoCallIconSize = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_video_call_icon_size, mVideoCallIconSize); + mVideoCallIconMargin = + a.getDimensionPixelOffset( + R.styleable.ContactListItemView_list_item_video_call_icon_margin, + mVideoCallIconMargin); + + setPaddingRelative( + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_left, 0), + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_top, 0), + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_right, 0), + a.getDimensionPixelOffset(R.styleable.ContactListItemView_list_item_padding_bottom, 0)); + + a.recycle(); + } + + mTextHighlighter = new TextHighlighter(Typeface.BOLD); + + if (R.styleable.Theme != null) { + a = getContext().obtainStyledAttributes(R.styleable.Theme); + mSecondaryTextColor = a.getColorStateList(R.styleable.Theme_android_textColorSecondary); + a.recycle(); + } + + mHeaderWidth = getResources().getDimensionPixelSize(R.dimen.contact_list_section_header_width); + + if (mActivatedBackgroundDrawable != null) { + mActivatedBackgroundDrawable.setCallback(this); + } + + mNameHighlightSequence = new ArrayList<HighlightSequence>(); + mNumberHighlightSequence = new ArrayList<HighlightSequence>(); + + setLayoutDirection(View.LAYOUT_DIRECTION_LOCALE); + } + + public static final PhotoPosition getDefaultPhotoPosition(boolean opposite) { + final Locale locale = Locale.getDefault(); + final int layoutDirection = TextUtils.getLayoutDirectionFromLocale(locale); + switch (layoutDirection) { + case View.LAYOUT_DIRECTION_RTL: + return (opposite ? PhotoPosition.LEFT : PhotoPosition.RIGHT); + case View.LAYOUT_DIRECTION_LTR: + default: + return (opposite ? PhotoPosition.RIGHT : PhotoPosition.LEFT); + } + } + + /** + * Helper method for splitting a string into tokens. The lists passed in are populated with the + * tokens and offsets into the content of each token. The tokenization function parses e-mail + * addresses as a single token; otherwise it splits on any non-alphanumeric character. + * + * @param content Content to split. + * @return List of token strings. + */ + private static List<String> split(String content) { + final Matcher matcher = SPLIT_PATTERN.matcher(content); + final ArrayList<String> tokens = new ArrayList<>(); + while (matcher.find()) { + tokens.add(matcher.group()); + } + return tokens; + } + + public void setUnknownNameText(CharSequence unknownNameText) { + mUnknownNameText = unknownNameText; + } + + public void setQuickContactEnabled(boolean flag) { + mQuickContactEnabled = flag; + } + + /** + * Sets whether the video calling icon is shown. For the video calling icon to be shown, {@link + * #mSupportVideoCallIcon} must be {@code true}. + * + * @param showVideoCallIcon {@code true} if the video calling icon is shown, {@code false} + * otherwise. + * @param listener Listener to notify when the video calling icon is clicked. + * @param position The position in the adapater of the video calling icon. + */ + public void setShowVideoCallIcon( + boolean showVideoCallIcon, PhoneNumberListAdapter.Listener listener, int position) { + mShowVideoCallIcon = showVideoCallIcon; + mPhoneNumberListAdapterListener = listener; + mPosition = position; + + if (mShowVideoCallIcon) { + if (mVideoCallIcon == null) { + mVideoCallIcon = new ImageView(getContext()); + addView(mVideoCallIcon); + } + mVideoCallIcon.setContentDescription( + getContext().getString(R.string.description_search_video_call)); + mVideoCallIcon.setImageResource(R.drawable.ic_search_video_call); + mVideoCallIcon.setScaleType(ScaleType.CENTER); + mVideoCallIcon.setVisibility(View.VISIBLE); + mVideoCallIcon.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + // Inform the adapter that the video calling icon was clicked. + if (mPhoneNumberListAdapterListener != null) { + mPhoneNumberListAdapterListener.onVideoCallIconClicked(mPosition); + } + } + }); + } else { + if (mVideoCallIcon != null) { + mVideoCallIcon.setVisibility(View.GONE); + } + } + } + + /** + * Sets whether the view supports a video calling icon. This is independent of whether the view is + * actually showing an icon. Support for the video calling icon ensures that the layout leaves + * space for the video icon, should it be shown. + * + * @param supportVideoCallIcon {@code true} if the video call icon is supported, {@code false} + * otherwise. + */ + public void setSupportVideoCallIcon(boolean supportVideoCallIcon) { + mSupportVideoCallIcon = supportVideoCallIcon; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // We will match parent's width and wrap content vertically, but make sure + // height is no less than listPreferredItemHeight. + final int specWidth = resolveSize(0, widthMeasureSpec); + final int preferredHeight = mPreferredHeight; + + mNameTextViewHeight = 0; + mPhoneticNameTextViewHeight = 0; + mLabelViewHeight = 0; + mDataViewHeight = 0; + mLabelAndDataViewMaxHeight = 0; + mSnippetTextViewHeight = 0; + mStatusTextViewHeight = 0; + mCheckBoxWidth = 0; + + ensurePhotoViewSize(); + + // Width each TextView is able to use. + int effectiveWidth; + // All the other Views will honor the photo, so available width for them may be shrunk. + if (mPhotoViewWidth > 0 || mKeepHorizontalPaddingForPhotoView) { + effectiveWidth = + specWidth + - getPaddingLeft() + - getPaddingRight() + - (mPhotoViewWidth + mGapBetweenImageAndText); + } else { + effectiveWidth = specWidth - getPaddingLeft() - getPaddingRight(); + } + + if (mIsSectionHeaderEnabled) { + effectiveWidth -= mHeaderWidth + mGapBetweenImageAndText; + } + + if (mSupportVideoCallIcon) { + effectiveWidth -= (mVideoCallIconSize + mVideoCallIconMargin); + } + + // Go over all visible text views and measure actual width of each of them. + // Also calculate their heights to get the total height for this entire view. + + if (isVisible(mNameTextView)) { + // Calculate width for name text - this parallels similar measurement in onLayout. + int nameTextWidth = effectiveWidth; + if (mPhotoPosition != PhotoPosition.LEFT) { + nameTextWidth -= mTextIndent; + } + mNameTextView.measure( + MeasureSpec.makeMeasureSpec(nameTextWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mNameTextViewHeight = mNameTextView.getMeasuredHeight(); + } + + // If both data (phone number/email address) and label (type like "MOBILE") are quite long, + // we should ellipsize both using appropriate ratio. + final int dataWidth; + final int labelWidth; + if (isVisible(mDataView)) { + if (isVisible(mLabelView)) { + final int totalWidth = effectiveWidth - mGapBetweenLabelAndData; + dataWidth = + ((totalWidth * mDataViewWidthWeight) / (mDataViewWidthWeight + mLabelViewWidthWeight)); + labelWidth = + ((totalWidth * mLabelViewWidthWeight) / (mDataViewWidthWeight + mLabelViewWidthWeight)); + } else { + dataWidth = effectiveWidth; + labelWidth = 0; + } + } else { + dataWidth = 0; + if (isVisible(mLabelView)) { + labelWidth = effectiveWidth; + } else { + labelWidth = 0; + } + } + + if (isVisible(mDataView)) { + mDataView.measure( + MeasureSpec.makeMeasureSpec(dataWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mDataViewHeight = mDataView.getMeasuredHeight(); + } + + if (isVisible(mLabelView)) { + mLabelView.measure( + MeasureSpec.makeMeasureSpec(labelWidth, MeasureSpec.AT_MOST), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mLabelViewHeight = mLabelView.getMeasuredHeight(); + } + mLabelAndDataViewMaxHeight = Math.max(mLabelViewHeight, mDataViewHeight); + + if (isVisible(mSnippetView)) { + mSnippetView.measure( + MeasureSpec.makeMeasureSpec(effectiveWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mSnippetTextViewHeight = mSnippetView.getMeasuredHeight(); + } + + // Status view height is the biggest of the text view and the presence icon + if (isVisible(mPresenceIcon)) { + mPresenceIcon.measure( + MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mPresenceIconSize, MeasureSpec.EXACTLY)); + mStatusTextViewHeight = mPresenceIcon.getMeasuredHeight(); + } + + if (mSupportVideoCallIcon && isVisible(mVideoCallIcon)) { + mVideoCallIcon.measure( + MeasureSpec.makeMeasureSpec(mVideoCallIconSize, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mVideoCallIconSize, MeasureSpec.EXACTLY)); + } + + if (isVisible(mWorkProfileIcon)) { + mWorkProfileIcon.measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mNameTextViewHeight = Math.max(mNameTextViewHeight, mWorkProfileIcon.getMeasuredHeight()); + } + + if (isVisible(mStatusView)) { + // Presence and status are in a same row, so status will be affected by icon size. + final int statusWidth; + if (isVisible(mPresenceIcon)) { + statusWidth = (effectiveWidth - mPresenceIcon.getMeasuredWidth() - mPresenceIconMargin); + } else { + statusWidth = effectiveWidth; + } + mStatusView.measure( + MeasureSpec.makeMeasureSpec(statusWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + mStatusTextViewHeight = Math.max(mStatusTextViewHeight, mStatusView.getMeasuredHeight()); + } + + // Calculate height including padding. + int height = + (mNameTextViewHeight + + mPhoneticNameTextViewHeight + + mLabelAndDataViewMaxHeight + + mSnippetTextViewHeight + + mStatusTextViewHeight); + + // Make sure the height is at least as high as the photo + height = Math.max(height, mPhotoViewHeight + getPaddingBottom() + getPaddingTop()); + + // Make sure height is at least the preferred height + height = Math.max(height, preferredHeight); + + // Measure the header if it is visible. + if (mHeaderTextView != null && mHeaderTextView.getVisibility() == VISIBLE) { + mHeaderTextView.measure( + MeasureSpec.makeMeasureSpec(mHeaderWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + } + + setMeasuredDimension(specWidth, height); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int height = bottom - top; + final int width = right - left; + + // Determine the vertical bounds by laying out the header first. + int topBound = 0; + int bottomBound = height; + int leftBound = getPaddingLeft(); + int rightBound = width - getPaddingRight(); + + final boolean isLayoutRtl = ViewUtil.isViewLayoutRtl(this); + + // Put the section header on the left side of the contact view. + if (mIsSectionHeaderEnabled) { + // Align the text view all the way left, to be consistent with Contacts. + if (isLayoutRtl) { + rightBound = width; + } else { + leftBound = 0; + } + if (mHeaderTextView != null) { + int headerHeight = mHeaderTextView.getMeasuredHeight(); + int headerTopBound = (bottomBound + topBound - headerHeight) / 2 + mTextOffsetTop; + + mHeaderTextView.layout( + isLayoutRtl ? rightBound - mHeaderWidth : leftBound, + headerTopBound, + isLayoutRtl ? rightBound : leftBound + mHeaderWidth, + headerTopBound + headerHeight); + } + if (isLayoutRtl) { + rightBound -= mHeaderWidth; + } else { + leftBound += mHeaderWidth; + } + } + + mBoundsWithoutHeader.set(left + leftBound, topBound, left + rightBound, bottomBound); + mLeftOffset = left + leftBound; + mRightOffset = left + rightBound; + if (mIsSectionHeaderEnabled) { + if (isLayoutRtl) { + rightBound -= mGapBetweenImageAndText; + } else { + leftBound += mGapBetweenImageAndText; + } + } + + if (mActivatedStateSupported && isActivated()) { + mActivatedBackgroundDrawable.setBounds(mBoundsWithoutHeader); + } + + final View photoView = mQuickContact != null ? mQuickContact : mPhotoView; + if (mPhotoPosition == PhotoPosition.LEFT) { + // Photo is the left most view. All the other Views should on the right of the photo. + if (photoView != null) { + // Center the photo vertically + final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2; + photoView.layout( + leftBound, photoTop, leftBound + mPhotoViewWidth, photoTop + mPhotoViewHeight); + leftBound += mPhotoViewWidth + mGapBetweenImageAndText; + } else if (mKeepHorizontalPaddingForPhotoView) { + // Draw nothing but keep the padding. + leftBound += mPhotoViewWidth + mGapBetweenImageAndText; + } + } else { + // Photo is the right most view. Right bound should be adjusted that way. + if (photoView != null) { + // Center the photo vertically + final int photoTop = topBound + (bottomBound - topBound - mPhotoViewHeight) / 2; + photoView.layout( + rightBound - mPhotoViewWidth, photoTop, rightBound, photoTop + mPhotoViewHeight); + rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText); + } else if (mKeepHorizontalPaddingForPhotoView) { + // Draw nothing but keep the padding. + rightBound -= (mPhotoViewWidth + mGapBetweenImageAndText); + } + + // Add indent between left-most padding and texts. + leftBound += mTextIndent; + } + + if (mSupportVideoCallIcon) { + // Place the video call button at the end of the list (e.g. take into account RTL mode). + if (isVisible(mVideoCallIcon)) { + // Center the video icon vertically + final int videoIconTop = topBound + (bottomBound - topBound - mVideoCallIconSize) / 2; + + if (!isLayoutRtl) { + // When photo is on left, video icon is placed on the right edge. + mVideoCallIcon.layout( + rightBound - mVideoCallIconSize, + videoIconTop, + rightBound, + videoIconTop + mVideoCallIconSize); + } else { + // When photo is on right, video icon is placed on the left edge. + mVideoCallIcon.layout( + leftBound, + videoIconTop, + leftBound + mVideoCallIconSize, + videoIconTop + mVideoCallIconSize); + } + } + + if (mPhotoPosition == PhotoPosition.LEFT) { + rightBound -= (mVideoCallIconSize + mVideoCallIconMargin); + } else { + leftBound += mVideoCallIconSize + mVideoCallIconMargin; + } + } + + // Center text vertically, then apply the top offset. + final int totalTextHeight = + mNameTextViewHeight + + mPhoneticNameTextViewHeight + + mLabelAndDataViewMaxHeight + + mSnippetTextViewHeight + + mStatusTextViewHeight; + int textTopBound = (bottomBound + topBound - totalTextHeight) / 2 + mTextOffsetTop; + + // Work Profile icon align top + int workProfileIconWidth = 0; + if (isVisible(mWorkProfileIcon)) { + workProfileIconWidth = mWorkProfileIcon.getMeasuredWidth(); + final int distanceFromEnd = mCheckBoxWidth > 0 ? mCheckBoxWidth + mGapBetweenImageAndText : 0; + if (mPhotoPosition == PhotoPosition.LEFT) { + // When photo is on left, label is placed on the right edge of the list item. + mWorkProfileIcon.layout( + rightBound - workProfileIconWidth - distanceFromEnd, + textTopBound, + rightBound - distanceFromEnd, + textTopBound + mNameTextViewHeight); + } else { + // When photo is on right, label is placed on the left of data view. + mWorkProfileIcon.layout( + leftBound + distanceFromEnd, + textTopBound, + leftBound + workProfileIconWidth + distanceFromEnd, + textTopBound + mNameTextViewHeight); + } + } + + // Layout all text view and presence icon + // Put name TextView first + if (isVisible(mNameTextView)) { + final int distanceFromEnd = + workProfileIconWidth + + (mCheckBoxWidth > 0 ? mCheckBoxWidth + mGapBetweenImageAndText : 0); + if (mPhotoPosition == PhotoPosition.LEFT) { + mNameTextView.layout( + leftBound, + textTopBound, + rightBound - distanceFromEnd, + textTopBound + mNameTextViewHeight); + } else { + mNameTextView.layout( + leftBound + distanceFromEnd, + textTopBound, + rightBound, + textTopBound + mNameTextViewHeight); + } + } + + if (isVisible(mNameTextView) || isVisible(mWorkProfileIcon)) { + textTopBound += mNameTextViewHeight; + } + + // Presence and status + if (isLayoutRtl) { + int statusRightBound = rightBound; + if (isVisible(mPresenceIcon)) { + int iconWidth = mPresenceIcon.getMeasuredWidth(); + mPresenceIcon.layout( + rightBound - iconWidth, textTopBound, rightBound, textTopBound + mStatusTextViewHeight); + statusRightBound -= (iconWidth + mPresenceIconMargin); + } + + if (isVisible(mStatusView)) { + mStatusView.layout( + leftBound, textTopBound, statusRightBound, textTopBound + mStatusTextViewHeight); + } + } else { + int statusLeftBound = leftBound; + if (isVisible(mPresenceIcon)) { + int iconWidth = mPresenceIcon.getMeasuredWidth(); + mPresenceIcon.layout( + leftBound, textTopBound, leftBound + iconWidth, textTopBound + mStatusTextViewHeight); + statusLeftBound += (iconWidth + mPresenceIconMargin); + } + + if (isVisible(mStatusView)) { + mStatusView.layout( + statusLeftBound, textTopBound, rightBound, textTopBound + mStatusTextViewHeight); + } + } + + if (isVisible(mStatusView) || isVisible(mPresenceIcon)) { + textTopBound += mStatusTextViewHeight; + } + + // Rest of text views + int dataLeftBound = leftBound; + + // Label and Data align bottom. + if (isVisible(mLabelView)) { + if (!isLayoutRtl) { + mLabelView.layout( + dataLeftBound, + textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + dataLeftBound += mLabelView.getMeasuredWidth() + mGapBetweenLabelAndData; + } else { + dataLeftBound = leftBound + mLabelView.getMeasuredWidth(); + mLabelView.layout( + rightBound - mLabelView.getMeasuredWidth(), + textTopBound + mLabelAndDataViewMaxHeight - mLabelViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + rightBound -= (mLabelView.getMeasuredWidth() + mGapBetweenLabelAndData); + } + } + + if (isVisible(mDataView)) { + if (!isLayoutRtl) { + mDataView.layout( + dataLeftBound, + textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + } else { + mDataView.layout( + rightBound - mDataView.getMeasuredWidth(), + textTopBound + mLabelAndDataViewMaxHeight - mDataViewHeight, + rightBound, + textTopBound + mLabelAndDataViewMaxHeight); + } + } + if (isVisible(mLabelView) || isVisible(mDataView)) { + textTopBound += mLabelAndDataViewMaxHeight; + } + + if (isVisible(mSnippetView)) { + mSnippetView.layout( + leftBound, textTopBound, rightBound, textTopBound + mSnippetTextViewHeight); + } + } + + @Override + public void adjustListItemSelectionBounds(Rect bounds) { + if (mAdjustSelectionBoundsEnabled) { + bounds.top += mBoundsWithoutHeader.top; + bounds.bottom = bounds.top + mBoundsWithoutHeader.height(); + bounds.left = mBoundsWithoutHeader.left; + bounds.right = mBoundsWithoutHeader.right; + } + } + + protected boolean isVisible(View view) { + return view != null && view.getVisibility() == View.VISIBLE; + } + + /** Extracts width and height from the style */ + private void ensurePhotoViewSize() { + if (!mPhotoViewWidthAndHeightAreReady) { + mPhotoViewWidth = mPhotoViewHeight = getDefaultPhotoViewSize(); + if (!mQuickContactEnabled && mPhotoView == null) { + if (!mKeepHorizontalPaddingForPhotoView) { + mPhotoViewWidth = 0; + } + if (!mKeepVerticalPaddingForPhotoView) { + mPhotoViewHeight = 0; + } + } + + mPhotoViewWidthAndHeightAreReady = true; + } + } + + protected int getDefaultPhotoViewSize() { + return mDefaultPhotoViewSize; + } + + /** + * Gets a LayoutParam that corresponds to the default photo size. + * + * @return A new LayoutParam. + */ + private LayoutParams getDefaultPhotoLayoutParams() { + LayoutParams params = generateDefaultLayoutParams(); + params.width = getDefaultPhotoViewSize(); + params.height = params.width; + return params; + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + if (mActivatedStateSupported) { + mActivatedBackgroundDrawable.setState(getDrawableState()); + } + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mActivatedBackgroundDrawable || super.verifyDrawable(who); + } + + @Override + public void jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState(); + if (mActivatedStateSupported) { + mActivatedBackgroundDrawable.jumpToCurrentState(); + } + } + + @Override + public void dispatchDraw(Canvas canvas) { + if (mActivatedStateSupported && isActivated()) { + mActivatedBackgroundDrawable.draw(canvas); + } + + super.dispatchDraw(canvas); + } + + /** Sets section header or makes it invisible if the title is null. */ + public void setSectionHeader(String title) { + if (!TextUtils.isEmpty(title)) { + if (mHeaderTextView == null) { + mHeaderTextView = new TextView(getContext()); + mHeaderTextView.setTextAppearance(getContext(), R.style.SectionHeaderStyle); + mHeaderTextView.setGravity(Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL); + addView(mHeaderTextView); + } + setMarqueeText(mHeaderTextView, title); + mHeaderTextView.setVisibility(View.VISIBLE); + mHeaderTextView.setAllCaps(true); + } else if (mHeaderTextView != null) { + mHeaderTextView.setVisibility(View.GONE); + } + } + + public void setIsSectionHeaderEnabled(boolean isSectionHeaderEnabled) { + mIsSectionHeaderEnabled = isSectionHeaderEnabled; + } + + /** Returns the quick contact badge, creating it if necessary. */ + public QuickContactBadge getQuickContact() { + if (!mQuickContactEnabled) { + throw new IllegalStateException("QuickContact is disabled for this view"); + } + if (mQuickContact == null) { + mQuickContact = new QuickContactBadge(getContext()); + if (CompatUtils.isLollipopCompatible()) { + mQuickContact.setOverlay(null); + } + mQuickContact.setLayoutParams(getDefaultPhotoLayoutParams()); + if (mNameTextView != null) { + mQuickContact.setContentDescription( + getContext() + .getString(R.string.description_quick_contact_for, mNameTextView.getText())); + } + + addView(mQuickContact); + mPhotoViewWidthAndHeightAreReady = false; + } + return mQuickContact; + } + + /** Returns the photo view, creating it if necessary. */ + public ImageView getPhotoView() { + if (mPhotoView == null) { + mPhotoView = new ImageView(getContext()); + mPhotoView.setLayoutParams(getDefaultPhotoLayoutParams()); + // Quick contact style used above will set a background - remove it + mPhotoView.setBackground(null); + addView(mPhotoView); + mPhotoViewWidthAndHeightAreReady = false; + } + return mPhotoView; + } + + /** Removes the photo view. */ + public void removePhotoView() { + removePhotoView(false, true); + } + + /** + * Removes the photo view. + * + * @param keepHorizontalPadding True means data on the right side will have padding on left, + * pretending there is still a photo view. + * @param keepVerticalPadding True means the View will have some height enough for accommodating a + * photo view. + */ + public void removePhotoView(boolean keepHorizontalPadding, boolean keepVerticalPadding) { + mPhotoViewWidthAndHeightAreReady = false; + mKeepHorizontalPaddingForPhotoView = keepHorizontalPadding; + mKeepVerticalPaddingForPhotoView = keepVerticalPadding; + if (mPhotoView != null) { + removeView(mPhotoView); + mPhotoView = null; + } + if (mQuickContact != null) { + removeView(mQuickContact); + mQuickContact = null; + } + } + + /** + * Sets a word prefix that will be highlighted if encountered in fields like name and search + * snippet. This will disable the mask highlighting for names. + * + * <p>NOTE: must be all upper-case + */ + public void setHighlightedPrefix(String upperCasePrefix) { + mHighlightedPrefix = upperCasePrefix; + } + + /** Clears previously set highlight sequences for the view. */ + public void clearHighlightSequences() { + mNameHighlightSequence.clear(); + mNumberHighlightSequence.clear(); + mHighlightedPrefix = null; + } + + /** + * Adds a highlight sequence to the name highlighter. + * + * @param start The start position of the highlight sequence. + * @param end The end position of the highlight sequence. + */ + public void addNameHighlightSequence(int start, int end) { + mNameHighlightSequence.add(new HighlightSequence(start, end)); + } + + /** + * Adds a highlight sequence to the number highlighter. + * + * @param start The start position of the highlight sequence. + * @param end The end position of the highlight sequence. + */ + public void addNumberHighlightSequence(int start, int end) { + mNumberHighlightSequence.add(new HighlightSequence(start, end)); + } + + /** Returns the text view for the contact name, creating it if necessary. */ + public TextView getNameTextView() { + if (mNameTextView == null) { + mNameTextView = new TextView(getContext()); + mNameTextView.setSingleLine(true); + mNameTextView.setEllipsize(getTextEllipsis()); + mNameTextView.setTextColor(mNameTextViewTextColor); + mNameTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mNameTextViewTextSize); + // Manually call setActivated() since this view may be added after the first + // setActivated() call toward this whole item view. + mNameTextView.setActivated(isActivated()); + mNameTextView.setGravity(Gravity.CENTER_VERTICAL); + mNameTextView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + mNameTextView.setId(R.id.cliv_name_textview); + if (CompatUtils.isLollipopCompatible()) { + mNameTextView.setElegantTextHeight(false); + } + addView(mNameTextView); + } + return mNameTextView; + } + + /** Adds or updates a text view for the data label. */ + public void setLabel(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mLabelView != null) { + mLabelView.setVisibility(View.GONE); + } + } else { + getLabelView(); + setMarqueeText(mLabelView, text); + mLabelView.setVisibility(VISIBLE); + } + } + + /** Returns the text view for the data label, creating it if necessary. */ + public TextView getLabelView() { + if (mLabelView == null) { + mLabelView = new TextView(getContext()); + mLabelView.setLayoutParams( + new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); + + mLabelView.setSingleLine(true); + mLabelView.setEllipsize(getTextEllipsis()); + mLabelView.setTextAppearance(getContext(), R.style.TextAppearanceSmall); + if (mPhotoPosition == PhotoPosition.LEFT) { + mLabelView.setAllCaps(true); + } else { + mLabelView.setTypeface(mLabelView.getTypeface(), Typeface.BOLD); + } + mLabelView.setActivated(isActivated()); + mLabelView.setId(R.id.cliv_label_textview); + addView(mLabelView); + } + return mLabelView; + } + + /** + * Sets phone number for a list item. This takes care of number highlighting if the highlight mask + * exists. + */ + public void setPhoneNumber(String text) { + if (text == null) { + if (mDataView != null) { + mDataView.setVisibility(View.GONE); + } + } else { + getDataView(); + + // TODO: Format number using PhoneNumberUtils.formatNumber before assigning it to + // mDataView. Make sure that determination of the highlight sequences are done only + // after number formatting. + + // Sets phone number texts for display after highlighting it, if applicable. + // CharSequence textToSet = text; + final SpannableString textToSet = new SpannableString(text); + + if (mNumberHighlightSequence.size() != 0) { + final HighlightSequence highlightSequence = mNumberHighlightSequence.get(0); + mTextHighlighter.applyMaskingHighlight( + textToSet, highlightSequence.start, highlightSequence.end); + } + + setMarqueeText(mDataView, textToSet); + mDataView.setVisibility(VISIBLE); + + // We have a phone number as "mDataView" so make it always LTR and VIEW_START + mDataView.setTextDirection(View.TEXT_DIRECTION_LTR); + mDataView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + } + } + + private void setMarqueeText(TextView textView, CharSequence text) { + if (getTextEllipsis() == TruncateAt.MARQUEE) { + // To show MARQUEE correctly (with END effect during non-active state), we need + // to build Spanned with MARQUEE in addition to TextView's ellipsize setting. + final SpannableString spannable = new SpannableString(text); + spannable.setSpan( + TruncateAt.MARQUEE, 0, spannable.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + textView.setText(spannable); + } else { + textView.setText(text); + } + } + + /** Returns the text view for the data text, creating it if necessary. */ + public TextView getDataView() { + if (mDataView == null) { + mDataView = new TextView(getContext()); + mDataView.setSingleLine(true); + mDataView.setEllipsize(getTextEllipsis()); + mDataView.setTextAppearance(getContext(), R.style.TextAppearanceSmall); + mDataView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + mDataView.setActivated(isActivated()); + mDataView.setId(R.id.cliv_data_view); + if (CompatUtils.isLollipopCompatible()) { + mDataView.setElegantTextHeight(false); + } + addView(mDataView); + } + return mDataView; + } + + /** Adds or updates a text view for the search snippet. */ + public void setSnippet(String text) { + if (TextUtils.isEmpty(text)) { + if (mSnippetView != null) { + mSnippetView.setVisibility(View.GONE); + } + } else { + mTextHighlighter.setPrefixText(getSnippetView(), text, mHighlightedPrefix); + mSnippetView.setVisibility(VISIBLE); + if (ContactDisplayUtils.isPossiblePhoneNumber(text)) { + // Give the text-to-speech engine a hint that it's a phone number + mSnippetView.setContentDescription(PhoneNumberUtilsCompat.createTtsSpannable(text)); + } else { + mSnippetView.setContentDescription(null); + } + } + } + + /** Returns the text view for the search snippet, creating it if necessary. */ + public TextView getSnippetView() { + if (mSnippetView == null) { + mSnippetView = new TextView(getContext()); + mSnippetView.setSingleLine(true); + mSnippetView.setEllipsize(getTextEllipsis()); + mSnippetView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small); + mSnippetView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + mSnippetView.setActivated(isActivated()); + addView(mSnippetView); + } + return mSnippetView; + } + + /** Returns the text view for the status, creating it if necessary. */ + public TextView getStatusView() { + if (mStatusView == null) { + mStatusView = new TextView(getContext()); + mStatusView.setSingleLine(true); + mStatusView.setEllipsize(getTextEllipsis()); + mStatusView.setTextAppearance(getContext(), android.R.style.TextAppearance_Small); + mStatusView.setTextColor(mSecondaryTextColor); + mStatusView.setActivated(isActivated()); + mStatusView.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + addView(mStatusView); + } + return mStatusView; + } + + /** Adds or updates a text view for the status. */ + public void setStatus(CharSequence text) { + if (TextUtils.isEmpty(text)) { + if (mStatusView != null) { + mStatusView.setVisibility(View.GONE); + } + } else { + getStatusView(); + setMarqueeText(mStatusView, text); + mStatusView.setVisibility(VISIBLE); + } + } + + /** Adds or updates the presence icon view. */ + public void setPresence(Drawable icon) { + if (icon != null) { + if (mPresenceIcon == null) { + mPresenceIcon = new ImageView(getContext()); + addView(mPresenceIcon); + } + mPresenceIcon.setImageDrawable(icon); + mPresenceIcon.setScaleType(ScaleType.CENTER); + mPresenceIcon.setVisibility(View.VISIBLE); + } else { + if (mPresenceIcon != null) { + mPresenceIcon.setVisibility(View.GONE); + } + } + } + + /** + * Set to display work profile icon or not + * + * @param enabled set to display work profile icon or not + */ + public void setWorkProfileIconEnabled(boolean enabled) { + if (mWorkProfileIcon != null) { + mWorkProfileIcon.setVisibility(enabled ? View.VISIBLE : View.GONE); + } else if (enabled) { + mWorkProfileIcon = new ImageView(getContext()); + addView(mWorkProfileIcon); + mWorkProfileIcon.setImageResource(R.drawable.ic_work_profile); + mWorkProfileIcon.setScaleType(ScaleType.CENTER_INSIDE); + mWorkProfileIcon.setVisibility(View.VISIBLE); + } + } + + private TruncateAt getTextEllipsis() { + return TruncateAt.MARQUEE; + } + + public void showDisplayName(Cursor cursor, int nameColumnIndex) { + CharSequence name = cursor.getString(nameColumnIndex); + setDisplayName(name); + + // Since the quick contact content description is derived from the display name and there is + // no guarantee that when the quick contact is initialized the display name is already set, + // do it here too. + if (mQuickContact != null) { + mQuickContact.setContentDescription( + getContext().getString(R.string.description_quick_contact_for, mNameTextView.getText())); + } + } + + public void setDisplayName(CharSequence name) { + if (!TextUtils.isEmpty(name)) { + // Chooses the available highlighting method for highlighting. + if (mHighlightedPrefix != null) { + name = mTextHighlighter.applyPrefixHighlight(name, mHighlightedPrefix); + } else if (mNameHighlightSequence.size() != 0) { + final SpannableString spannableName = new SpannableString(name); + for (HighlightSequence highlightSequence : mNameHighlightSequence) { + mTextHighlighter.applyMaskingHighlight( + spannableName, highlightSequence.start, highlightSequence.end); + } + name = spannableName; + } + } else { + name = mUnknownNameText; + } + setMarqueeText(getNameTextView(), name); + + if (ContactDisplayUtils.isPossiblePhoneNumber(name)) { + // Give the text-to-speech engine a hint that it's a phone number + mNameTextView.setTextDirection(View.TEXT_DIRECTION_LTR); + mNameTextView.setContentDescription( + PhoneNumberUtilsCompat.createTtsSpannable(name.toString())); + } else { + // Remove span tags of highlighting for talkback to avoid reading highlighting and rest + // of the name into two separate parts. + mNameTextView.setContentDescription(name.toString()); + } + } + + public void hideDisplayName() { + if (mNameTextView != null) { + removeView(mNameTextView); + mNameTextView = null; + } + } + + /** Sets the proper icon (star or presence or nothing) and/or status message. */ + public void showPresenceAndStatusMessage( + Cursor cursor, int presenceColumnIndex, int contactStatusColumnIndex) { + Drawable icon = null; + int presence = 0; + if (!cursor.isNull(presenceColumnIndex)) { + presence = cursor.getInt(presenceColumnIndex); + icon = ContactPresenceIconUtil.getPresenceIcon(getContext(), presence); + } + setPresence(icon); + + String statusMessage = null; + if (contactStatusColumnIndex != 0 && !cursor.isNull(contactStatusColumnIndex)) { + statusMessage = cursor.getString(contactStatusColumnIndex); + } + // If there is no status message from the contact, but there was a presence value, then use + // the default status message string + if (statusMessage == null && presence != 0) { + statusMessage = ContactStatusUtil.getStatusString(getContext(), presence); + } + setStatus(statusMessage); + } + + /** Shows search snippet. */ + public void showSnippet(Cursor cursor, int summarySnippetColumnIndex) { + if (cursor.getColumnCount() <= summarySnippetColumnIndex + || !SearchSnippets.SNIPPET.equals(cursor.getColumnName(summarySnippetColumnIndex))) { + setSnippet(null); + return; + } + + String snippet = cursor.getString(summarySnippetColumnIndex); + + // Do client side snippeting if provider didn't do it + final Bundle extras = cursor.getExtras(); + if (extras.getBoolean(ContactsContract.DEFERRED_SNIPPETING)) { + + final String query = extras.getString(ContactsContract.DEFERRED_SNIPPETING_QUERY); + + String displayName = null; + int displayNameIndex = cursor.getColumnIndex(Contacts.DISPLAY_NAME); + if (displayNameIndex >= 0) { + displayName = cursor.getString(displayNameIndex); + } + + snippet = updateSnippet(snippet, query, displayName); + + } else { + if (snippet != null) { + int from = 0; + int to = snippet.length(); + int start = snippet.indexOf(SNIPPET_START_MATCH); + if (start == -1) { + snippet = null; + } else { + int firstNl = snippet.lastIndexOf('\n', start); + if (firstNl != -1) { + from = firstNl + 1; + } + int end = snippet.lastIndexOf(SNIPPET_END_MATCH); + if (end != -1) { + int lastNl = snippet.indexOf('\n', end); + if (lastNl != -1) { + to = lastNl; + } + } + + StringBuilder sb = new StringBuilder(); + for (int i = from; i < to; i++) { + char c = snippet.charAt(i); + if (c != SNIPPET_START_MATCH && c != SNIPPET_END_MATCH) { + sb.append(c); + } + } + snippet = sb.toString(); + } + } + } + + setSnippet(snippet); + } + + /** + * Used for deferred snippets from the database. The contents come back as large strings which + * need to be extracted for display. + * + * @param snippet The snippet from the database. + * @param query The search query substring. + * @param displayName The contact display name. + * @return The proper snippet to display. + */ + private String updateSnippet(String snippet, String query, String displayName) { + + if (TextUtils.isEmpty(snippet) || TextUtils.isEmpty(query)) { + return null; + } + query = SearchUtil.cleanStartAndEndOfSearchQuery(query.toLowerCase()); + + // If the display name already contains the query term, return empty - snippets should + // not be needed in that case. + if (!TextUtils.isEmpty(displayName)) { + final String lowerDisplayName = displayName.toLowerCase(); + final List<String> nameTokens = split(lowerDisplayName); + for (String nameToken : nameTokens) { + if (nameToken.startsWith(query)) { + return null; + } + } + } + + // The snippet may contain multiple data lines. + // Show the first line that matches the query. + final SearchUtil.MatchedLine matched = SearchUtil.findMatchingLine(snippet, query); + + if (matched != null && matched.line != null) { + // Tokenize for long strings since the match may be at the end of it. + // Skip this part for short strings since the whole string will be displayed. + // Most contact strings are short so the snippetize method will be called infrequently. + final int lengthThreshold = + getResources().getInteger(R.integer.snippet_length_before_tokenize); + if (matched.line.length() > lengthThreshold) { + return snippetize(matched.line, matched.startIndex, lengthThreshold); + } else { + return matched.line; + } + } + + // No match found. + return null; + } + + private String snippetize(String line, int matchIndex, int maxLength) { + // Show up to maxLength characters. But we only show full tokens so show the last full token + // up to maxLength characters. So as many starting tokens as possible before trying ending + // tokens. + int remainingLength = maxLength; + int tempRemainingLength = remainingLength; + + // Start the end token after the matched query. + int index = matchIndex; + int endTokenIndex = index; + + // Find the match token first. + while (index < line.length()) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + endTokenIndex = index; + remainingLength = tempRemainingLength; + break; + } + tempRemainingLength--; + index++; + } + + // Find as much content before the match. + index = matchIndex - 1; + tempRemainingLength = remainingLength; + int startTokenIndex = matchIndex; + while (index > -1 && tempRemainingLength > 0) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + startTokenIndex = index; + remainingLength = tempRemainingLength; + } + tempRemainingLength--; + index--; + } + + index = endTokenIndex; + tempRemainingLength = remainingLength; + // Find remaining content at after match. + while (index < line.length() && tempRemainingLength > 0) { + if (!Character.isLetterOrDigit(line.charAt(index))) { + endTokenIndex = index; + } + tempRemainingLength--; + index++; + } + // Append ellipse if there is content before or after. + final StringBuilder sb = new StringBuilder(); + if (startTokenIndex > 0) { + sb.append("..."); + } + sb.append(line.substring(startTokenIndex, endTokenIndex)); + if (endTokenIndex < line.length()) { + sb.append("..."); + } + return sb.toString(); + } + + public void setActivatedStateSupported(boolean flag) { + this.mActivatedStateSupported = flag; + } + + public void setAdjustSelectionBoundsEnabled(boolean enabled) { + mAdjustSelectionBoundsEnabled = enabled; + } + + @Override + public void requestLayout() { + // We will assume that once measured this will not need to resize + // itself, so there is no need to pass the layout request to the parent + // view (ListView). + forceLayout(); + } + + public void setPhotoPosition(PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + } + + /** + * Set drawable resources directly for the drawable resource of the photo view. + * + * @param drawableId Id of drawable resource. + */ + public void setDrawableResource(int drawableId) { + ImageView photo = getPhotoView(); + photo.setScaleType(ImageView.ScaleType.CENTER); + final Drawable drawable = ContextCompat.getDrawable(getContext(), drawableId); + final int iconColor = ContextCompat.getColor(getContext(), R.color.search_shortcut_icon_color); + if (CompatUtils.isLollipopCompatible()) { + photo.setImageDrawable(drawable); + photo.setImageTintList(ColorStateList.valueOf(iconColor)); + } else { + final Drawable drawableWrapper = DrawableCompat.wrap(drawable).mutate(); + DrawableCompat.setTint(drawableWrapper, iconColor); + photo.setImageDrawable(drawableWrapper); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + final float x = event.getX(); + final float y = event.getY(); + // If the touch event's coordinates are not within the view's header, then delegate + // to super.onTouchEvent so that regular view behavior is preserved. Otherwise, consume + // and ignore the touch event. + if (mBoundsWithoutHeader.contains((int) x, (int) y) || !pointIsInView(x, y)) { + return super.onTouchEvent(event); + } else { + return true; + } + } + + private final boolean pointIsInView(float localX, float localY) { + return localX >= mLeftOffset + && localX < mRightOffset + && localY >= 0 + && localY < (getBottom() - getTop()); + } + + /** + * Where to put contact photo. This affects the other Views' layout or look-and-feel. + * + * <p>TODO: replace enum with int constants + */ + public enum PhotoPosition { + LEFT, + RIGHT + } + + protected static class HighlightSequence { + + private final int start; + private final int end; + + HighlightSequence(int start, int end) { + this.start = start; + this.end = end; + } + } +} diff --git a/java/com/android/contacts/common/list/ContactListPinnedHeaderView.java b/java/com/android/contacts/common/list/ContactListPinnedHeaderView.java new file mode 100644 index 000000000..1f3e2bfe3 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactListPinnedHeaderView.java @@ -0,0 +1,70 @@ +/* + * 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.res.TypedArray; +import android.graphics.Color; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; +import com.android.contacts.common.R; + +/** A custom view for the pinned section header shown at the top of the contact list. */ +public class ContactListPinnedHeaderView extends TextView { + + public ContactListPinnedHeaderView(Context context, AttributeSet attrs, View parent) { + super(context, attrs); + + if (R.styleable.ContactListItemView == null) { + return; + } + TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.ContactListItemView); + int backgroundColor = + a.getColor(R.styleable.ContactListItemView_list_item_background_color, Color.WHITE); + int textOffsetTop = + a.getDimensionPixelSize(R.styleable.ContactListItemView_list_item_text_offset_top, 0); + int paddingStartOffset = + a.getDimensionPixelSize(R.styleable.ContactListItemView_list_item_padding_left, 0); + int textWidth = getResources().getDimensionPixelSize(R.dimen.contact_list_section_header_width); + int widthIncludingPadding = paddingStartOffset + textWidth; + a.recycle(); + + setBackgroundColor(backgroundColor); + setTextAppearance(getContext(), R.style.SectionHeaderStyle); + setLayoutParams(new LayoutParams(textWidth, LayoutParams.WRAP_CONTENT)); + setLayoutDirection(parent.getLayoutDirection()); + setGravity(Gravity.CENTER_VERTICAL | Gravity.CENTER_HORIZONTAL); + + // Apply text top offset. Multiply by two, because we are implementing this by padding for a + // vertically centered view, rather than adjusting the position directly via a layout. + setPaddingRelative( + 0, getPaddingTop() + (textOffsetTop * 2), getPaddingEnd(), getPaddingBottom()); + } + + /** Sets section header or makes it invisible if the title is null. */ + public void setSectionHeaderTitle(String title) { + if (!TextUtils.isEmpty(title)) { + setText(title); + } else { + setVisibility(View.GONE); + } + } +} diff --git a/java/com/android/contacts/common/list/ContactTileView.java b/java/com/android/contacts/common/list/ContactTileView.java new file mode 100644 index 000000000..9273b0583 --- /dev/null +++ b/java/com/android/contacts/common/list/ContactTileView.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2011 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.graphics.Rect; +import android.net.Uri; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import com.android.contacts.common.ContactPhotoManager; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.MoreContactUtils; +import com.android.contacts.common.R; + +/** A ContactTile displays a contact's picture and name */ +public abstract class ContactTileView extends FrameLayout { + + private static final String TAG = ContactTileView.class.getSimpleName(); + protected Listener mListener; + private Uri mLookupUri; + private ImageView mPhoto; + private TextView mName; + private ContactPhotoManager mPhotoManager = null; + + public ContactTileView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mName = (TextView) findViewById(R.id.contact_tile_name); + mPhoto = (ImageView) findViewById(R.id.contact_tile_image); + + OnClickListener listener = createClickListener(); + setOnClickListener(listener); + } + + protected OnClickListener createClickListener() { + return new OnClickListener() { + @Override + public void onClick(View v) { + if (mListener == null) { + return; + } + mListener.onContactSelected( + getLookupUri(), MoreContactUtils.getTargetRectFromView(ContactTileView.this)); + } + }; + } + + public void setPhotoManager(ContactPhotoManager photoManager) { + mPhotoManager = photoManager; + } + + /** + * Populates the data members to be displayed from the fields in {@link + * com.android.contacts.common.list.ContactEntry} + */ + public void loadFromContact(ContactEntry entry) { + + if (entry != null) { + mName.setText(getNameForView(entry)); + mLookupUri = entry.lookupUri; + + setVisibility(View.VISIBLE); + + if (mPhotoManager != null) { + DefaultImageRequest request = getDefaultImageRequest(entry.namePrimary, entry.lookupKey); + configureViewForImage(entry.photoUri == null); + if (mPhoto != null) { + mPhotoManager.loadPhoto( + mPhoto, + entry.photoUri, + getApproximateImageSize(), + isDarkTheme(), + isContactPhotoCircular(), + request); + + + } + } else { + Log.w(TAG, "contactPhotoManager not set"); + } + } else { + setVisibility(View.INVISIBLE); + } + } + + public void setListener(Listener listener) { + mListener = listener; + } + + public Uri getLookupUri() { + return mLookupUri; + } + + /** + * Returns the string that should actually be displayed as the contact's name. Subclasses can + * override this to return formatted versions of the name - i.e. first name only. + */ + protected String getNameForView(ContactEntry contactEntry) { + return contactEntry.namePrimary; + } + + /** + * Implemented by subclasses to estimate the size of the picture. This can return -1 if only a + * thumbnail is shown anyway + */ + protected abstract int getApproximateImageSize(); + + protected abstract boolean isDarkTheme(); + + /** + * Implemented by subclasses to reconfigure the view's layout and subviews, based on whether or + * not the contact has a user-defined photo. + * + * @param isDefaultImage True if the contact does not have a user-defined contact photo (which + * means a default contact image will be applied by the {@link ContactPhotoManager} + */ + protected void configureViewForImage(boolean isDefaultImage) { + // No-op by default. + } + + /** + * Implemented by subclasses to allow them to return a {@link DefaultImageRequest} with the + * various image parameters defined to match their own layouts. + * + * @param displayName The display name of the contact + * @param lookupKey The lookup key of the contact + * @return A {@link DefaultImageRequest} object with each field configured by the subclass as + * desired, or {@code null}. + */ + protected DefaultImageRequest getDefaultImageRequest(String displayName, String lookupKey) { + return new DefaultImageRequest(displayName, lookupKey, isContactPhotoCircular()); + } + + /** + * Whether contact photo should be displayed as a circular image. Implemented by subclasses so + * they can change which drawables to fetch. + */ + protected boolean isContactPhotoCircular() { + return true; + } + + public interface Listener { + + /** Notification that the contact was selected; no specific action is dictated. */ + void onContactSelected(Uri contactLookupUri, Rect viewRect); + + /** Notification that the specified number is to be called. */ + void onCallNumberDirectly(String phoneNumber); + } +} diff --git a/java/com/android/contacts/common/list/ContactsSectionIndexer.java b/java/com/android/contacts/common/list/ContactsSectionIndexer.java new file mode 100644 index 000000000..3f0f2b7ee --- /dev/null +++ b/java/com/android/contacts/common/list/ContactsSectionIndexer.java @@ -0,0 +1,119 @@ +/* + * 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.text.TextUtils; +import android.widget.SectionIndexer; +import java.util.Arrays; + +/** + * A section indexer that is configured with precomputed section titles and their respective counts. + */ +public class ContactsSectionIndexer implements SectionIndexer { + + private static final String BLANK_HEADER_STRING = " "; + private String[] mSections; + private int[] mPositions; + private int mCount; + + /** + * Constructor. + * + * @param sections a non-null array + * @param counts a non-null array of the same size as <code>sections</code> + */ + public ContactsSectionIndexer(String[] sections, int[] counts) { + if (sections == null || counts == null) { + throw new NullPointerException(); + } + + if (sections.length != counts.length) { + throw new IllegalArgumentException( + "The sections and counts arrays must have the same length"); + } + + // TODO process sections/counts based on current locale and/or specific section titles + + this.mSections = sections; + mPositions = new int[counts.length]; + int position = 0; + for (int i = 0; i < counts.length; i++) { + if (TextUtils.isEmpty(mSections[i])) { + mSections[i] = BLANK_HEADER_STRING; + } else if (!mSections[i].equals(BLANK_HEADER_STRING)) { + mSections[i] = mSections[i].trim(); + } + + mPositions[i] = position; + position += counts[i]; + } + mCount = position; + } + + public Object[] getSections() { + return mSections; + } + + public int getPositionForSection(int section) { + if (section < 0 || section >= mSections.length) { + return -1; + } + + return mPositions[section]; + } + + public int getSectionForPosition(int position) { + if (position < 0 || position >= mCount) { + return -1; + } + + int index = Arrays.binarySearch(mPositions, position); + + /* + * Consider this example: section positions are 0, 3, 5; the supplied + * position is 4. The section corresponding to position 4 starts at + * position 3, so the expected return value is 1. Binary search will not + * find 4 in the array and thus will return -insertPosition-1, i.e. -3. + * To get from that number to the expected value of 1 we need to negate + * and subtract 2. + */ + return index >= 0 ? index : -index - 2; + } + + public void setProfileAndFavoritesHeader(String header, int numberOfItemsToAdd) { + if (mSections != null) { + // Don't do anything if the header is already set properly. + if (mSections.length > 0 && header.equals(mSections[0])) { + return; + } + + // Since the section indexer isn't aware of the profile at the top, we need to add a + // special section at the top for it and shift everything else down. + String[] tempSections = new String[mSections.length + 1]; + int[] tempPositions = new int[mPositions.length + 1]; + tempSections[0] = header; + tempPositions[0] = 0; + for (int i = 1; i <= mPositions.length; i++) { + tempSections[i] = mSections[i - 1]; + tempPositions[i] = mPositions[i - 1] + numberOfItemsToAdd; + } + mSections = tempSections; + mPositions = tempPositions; + mCount = mCount + numberOfItemsToAdd; + } + } +} diff --git a/java/com/android/contacts/common/list/DefaultContactListAdapter.java b/java/com/android/contacts/common/list/DefaultContactListAdapter.java new file mode 100644 index 000000000..7bcae0e0e --- /dev/null +++ b/java/com/android/contacts/common/list/DefaultContactListAdapter.java @@ -0,0 +1,216 @@ +/* + * 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.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.preference.PreferenceManager; +import android.provider.ContactsContract; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.provider.ContactsContract.SearchSnippets; +import android.text.TextUtils; +import android.view.View; +import com.android.contacts.common.compat.ContactsCompat; +import com.android.contacts.common.preference.ContactsPreferences; +import java.util.ArrayList; +import java.util.List; + +/** A cursor adapter for the {@link ContactsContract.Contacts#CONTENT_TYPE} content type. */ +public class DefaultContactListAdapter extends ContactListAdapter { + + public DefaultContactListAdapter(Context context) { + super(context); + } + + @Override + public void configureLoader(CursorLoader loader, long directoryId) { + String sortOrder = null; + if (isSearchMode()) { + String query = getQueryString(); + if (query == null) { + query = ""; + } + query = query.trim(); + if (TextUtils.isEmpty(query)) { + // Regardless of the directory, we don't want anything returned, + // so let's just send a "nothing" query to the local directory. + loader.setUri(Contacts.CONTENT_URI); + loader.setProjection(getProjection(false)); + loader.setSelection("0"); + } else { + final Builder builder = ContactsCompat.getContentUri().buildUpon(); + appendSearchParameters(builder, query, directoryId); + loader.setUri(builder.build()); + loader.setProjection(getProjection(true)); + } + } else { + final ContactListFilter filter = getFilter(); + configureUri(loader, directoryId, filter); + loader.setProjection(getProjection(false)); + configureSelection(loader, directoryId, filter); + } + + if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) { + if (sortOrder == null) { + sortOrder = Contacts.SORT_KEY_PRIMARY; + } else { + sortOrder += ", " + Contacts.SORT_KEY_PRIMARY; + } + } else { + if (sortOrder == null) { + sortOrder = Contacts.SORT_KEY_ALTERNATIVE; + } else { + sortOrder += ", " + Contacts.SORT_KEY_ALTERNATIVE; + } + } + loader.setSortOrder(sortOrder); + } + + private void appendSearchParameters(Builder builder, String query, long directoryId) { + builder.appendPath(query); // Builder will encode the query + builder.appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); + if (directoryId != Directory.DEFAULT && directoryId != Directory.LOCAL_INVISIBLE) { + builder.appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId)))); + } + builder.appendQueryParameter(SearchSnippets.DEFERRED_SNIPPETING_KEY, "1"); + } + + protected void configureUri(CursorLoader loader, long directoryId, ContactListFilter filter) { + Uri uri = Contacts.CONTENT_URI; + + if (directoryId == Directory.DEFAULT && isSectionHeaderDisplayEnabled()) { + uri = ContactListAdapter.buildSectionIndexerUri(uri); + } + + // The "All accounts" filter is the same as the entire contents of Directory.DEFAULT + if (filter != null + && filter.filterType != ContactListFilter.FILTER_TYPE_CUSTOM + && filter.filterType != ContactListFilter.FILTER_TYPE_SINGLE_CONTACT) { + final Uri.Builder builder = uri.buildUpon(); + builder.appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)); + if (filter.filterType == ContactListFilter.FILTER_TYPE_ACCOUNT) { + filter.addAccountQueryParameterToUrl(builder); + } + uri = builder.build(); + } + + loader.setUri(uri); + } + + private void configureSelection(CursorLoader loader, long directoryId, ContactListFilter filter) { + if (filter == null) { + return; + } + + if (directoryId != Directory.DEFAULT) { + return; + } + + StringBuilder selection = new StringBuilder(); + List<String> selectionArgs = new ArrayList<String>(); + + switch (filter.filterType) { + case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: + { + // We have already added directory=0 to the URI, which takes care of this + // filter + break; + } + case ContactListFilter.FILTER_TYPE_SINGLE_CONTACT: + { + // We have already added the lookup key to the URI, which takes care of this + // filter + break; + } + case ContactListFilter.FILTER_TYPE_STARRED: + { + selection.append(Contacts.STARRED + "!=0"); + break; + } + case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + { + selection.append(Contacts.HAS_PHONE_NUMBER + "=1"); + break; + } + case ContactListFilter.FILTER_TYPE_CUSTOM: + { + selection.append(Contacts.IN_VISIBLE_GROUP + "=1"); + if (isCustomFilterForPhoneNumbersOnly()) { + selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1"); + } + break; + } + case ContactListFilter.FILTER_TYPE_ACCOUNT: + { + // We use query parameters for account filter, so no selection to add here. + break; + } + } + loader.setSelection(selection.toString()); + loader.setSelectionArgs(selectionArgs.toArray(new String[0])); + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + final ContactListItemView view = (ContactListItemView) itemView; + + view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null); + + bindSectionHeaderAndDivider(view, position, cursor); + + if (isQuickContactEnabled()) { + bindQuickContact( + view, + partition, + cursor, + ContactQuery.CONTACT_PHOTO_ID, + ContactQuery.CONTACT_PHOTO_URI, + ContactQuery.CONTACT_ID, + ContactQuery.CONTACT_LOOKUP_KEY, + ContactQuery.CONTACT_DISPLAY_NAME); + } else { + if (getDisplayPhotos()) { + bindPhoto(view, partition, cursor); + } + } + + bindNameAndViewId(view, cursor); + bindPresenceAndStatusMessage(view, cursor); + + if (isSearchMode()) { + bindSearchSnippet(view, cursor); + } else { + view.setSnippet(null); + } + } + + private boolean isCustomFilterForPhoneNumbersOnly() { + // TODO: this flag should not be stored in shared prefs. It needs to be in the db. + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + return prefs.getBoolean( + ContactsPreferences.PREF_DISPLAY_ONLY_PHONES, + ContactsPreferences.PREF_DISPLAY_ONLY_PHONES_DEFAULT); + } +} diff --git a/java/com/android/contacts/common/list/DirectoryListLoader.java b/java/com/android/contacts/common/list/DirectoryListLoader.java new file mode 100644 index 000000000..48b098c07 --- /dev/null +++ b/java/com/android/contacts/common/list/DirectoryListLoader.java @@ -0,0 +1,201 @@ +/* + * 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.AsyncTaskLoader; +import android.content.Context; +import android.content.pm.PackageManager; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Handler; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.DirectoryCompat; + +/** A specialized loader for the list of directories, see {@link Directory}. */ +public class DirectoryListLoader extends AsyncTaskLoader<Cursor> { + + public static final int SEARCH_MODE_NONE = 0; + public static final int SEARCH_MODE_DEFAULT = 1; + public static final int SEARCH_MODE_CONTACT_SHORTCUT = 2; + public static final int SEARCH_MODE_DATA_SHORTCUT = 3; + // This is a virtual column created for a MatrixCursor. + public static final String DIRECTORY_TYPE = "directoryType"; + private static final String TAG = "ContactEntryListAdapter"; + private static final String[] RESULT_PROJECTION = { + Directory._ID, DIRECTORY_TYPE, Directory.DISPLAY_NAME, Directory.PHOTO_SUPPORT, + }; + private final ContentObserver mObserver = + new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + forceLoad(); + } + }; + private int mDirectorySearchMode; + private boolean mLocalInvisibleDirectoryEnabled; + private MatrixCursor mDefaultDirectoryList; + + public DirectoryListLoader(Context context) { + super(context); + } + + public void setDirectorySearchMode(int mode) { + mDirectorySearchMode = mode; + } + + /** + * A flag that indicates whether the {@link Directory#LOCAL_INVISIBLE} directory should be + * included in the results. + */ + public void setLocalInvisibleDirectoryEnabled(boolean flag) { + this.mLocalInvisibleDirectoryEnabled = flag; + } + + @Override + protected void onStartLoading() { + getContext().getContentResolver().registerContentObserver(DirectoryQuery.URI, false, mObserver); + forceLoad(); + } + + @Override + protected void onStopLoading() { + getContext().getContentResolver().unregisterContentObserver(mObserver); + } + + @Override + public Cursor loadInBackground() { + if (mDirectorySearchMode == SEARCH_MODE_NONE) { + return getDefaultDirectories(); + } + + MatrixCursor result = new MatrixCursor(RESULT_PROJECTION); + Context context = getContext(); + PackageManager pm = context.getPackageManager(); + String selection; + switch (mDirectorySearchMode) { + case SEARCH_MODE_DEFAULT: + selection = null; + break; + + case SEARCH_MODE_CONTACT_SHORTCUT: + selection = Directory.SHORTCUT_SUPPORT + "=" + Directory.SHORTCUT_SUPPORT_FULL; + break; + + case SEARCH_MODE_DATA_SHORTCUT: + selection = + Directory.SHORTCUT_SUPPORT + + " IN (" + + Directory.SHORTCUT_SUPPORT_FULL + + ", " + + Directory.SHORTCUT_SUPPORT_DATA_ITEMS_ONLY + + ")"; + break; + + default: + throw new RuntimeException("Unsupported directory search mode: " + mDirectorySearchMode); + } + Cursor cursor = null; + try { + cursor = + context + .getContentResolver() + .query( + DirectoryQuery.URI, + DirectoryQuery.PROJECTION, + selection, + null, + DirectoryQuery.ORDER_BY); + + if (cursor == null) { + return result; + } + + while (cursor.moveToNext()) { + long directoryId = cursor.getLong(DirectoryQuery.ID); + if (!mLocalInvisibleDirectoryEnabled && DirectoryCompat.isInvisibleDirectory(directoryId)) { + continue; + } + String directoryType = null; + + String packageName = cursor.getString(DirectoryQuery.PACKAGE_NAME); + int typeResourceId = cursor.getInt(DirectoryQuery.TYPE_RESOURCE_ID); + if (!TextUtils.isEmpty(packageName) && typeResourceId != 0) { + try { + directoryType = pm.getResourcesForApplication(packageName).getString(typeResourceId); + } catch (Exception e) { + Log.e(TAG, "Cannot obtain directory type from package: " + packageName); + } + } + String displayName = cursor.getString(DirectoryQuery.DISPLAY_NAME); + int photoSupport = cursor.getInt(DirectoryQuery.PHOTO_SUPPORT); + result.addRow(new Object[] {directoryId, directoryType, displayName, photoSupport}); + } + } catch (RuntimeException e) { + Log.w(TAG, "Runtime Exception when querying directory"); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return result; + } + + private Cursor getDefaultDirectories() { + if (mDefaultDirectoryList == null) { + mDefaultDirectoryList = new MatrixCursor(RESULT_PROJECTION); + mDefaultDirectoryList.addRow( + new Object[] {Directory.DEFAULT, getContext().getString(R.string.contactsList), null}); + mDefaultDirectoryList.addRow( + new Object[] { + Directory.LOCAL_INVISIBLE, + getContext().getString(R.string.local_invisible_directory), + null + }); + } + return mDefaultDirectoryList; + } + + @Override + protected void onReset() { + stopLoading(); + } + + private static final class DirectoryQuery { + + public static final Uri URI = DirectoryCompat.getContentUri(); + public static final String ORDER_BY = Directory._ID; + + public static final String[] PROJECTION = { + Directory._ID, + Directory.PACKAGE_NAME, + Directory.TYPE_RESOURCE_ID, + Directory.DISPLAY_NAME, + Directory.PHOTO_SUPPORT, + }; + + public static final int ID = 0; + public static final int PACKAGE_NAME = 1; + public static final int TYPE_RESOURCE_ID = 2; + public static final int DISPLAY_NAME = 3; + public static final int PHOTO_SUPPORT = 4; + } +} diff --git a/java/com/android/contacts/common/list/DirectoryPartition.java b/java/com/android/contacts/common/list/DirectoryPartition.java new file mode 100644 index 000000000..26b851041 --- /dev/null +++ b/java/com/android/contacts/common/list/DirectoryPartition.java @@ -0,0 +1,179 @@ +/* + * 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.provider.ContactsContract.Directory; +import com.android.common.widget.CompositeCursorAdapter; + +/** Model object for a {@link Directory} row. */ +public final class DirectoryPartition extends CompositeCursorAdapter.Partition { + + public static final int STATUS_NOT_LOADED = 0; + public static final int STATUS_LOADING = 1; + public static final int STATUS_LOADED = 2; + + public static final int RESULT_LIMIT_DEFAULT = -1; + + private long mDirectoryId; + private String mContentUri; + private String mDirectoryType; + private String mDisplayName; + private int mStatus; + private boolean mPriorityDirectory; + private boolean mPhotoSupported; + private int mResultLimit = RESULT_LIMIT_DEFAULT; + private boolean mDisplayNumber = true; + + private String mLabel; + + public DirectoryPartition(boolean showIfEmpty, boolean hasHeader) { + super(showIfEmpty, hasHeader); + } + + /** Directory ID, see {@link Directory}. */ + public long getDirectoryId() { + return mDirectoryId; + } + + public void setDirectoryId(long directoryId) { + this.mDirectoryId = directoryId; + } + + /** + * Directory type resolved from {@link Directory#PACKAGE_NAME} and {@link + * Directory#TYPE_RESOURCE_ID}; + */ + public String getDirectoryType() { + return mDirectoryType; + } + + public void setDirectoryType(String directoryType) { + this.mDirectoryType = directoryType; + } + + /** See {@link Directory#DISPLAY_NAME}. */ + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + this.mDisplayName = displayName; + } + + public int getStatus() { + return mStatus; + } + + public void setStatus(int status) { + mStatus = status; + } + + public boolean isLoading() { + return mStatus == STATUS_NOT_LOADED || mStatus == STATUS_LOADING; + } + + /** Returns true if this directory should be loaded before non-priority directories. */ + public boolean isPriorityDirectory() { + return mPriorityDirectory; + } + + public void setPriorityDirectory(boolean priorityDirectory) { + mPriorityDirectory = priorityDirectory; + } + + /** Returns true if this directory supports photos. */ + public boolean isPhotoSupported() { + return mPhotoSupported; + } + + public void setPhotoSupported(boolean flag) { + this.mPhotoSupported = flag; + } + + /** + * Max number of results for this directory. Defaults to {@link #RESULT_LIMIT_DEFAULT} which + * implies using the adapter's {@link + * com.android.contacts.common.list.ContactListAdapter#getDirectoryResultLimit()} + */ + public int getResultLimit() { + return mResultLimit; + } + + public void setResultLimit(int resultLimit) { + mResultLimit = resultLimit; + } + + /** + * Used by extended directories to specify a custom content URI. Extended directories MUST have a + * content URI + */ + public String getContentUri() { + return mContentUri; + } + + public void setContentUri(String contentUri) { + mContentUri = contentUri; + } + + /** A label to display in the header next to the display name. */ + public String getLabel() { + return mLabel; + } + + public void setLabel(String label) { + mLabel = label; + } + + @Override + public String toString() { + return "DirectoryPartition{" + + "mDirectoryId=" + + mDirectoryId + + ", mContentUri='" + + mContentUri + + '\'' + + ", mDirectoryType='" + + mDirectoryType + + '\'' + + ", mDisplayName='" + + mDisplayName + + '\'' + + ", mStatus=" + + mStatus + + ", mPriorityDirectory=" + + mPriorityDirectory + + ", mPhotoSupported=" + + mPhotoSupported + + ", mResultLimit=" + + mResultLimit + + ", mLabel='" + + mLabel + + '\'' + + '}'; + } + + /** + * Whether or not to display the phone number in app that have that option - Dialer. If false, + * Phone Label should be used instead of Phone Number. + */ + public boolean isDisplayNumber() { + return mDisplayNumber; + } + + public void setDisplayNumber(boolean displayNumber) { + mDisplayNumber = displayNumber; + } +} diff --git a/java/com/android/contacts/common/list/IndexerListAdapter.java b/java/com/android/contacts/common/list/IndexerListAdapter.java new file mode 100644 index 000000000..2289f6e59 --- /dev/null +++ b/java/com/android/contacts/common/list/IndexerListAdapter.java @@ -0,0 +1,214 @@ +/* + * 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.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import android.widget.SectionIndexer; + +/** A list adapter that supports section indexer and a pinned header. */ +public abstract class IndexerListAdapter extends PinnedHeaderListAdapter implements SectionIndexer { + + protected Context mContext; + private SectionIndexer mIndexer; + private int mIndexedPartition = 0; + private boolean mSectionHeaderDisplayEnabled; + private View mHeader; + private Placement mPlacementCache = new Placement(); + + /** Constructor. */ + public IndexerListAdapter(Context context) { + super(context); + mContext = context; + } + + /** + * Creates a section header view that will be pinned at the top of the list as the user scrolls. + */ + protected abstract View createPinnedSectionHeaderView(Context context, ViewGroup parent); + + /** Sets the title in the pinned header as the user scrolls. */ + protected abstract void setPinnedSectionTitle(View pinnedHeaderView, String title); + + public boolean isSectionHeaderDisplayEnabled() { + return mSectionHeaderDisplayEnabled; + } + + public void setSectionHeaderDisplayEnabled(boolean flag) { + this.mSectionHeaderDisplayEnabled = flag; + } + + public int getIndexedPartition() { + return mIndexedPartition; + } + + public void setIndexedPartition(int partition) { + this.mIndexedPartition = partition; + } + + public SectionIndexer getIndexer() { + return mIndexer; + } + + public void setIndexer(SectionIndexer indexer) { + mIndexer = indexer; + mPlacementCache.invalidate(); + } + + public Object[] getSections() { + if (mIndexer == null) { + return new String[] {" "}; + } else { + return mIndexer.getSections(); + } + } + + /** @return relative position of the section in the indexed partition */ + public int getPositionForSection(int sectionIndex) { + if (mIndexer == null) { + return -1; + } + + return mIndexer.getPositionForSection(sectionIndex); + } + + /** @param position relative position in the indexed partition */ + public int getSectionForPosition(int position) { + if (mIndexer == null) { + return -1; + } + + return mIndexer.getSectionForPosition(position); + } + + @Override + public int getPinnedHeaderCount() { + if (isSectionHeaderDisplayEnabled()) { + return super.getPinnedHeaderCount() + 1; + } else { + return super.getPinnedHeaderCount(); + } + } + + @Override + public View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent) { + if (isSectionHeaderDisplayEnabled() && viewIndex == getPinnedHeaderCount() - 1) { + if (mHeader == null) { + mHeader = createPinnedSectionHeaderView(mContext, parent); + } + return mHeader; + } else { + return super.getPinnedHeaderView(viewIndex, convertView, parent); + } + } + + @Override + public void configurePinnedHeaders(PinnedHeaderListView listView) { + super.configurePinnedHeaders(listView); + + if (!isSectionHeaderDisplayEnabled()) { + return; + } + + int index = getPinnedHeaderCount() - 1; + if (mIndexer == null || getCount() == 0) { + listView.setHeaderInvisible(index, false); + } else { + int listPosition = listView.getPositionAt(listView.getTotalTopPinnedHeaderHeight()); + int position = listPosition - listView.getHeaderViewsCount(); + + int section = -1; + int partition = getPartitionForPosition(position); + if (partition == mIndexedPartition) { + int offset = getOffsetInPartition(position); + if (offset != -1) { + section = getSectionForPosition(offset); + } + } + + if (section == -1) { + listView.setHeaderInvisible(index, false); + } else { + View topChild = listView.getChildAt(listPosition); + if (topChild != null) { + // Match the pinned header's height to the height of the list item. + mHeader.setMinimumHeight(topChild.getMeasuredHeight()); + } + setPinnedSectionTitle(mHeader, (String) mIndexer.getSections()[section]); + + // Compute the item position where the current partition begins + int partitionStart = getPositionForPartition(mIndexedPartition); + if (hasHeader(mIndexedPartition)) { + partitionStart++; + } + + // Compute the item position where the next section begins + int nextSectionPosition = partitionStart + getPositionForSection(section + 1); + boolean isLastInSection = position == nextSectionPosition - 1; + listView.setFadingHeader(index, listPosition, isLastInSection); + } + } + } + + /** + * Computes the item's placement within its section and populates the {@code placement} object + * accordingly. Please note that the returned object is volatile and should be copied if the + * result needs to be used later. + */ + public Placement getItemPlacementInSection(int position) { + if (mPlacementCache.position == position) { + return mPlacementCache; + } + + mPlacementCache.position = position; + if (isSectionHeaderDisplayEnabled()) { + int section = getSectionForPosition(position); + if (section != -1 && getPositionForSection(section) == position) { + mPlacementCache.firstInSection = true; + mPlacementCache.sectionHeader = (String) getSections()[section]; + } else { + mPlacementCache.firstInSection = false; + mPlacementCache.sectionHeader = null; + } + + mPlacementCache.lastInSection = (getPositionForSection(section + 1) - 1 == position); + } else { + mPlacementCache.firstInSection = false; + mPlacementCache.lastInSection = false; + mPlacementCache.sectionHeader = null; + } + return mPlacementCache; + } + + /** + * An item view is displayed differently depending on whether it is placed at the beginning, + * middle or end of a section. It also needs to know the section header when it is at the + * beginning of a section. This object captures all this configuration. + */ + public static final class Placement { + + public boolean firstInSection; + public boolean lastInSection; + public String sectionHeader; + private int position = ListView.INVALID_POSITION; + + public void invalidate() { + position = ListView.INVALID_POSITION; + } + } +} diff --git a/java/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java b/java/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java new file mode 100644 index 000000000..89bd889e6 --- /dev/null +++ b/java/com/android/contacts/common/list/OnPhoneNumberPickerActionListener.java @@ -0,0 +1,39 @@ +/* + * 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.app.ActionBar; +import android.net.Uri; +import com.android.dialer.callintent.nano.CallSpecificAppData; + +/** Action callbacks that can be sent by a phone number picker. */ +public interface OnPhoneNumberPickerActionListener { + + int CALL_INITIATION_UNKNOWN = 0; + + /** Returns the selected phone number uri to the requester. */ + void onPickDataUri(Uri dataUri, boolean isVideoCall, CallSpecificAppData callSpecificAppData); + + /** + * Returns the specified phone number to the requester. May call the specified phone number, + * either as an audio or video call. + */ + void onPickPhoneNumber( + String phoneNumber, boolean isVideoCall, CallSpecificAppData callSpecificAppData); + + /** Called when home menu in {@link ActionBar} is clicked by the user. */ + void onHomeInActionBarSelected(); +} diff --git a/java/com/android/contacts/common/list/PhoneNumberListAdapter.java b/java/com/android/contacts/common/list/PhoneNumberListAdapter.java new file mode 100644 index 000000000..c7b24229f --- /dev/null +++ b/java/com/android/contacts/common/list/PhoneNumberListAdapter.java @@ -0,0 +1,583 @@ +/* + * 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.database.Cursor; +import android.net.Uri; +import android.net.Uri.Builder; +import android.provider.ContactsContract; +import android.provider.ContactsContract.CommonDataKinds.Callable; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.provider.ContactsContract.CommonDataKinds.SipAddress; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Directory; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.ContactPhotoManager.DefaultImageRequest; +import com.android.contacts.common.ContactsUtils; +import com.android.contacts.common.GeoUtil; +import com.android.contacts.common.R; +import com.android.contacts.common.compat.CallableCompat; +import com.android.contacts.common.compat.DirectoryCompat; +import com.android.contacts.common.compat.PhoneCompat; +import com.android.contacts.common.extensions.PhoneDirectoryExtenderAccessor; +import com.android.contacts.common.preference.ContactsPreferences; +import com.android.contacts.common.util.Constants; +import com.android.dialer.compat.CompatUtils; +import com.android.dialer.util.CallUtil; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A cursor adapter for the {@link Phone#CONTENT_ITEM_TYPE} and {@link + * SipAddress#CONTENT_ITEM_TYPE}. + * + * <p>By default this adapter just handles phone numbers. When {@link #setUseCallableUri(boolean)} + * is called with "true", this adapter starts handling SIP addresses too, by using {@link Callable} + * API instead of {@link Phone}. + */ +public class PhoneNumberListAdapter extends ContactEntryListAdapter { + + private static final String TAG = PhoneNumberListAdapter.class.getSimpleName(); + private static final String IGNORE_NUMBER_TOO_LONG_CLAUSE = "length(" + Phone.NUMBER + ") < 1000"; + // A list of extended directories to add to the directories from the database + private final List<DirectoryPartition> mExtendedDirectories; + private final CharSequence mUnknownNameText; + // Extended directories will have ID's that are higher than any of the id's from the database, + // so that we can identify them and set them up properly. If no extended directories + // exist, this will be Long.MAX_VALUE + private long mFirstExtendedDirectoryId = Long.MAX_VALUE; + private ContactListItemView.PhotoPosition mPhotoPosition; + private boolean mUseCallableUri; + private Listener mListener; + private boolean mIsVideoEnabled; + private boolean mIsPresenceEnabled; + + public PhoneNumberListAdapter(Context context) { + super(context); + setDefaultFilterHeaderText(R.string.list_filter_phones); + mUnknownNameText = context.getText(android.R.string.unknownName); + + mExtendedDirectories = + PhoneDirectoryExtenderAccessor.get(mContext).getExtendedDirectories(mContext); + + int videoCapabilities = CallUtil.getVideoCallingAvailability(context); + mIsVideoEnabled = (videoCapabilities & CallUtil.VIDEO_CALLING_ENABLED) != 0; + mIsPresenceEnabled = (videoCapabilities & CallUtil.VIDEO_CALLING_PRESENCE) != 0; + } + + @Override + public void configureLoader(CursorLoader loader, long directoryId) { + String query = getQueryString(); + if (query == null) { + query = ""; + } + if (isExtendedDirectory(directoryId)) { + final DirectoryPartition directory = getExtendedDirectoryFromId(directoryId); + final String contentUri = directory.getContentUri(); + if (contentUri == null) { + throw new IllegalStateException("Extended directory must have a content URL: " + directory); + } + final Builder builder = Uri.parse(contentUri).buildUpon(); + builder.appendPath(query); + builder.appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, String.valueOf(getDirectoryResultLimit(directory))); + loader.setUri(builder.build()); + loader.setProjection(PhoneQuery.PROJECTION_PRIMARY); + } else { + final boolean isRemoteDirectoryQuery = DirectoryCompat.isRemoteDirectoryId(directoryId); + final Builder builder; + if (isSearchMode()) { + final Uri baseUri; + if (isRemoteDirectoryQuery) { + baseUri = PhoneCompat.getContentFilterUri(); + } else if (mUseCallableUri) { + baseUri = CallableCompat.getContentFilterUri(); + } else { + baseUri = PhoneCompat.getContentFilterUri(); + } + builder = baseUri.buildUpon(); + builder.appendPath(query); // Builder will encode the query + builder.appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)); + if (isRemoteDirectoryQuery) { + builder.appendQueryParameter( + ContactsContract.LIMIT_PARAM_KEY, + String.valueOf(getDirectoryResultLimit(getDirectoryById(directoryId)))); + } + } else { + Uri baseUri = mUseCallableUri ? Callable.CONTENT_URI : Phone.CONTENT_URI; + builder = + baseUri + .buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(Directory.DEFAULT)); + if (isSectionHeaderDisplayEnabled()) { + builder.appendQueryParameter(Phone.EXTRA_ADDRESS_BOOK_INDEX, "true"); + } + applyFilter(loader, builder, directoryId, getFilter()); + } + + // Ignore invalid phone numbers that are too long. These can potentially cause freezes + // in the UI and there is no reason to display them. + final String prevSelection = loader.getSelection(); + final String newSelection; + if (!TextUtils.isEmpty(prevSelection)) { + newSelection = prevSelection + " AND " + IGNORE_NUMBER_TOO_LONG_CLAUSE; + } else { + newSelection = IGNORE_NUMBER_TOO_LONG_CLAUSE; + } + loader.setSelection(newSelection); + + // Remove duplicates when it is possible. + builder.appendQueryParameter(ContactsContract.REMOVE_DUPLICATE_ENTRIES, "true"); + loader.setUri(builder.build()); + + // TODO a projection that includes the search snippet + if (getContactNameDisplayOrder() == ContactsPreferences.DISPLAY_ORDER_PRIMARY) { + loader.setProjection(PhoneQuery.PROJECTION_PRIMARY); + } else { + loader.setProjection(PhoneQuery.PROJECTION_ALTERNATIVE); + } + + if (getSortOrder() == ContactsPreferences.SORT_ORDER_PRIMARY) { + loader.setSortOrder(Phone.SORT_KEY_PRIMARY); + } else { + loader.setSortOrder(Phone.SORT_KEY_ALTERNATIVE); + } + } + } + + protected boolean isExtendedDirectory(long directoryId) { + return directoryId >= mFirstExtendedDirectoryId; + } + + private DirectoryPartition getExtendedDirectoryFromId(long directoryId) { + final int directoryIndex = (int) (directoryId - mFirstExtendedDirectoryId); + return mExtendedDirectories.get(directoryIndex); + } + + /** + * Configure {@code loader} and {@code uriBuilder} according to {@code directoryId} and {@code + * filter}. + */ + private void applyFilter( + CursorLoader loader, Uri.Builder uriBuilder, long directoryId, ContactListFilter filter) { + if (filter == null || directoryId != Directory.DEFAULT) { + return; + } + + final StringBuilder selection = new StringBuilder(); + final List<String> selectionArgs = new ArrayList<String>(); + + switch (filter.filterType) { + case ContactListFilter.FILTER_TYPE_CUSTOM: + { + selection.append(Contacts.IN_VISIBLE_GROUP + "=1"); + selection.append(" AND " + Contacts.HAS_PHONE_NUMBER + "=1"); + break; + } + case ContactListFilter.FILTER_TYPE_ACCOUNT: + { + filter.addAccountQueryParameterToUrl(uriBuilder); + break; + } + case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: + case ContactListFilter.FILTER_TYPE_DEFAULT: + break; // No selection needed. + case ContactListFilter.FILTER_TYPE_WITH_PHONE_NUMBERS_ONLY: + break; // This adapter is always "phone only", so no selection needed either. + default: + Log.w( + TAG, + "Unsupported filter type came " + + "(type: " + + filter.filterType + + ", toString: " + + filter + + ")" + + " showing all contacts."); + // No selection. + break; + } + loader.setSelection(selection.toString()); + loader.setSelectionArgs(selectionArgs.toArray(new String[0])); + } + + public String getPhoneNumber(int position) { + final Cursor item = (Cursor) getItem(position); + return item != null ? item.getString(PhoneQuery.PHONE_NUMBER) : null; + } + + /** + * Retrieves the lookup key for the given cursor position. + * + * @param position The cursor position. + * @return The lookup key. + */ + public String getLookupKey(int position) { + final Cursor item = (Cursor) getItem(position); + return item != null ? item.getString(PhoneQuery.LOOKUP_KEY) : null; + } + + @Override + protected ContactListItemView newView( + Context context, int partition, Cursor cursor, int position, ViewGroup parent) { + ContactListItemView view = super.newView(context, partition, cursor, position, parent); + view.setUnknownNameText(mUnknownNameText); + view.setQuickContactEnabled(isQuickContactEnabled()); + view.setPhotoPosition(mPhotoPosition); + return view; + } + + protected void setHighlight(ContactListItemView view, Cursor cursor) { + view.setHighlightedPrefix(isSearchMode() ? getUpperCaseQueryString() : null); + } + + @Override + protected void bindView(View itemView, int partition, Cursor cursor, int position) { + super.bindView(itemView, partition, cursor, position); + ContactListItemView view = (ContactListItemView) itemView; + + setHighlight(view, cursor); + + // Look at elements before and after this position, checking if contact IDs are same. + // If they have one same contact ID, it means they can be grouped. + // + // In one group, only the first entry will show its photo and its name, and the other + // entries in the group show just their data (e.g. phone number, email address). + cursor.moveToPosition(position); + boolean isFirstEntry = true; + final long currentContactId = cursor.getLong(PhoneQuery.CONTACT_ID); + if (cursor.moveToPrevious() && !cursor.isBeforeFirst()) { + final long previousContactId = cursor.getLong(PhoneQuery.CONTACT_ID); + if (currentContactId == previousContactId) { + isFirstEntry = false; + } + } + cursor.moveToPosition(position); + + bindViewId(view, cursor, PhoneQuery.PHONE_ID); + + bindSectionHeaderAndDivider(view, position); + if (isFirstEntry) { + bindName(view, cursor); + if (isQuickContactEnabled()) { + bindQuickContact( + view, + partition, + cursor, + PhoneQuery.PHOTO_ID, + PhoneQuery.PHOTO_URI, + PhoneQuery.CONTACT_ID, + PhoneQuery.LOOKUP_KEY, + PhoneQuery.DISPLAY_NAME); + } else { + if (getDisplayPhotos()) { + bindPhoto(view, partition, cursor); + } + } + } else { + unbindName(view); + + view.removePhotoView(true, false); + } + + final DirectoryPartition directory = (DirectoryPartition) getPartition(partition); + + // If the first partition does not have a header, then all subsequent partitions' + // getPositionForPartition returns an index off by 1. + int partitionOffset = 0; + if (partition > 0 && !getPartition(0).getHasHeader()) { + partitionOffset = 1; + } + position += getPositionForPartition(partition) + partitionOffset; + + bindPhoneNumber(view, cursor, directory.isDisplayNumber(), position); + } + + protected void bindPhoneNumber( + ContactListItemView view, Cursor cursor, boolean displayNumber, int position) { + CharSequence label = null; + if (displayNumber && !cursor.isNull(PhoneQuery.PHONE_TYPE)) { + final int type = cursor.getInt(PhoneQuery.PHONE_TYPE); + final String customLabel = cursor.getString(PhoneQuery.PHONE_LABEL); + + // TODO cache + label = Phone.getTypeLabel(getContext().getResources(), type, customLabel); + } + view.setLabel(label); + final String text; + if (displayNumber) { + text = cursor.getString(PhoneQuery.PHONE_NUMBER); + } else { + // Display phone label. If that's null, display geocoded location for the number + final String phoneLabel = cursor.getString(PhoneQuery.PHONE_LABEL); + if (phoneLabel != null) { + text = phoneLabel; + } else { + final String phoneNumber = cursor.getString(PhoneQuery.PHONE_NUMBER); + text = GeoUtil.getGeocodedLocationFor(mContext, phoneNumber); + } + } + view.setPhoneNumber(text); + + if (CompatUtils.isVideoCompatible()) { + // Determine if carrier presence indicates the number supports video calling. + int carrierPresence = cursor.getInt(PhoneQuery.CARRIER_PRESENCE); + boolean isPresent = (carrierPresence & Phone.CARRIER_PRESENCE_VT_CAPABLE) != 0; + + boolean isVideoIconShown = mIsVideoEnabled && (!mIsPresenceEnabled || isPresent); + view.setShowVideoCallIcon(isVideoIconShown, mListener, position); + } + } + + protected void bindSectionHeaderAndDivider(final ContactListItemView view, int position) { + if (isSectionHeaderDisplayEnabled()) { + Placement placement = getItemPlacementInSection(position); + view.setSectionHeader(placement.firstInSection ? placement.sectionHeader : null); + } else { + view.setSectionHeader(null); + } + } + + protected void bindName(final ContactListItemView view, Cursor cursor) { + view.showDisplayName(cursor, PhoneQuery.DISPLAY_NAME); + // Note: we don't show phonetic names any more (see issue 5265330) + } + + protected void unbindName(final ContactListItemView view) { + view.hideDisplayName(); + } + + @Override + protected void bindWorkProfileIcon(final ContactListItemView view, int partition) { + final DirectoryPartition directory = (DirectoryPartition) getPartition(partition); + final long directoryId = directory.getDirectoryId(); + final long userType = ContactsUtils.determineUserType(directoryId, null); + // Work directory must not be a extended directory. An extended directory is custom + // directory in the app, but not a directory provided by framework. So it can't be + // USER_TYPE_WORK. + view.setWorkProfileIconEnabled( + !isExtendedDirectory(directoryId) && userType == ContactsUtils.USER_TYPE_WORK); + } + + protected void bindPhoto(final ContactListItemView view, int partitionIndex, Cursor cursor) { + if (!isPhotoSupported(partitionIndex)) { + view.removePhotoView(); + return; + } + + long photoId = 0; + if (!cursor.isNull(PhoneQuery.PHOTO_ID)) { + photoId = cursor.getLong(PhoneQuery.PHOTO_ID); + } + + if (photoId != 0) { + getPhotoLoader() + .loadThumbnail(view.getPhotoView(), photoId, false, getCircularPhotos(), null); + } else { + final String photoUriString = cursor.getString(PhoneQuery.PHOTO_URI); + final Uri photoUri = photoUriString == null ? null : Uri.parse(photoUriString); + + DefaultImageRequest request = null; + if (photoUri == null) { + final String displayName = cursor.getString(PhoneQuery.DISPLAY_NAME); + final String lookupKey = cursor.getString(PhoneQuery.LOOKUP_KEY); + request = new DefaultImageRequest(displayName, lookupKey, getCircularPhotos()); + } + getPhotoLoader() + .loadDirectoryPhoto(view.getPhotoView(), photoUri, false, getCircularPhotos(), request); + } + } + + public ContactListItemView.PhotoPosition getPhotoPosition() { + return mPhotoPosition; + } + + public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + } + + public void setUseCallableUri(boolean useCallableUri) { + mUseCallableUri = useCallableUri; + } + + /** + * Override base implementation to inject extended directories between local & remote directories. + * This is done in the following steps: 1. Call base implementation to add directories from the + * cursor. 2. Iterate all base directories and establish the following information: a. The highest + * directory id so that we can assign unused id's to the extended directories. b. The index of the + * last non-remote directory. This is where we will insert extended directories. 3. Iterate the + * extended directories and for each one, assign an ID and insert it in the proper location. + */ + @Override + public void changeDirectories(Cursor cursor) { + super.changeDirectories(cursor); + if (getDirectorySearchMode() == DirectoryListLoader.SEARCH_MODE_NONE) { + return; + } + final int numExtendedDirectories = mExtendedDirectories.size(); + if (getPartitionCount() == cursor.getCount() + numExtendedDirectories) { + // already added all directories; + return; + } + // + mFirstExtendedDirectoryId = Long.MAX_VALUE; + if (numExtendedDirectories > 0) { + // The Directory.LOCAL_INVISIBLE is not in the cursor but we can't reuse it's + // "special" ID. + long maxId = Directory.LOCAL_INVISIBLE; + int insertIndex = 0; + for (int i = 0, n = getPartitionCount(); i < n; i++) { + final DirectoryPartition partition = (DirectoryPartition) getPartition(i); + final long id = partition.getDirectoryId(); + if (id > maxId) { + maxId = id; + } + if (!DirectoryCompat.isRemoteDirectoryId(id)) { + // assuming remote directories come after local, we will end up with the index + // where we should insert extended directories. This also works if there are no + // remote directories at all. + insertIndex = i + 1; + } + } + // Extended directories ID's cannot collide with base directories + mFirstExtendedDirectoryId = maxId + 1; + for (int i = 0; i < numExtendedDirectories; i++) { + final long id = mFirstExtendedDirectoryId + i; + final DirectoryPartition directory = mExtendedDirectories.get(i); + if (getPartitionByDirectoryId(id) == -1) { + addPartition(insertIndex, directory); + directory.setDirectoryId(id); + } + } + } + } + + @Override + protected Uri getContactUri( + int partitionIndex, Cursor cursor, int contactIdColumn, int lookUpKeyColumn) { + final DirectoryPartition directory = (DirectoryPartition) getPartition(partitionIndex); + final long directoryId = directory.getDirectoryId(); + if (!isExtendedDirectory(directoryId)) { + return super.getContactUri(partitionIndex, cursor, contactIdColumn, lookUpKeyColumn); + } + return Contacts.CONTENT_LOOKUP_URI + .buildUpon() + .appendPath(Constants.LOOKUP_URI_ENCODED) + .appendQueryParameter(Directory.DISPLAY_NAME, directory.getLabel()) + .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY, String.valueOf(directoryId)) + .encodedFragment(cursor.getString(lookUpKeyColumn)) + .build(); + } + + public Listener getListener() { + return mListener; + } + + public void setListener(Listener listener) { + mListener = listener; + } + + public interface Listener { + + void onVideoCallIconClicked(int position); + } + + public static class PhoneQuery { + + /** + * Optional key used as part of a JSON lookup key to specify an analytics category associated + * with the row. + */ + public static final String ANALYTICS_CATEGORY = "analytics_category"; + + /** + * Optional key used as part of a JSON lookup key to specify an analytics action associated with + * the row. + */ + public static final String ANALYTICS_ACTION = "analytics_action"; + + /** + * Optional key used as part of a JSON lookup key to specify an analytics value associated with + * the row. + */ + public static final String ANALYTICS_VALUE = "analytics_value"; + + public static final String[] PROJECTION_PRIMARY_INTERNAL = + new String[] { + Phone._ID, // 0 + Phone.TYPE, // 1 + Phone.LABEL, // 2 + Phone.NUMBER, // 3 + Phone.CONTACT_ID, // 4 + Phone.LOOKUP_KEY, // 5 + Phone.PHOTO_ID, // 6 + Phone.DISPLAY_NAME_PRIMARY, // 7 + Phone.PHOTO_THUMBNAIL_URI, // 8 + }; + + public static final String[] PROJECTION_PRIMARY; + public static final String[] PROJECTION_ALTERNATIVE_INTERNAL = + new String[] { + Phone._ID, // 0 + Phone.TYPE, // 1 + Phone.LABEL, // 2 + Phone.NUMBER, // 3 + Phone.CONTACT_ID, // 4 + Phone.LOOKUP_KEY, // 5 + Phone.PHOTO_ID, // 6 + Phone.DISPLAY_NAME_ALTERNATIVE, // 7 + Phone.PHOTO_THUMBNAIL_URI, // 8 + }; + public static final String[] PROJECTION_ALTERNATIVE; + public static final int PHONE_ID = 0; + public static final int PHONE_TYPE = 1; + public static final int PHONE_LABEL = 2; + public static final int PHONE_NUMBER = 3; + public static final int CONTACT_ID = 4; + public static final int LOOKUP_KEY = 5; + public static final int PHOTO_ID = 6; + public static final int DISPLAY_NAME = 7; + public static final int PHOTO_URI = 8; + public static final int CARRIER_PRESENCE = 9; + + static { + final List<String> projectionList = + new ArrayList<>(Arrays.asList(PROJECTION_PRIMARY_INTERNAL)); + if (CompatUtils.isMarshmallowCompatible()) { + projectionList.add(Phone.CARRIER_PRESENCE); // 9 + } + PROJECTION_PRIMARY = projectionList.toArray(new String[projectionList.size()]); + } + + static { + final List<String> projectionList = + new ArrayList<>(Arrays.asList(PROJECTION_ALTERNATIVE_INTERNAL)); + if (CompatUtils.isMarshmallowCompatible()) { + projectionList.add(Phone.CARRIER_PRESENCE); // 9 + } + PROJECTION_ALTERNATIVE = projectionList.toArray(new String[projectionList.size()]); + } + } +} diff --git a/java/com/android/contacts/common/list/PhoneNumberPickerFragment.java b/java/com/android/contacts/common/list/PhoneNumberPickerFragment.java new file mode 100644 index 000000000..4ae81529b --- /dev/null +++ b/java/com/android/contacts/common/list/PhoneNumberPickerFragment.java @@ -0,0 +1,402 @@ +/* + * 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.Loader; +import android.database.Cursor; +import android.os.Bundle; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.ArraySet; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import com.android.contacts.common.R; +import com.android.contacts.common.util.AccountFilterUtil; +import com.android.dialer.callintent.nano.CallSpecificAppData; +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.logging.Logger; +import java.util.Set; +import org.json.JSONException; +import org.json.JSONObject; + +/** Fragment containing a phone number list for picking. */ +public class PhoneNumberPickerFragment extends ContactEntryListFragment<ContactEntryListAdapter> + implements PhoneNumberListAdapter.Listener { + + private static final String KEY_FILTER = "filter"; + private OnPhoneNumberPickerActionListener mListener; + private ContactListFilter mFilter; + private View mAccountFilterHeader; + /** + * Lives as ListView's header and is shown when {@link #mAccountFilterHeader} is set to View.GONE. + */ + private View mPaddingView; + /** true if the loader has started at least once. */ + private boolean mLoaderStarted; + + private boolean mUseCallableUri; + + private ContactListItemView.PhotoPosition mPhotoPosition = + ContactListItemView.getDefaultPhotoPosition(false /* normal/non opposite */); + + private final Set<OnLoadFinishedListener> mLoadFinishedListeners = + new ArraySet<OnLoadFinishedListener>(); + + private CursorReranker mCursorReranker; + + public PhoneNumberPickerFragment() { + setQuickContactEnabled(false); + setPhotoLoaderEnabled(true); + setSectionHeaderDisplayEnabled(true); + setDirectorySearchMode(DirectoryListLoader.SEARCH_MODE_NONE); + + // Show nothing instead of letting caller Activity show something. + setHasOptionsMenu(true); + } + + /** + * Handles a click on the video call icon for a row in the list. + * + * @param position The position in the list where the click ocurred. + */ + @Override + public void onVideoCallIconClicked(int position) { + callNumber(position, true /* isVideoCall */); + } + + public void setDirectorySearchEnabled(boolean flag) { + setDirectorySearchMode( + flag ? DirectoryListLoader.SEARCH_MODE_DEFAULT : DirectoryListLoader.SEARCH_MODE_NONE); + } + + public void setOnPhoneNumberPickerActionListener(OnPhoneNumberPickerActionListener listener) { + this.mListener = listener; + } + + public OnPhoneNumberPickerActionListener getOnPhoneNumberPickerListener() { + return mListener; + } + + @Override + protected void onCreateView(LayoutInflater inflater, ViewGroup container) { + super.onCreateView(inflater, container); + + View paddingView = inflater.inflate(R.layout.contact_detail_list_padding, null, false); + mPaddingView = paddingView.findViewById(R.id.contact_detail_list_padding); + getListView().addHeaderView(paddingView); + + mAccountFilterHeader = getView().findViewById(R.id.account_filter_header_container); + updateFilterHeaderView(); + + setVisibleScrollbarEnabled(getVisibleScrollbarEnabled()); + } + + protected boolean getVisibleScrollbarEnabled() { + return true; + } + + @Override + protected void setSearchMode(boolean flag) { + super.setSearchMode(flag); + updateFilterHeaderView(); + } + + private void updateFilterHeaderView() { + final ContactListFilter filter = getFilter(); + if (mAccountFilterHeader == null || filter == null) { + return; + } + final boolean shouldShowHeader = + !isSearchMode() + && AccountFilterUtil.updateAccountFilterTitleForPhone( + mAccountFilterHeader, filter, false); + if (shouldShowHeader) { + mPaddingView.setVisibility(View.GONE); + mAccountFilterHeader.setVisibility(View.VISIBLE); + } else { + mPaddingView.setVisibility(View.VISIBLE); + mAccountFilterHeader.setVisibility(View.GONE); + } + } + + @Override + public void restoreSavedState(Bundle savedState) { + super.restoreSavedState(savedState); + + if (savedState == null) { + return; + } + + mFilter = savedState.getParcelable(KEY_FILTER); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putParcelable(KEY_FILTER, mFilter); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == android.R.id.home) { // See ActionBar#setDisplayHomeAsUpEnabled() + if (mListener != null) { + mListener.onHomeInActionBarSelected(); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onItemClick(int position, long id) { + callNumber(position, false /* isVideoCall */); + } + + /** + * Initiates a call to the number at the specified position. + * + * @param position The position. + * @param isVideoCall {@code true} if the call should be initiated as a video call, {@code false} + * otherwise. + */ + private void callNumber(int position, boolean isVideoCall) { + final String number = getPhoneNumber(position); + if (!TextUtils.isEmpty(number)) { + cacheContactInfo(position); + CallSpecificAppData callSpecificAppData = new CallSpecificAppData(); + callSpecificAppData.callInitiationType = getCallInitiationType(true /* isRemoteDirectory */); + callSpecificAppData.positionOfSelectedSearchResult = position; + callSpecificAppData.charactersInSearchString = + getQueryString() == null ? 0 : getQueryString().length(); + mListener.onPickPhoneNumber(number, isVideoCall, callSpecificAppData); + } else { + LogUtil.i( + "PhoneNumberPickerFragment.callNumber", + "item at %d was clicked before adapter is ready, ignoring", + position); + } + + // Get the lookup key and track any analytics + final String lookupKey = getLookupKey(position); + if (!TextUtils.isEmpty(lookupKey)) { + maybeTrackAnalytics(lookupKey); + } + } + + protected void cacheContactInfo(int position) { + // Not implemented. Hook for child classes + } + + protected String getPhoneNumber(int position) { + final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter(); + return adapter.getPhoneNumber(position); + } + + protected String getLookupKey(int position) { + final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter(); + return adapter.getLookupKey(position); + } + + @Override + protected void startLoading() { + mLoaderStarted = true; + super.startLoading(); + } + + @Override + @MainThread + public void onLoadFinished(Loader<Cursor> loader, Cursor data) { + Assert.isMainThread(); + // TODO: define and verify behavior for "Nearby places", corp directories, + // and dividers listed in UI between these categories + if (mCursorReranker != null + && data != null + && !data.isClosed() + && data.getCount() > 0 + && loader.getId() != -1) { // skip invalid directory ID of -1 + data = mCursorReranker.rerankCursor(data); + } + super.onLoadFinished(loader, data); + + // disable scroll bar if there is no data + setVisibleScrollbarEnabled(data != null && !data.isClosed() && data.getCount() > 0); + + if (data != null) { + notifyListeners(); + } + } + + /** Ranks cursor data rows and returns reference to new cursor object with reordered data. */ + public interface CursorReranker { + @MainThread + Cursor rerankCursor(Cursor data); + } + + @MainThread + public void setReranker(@Nullable CursorReranker reranker) { + Assert.isMainThread(); + mCursorReranker = reranker; + } + + /** Listener that is notified when cursor has finished loading data. */ + public interface OnLoadFinishedListener { + void onLoadFinished(); + } + + @MainThread + public void addOnLoadFinishedListener(OnLoadFinishedListener listener) { + Assert.isMainThread(); + mLoadFinishedListeners.add(listener); + } + + @MainThread + public void removeOnLoadFinishedListener(OnLoadFinishedListener listener) { + Assert.isMainThread(); + mLoadFinishedListeners.remove(listener); + } + + @MainThread + protected void notifyListeners() { + Assert.isMainThread(); + for (OnLoadFinishedListener listener : mLoadFinishedListeners) { + listener.onLoadFinished(); + } + } + + @MainThread + @Override + public void onDetach() { + Assert.isMainThread(); + mLoadFinishedListeners.clear(); + super.onDetach(); + } + + public void setUseCallableUri(boolean useCallableUri) { + mUseCallableUri = useCallableUri; + } + + public boolean usesCallableUri() { + return mUseCallableUri; + } + + @Override + protected ContactEntryListAdapter createListAdapter() { + PhoneNumberListAdapter adapter = new PhoneNumberListAdapter(getActivity()); + adapter.setDisplayPhotos(true); + adapter.setUseCallableUri(mUseCallableUri); + return adapter; + } + + @Override + protected void configureAdapter() { + super.configureAdapter(); + + final ContactEntryListAdapter adapter = getAdapter(); + if (adapter == null) { + return; + } + + if (!isSearchMode() && mFilter != null) { + adapter.setFilter(mFilter); + } + + setPhotoPosition(adapter); + } + + protected void setPhotoPosition(ContactEntryListAdapter adapter) { + ((PhoneNumberListAdapter) adapter).setPhotoPosition(mPhotoPosition); + } + + @Override + protected View inflateView(LayoutInflater inflater, ViewGroup container) { + return inflater.inflate(R.layout.contact_list_content, null); + } + + public ContactListFilter getFilter() { + return mFilter; + } + + public void setFilter(ContactListFilter filter) { + if ((mFilter == null && filter == null) || (mFilter != null && mFilter.equals(filter))) { + return; + } + + mFilter = filter; + if (mLoaderStarted) { + reloadData(); + } + updateFilterHeaderView(); + } + + public void setPhotoPosition(ContactListItemView.PhotoPosition photoPosition) { + mPhotoPosition = photoPosition; + + final PhoneNumberListAdapter adapter = (PhoneNumberListAdapter) getAdapter(); + if (adapter != null) { + adapter.setPhotoPosition(photoPosition); + } + } + + /** + * @param isRemoteDirectory {@code true} if the call was initiated using a contact/phone number + * not in the local contacts database + */ + protected int getCallInitiationType(boolean isRemoteDirectory) { + return OnPhoneNumberPickerActionListener.CALL_INITIATION_UNKNOWN; + } + + /** + * Where a lookup key contains analytic event information, logs the associated analytics event. + * + * @param lookupKey The lookup key JSON object. + */ + private void maybeTrackAnalytics(String lookupKey) { + try { + JSONObject json = new JSONObject(lookupKey); + + String analyticsCategory = + json.getString(PhoneNumberListAdapter.PhoneQuery.ANALYTICS_CATEGORY); + String analyticsAction = json.getString(PhoneNumberListAdapter.PhoneQuery.ANALYTICS_ACTION); + String analyticsValue = json.getString(PhoneNumberListAdapter.PhoneQuery.ANALYTICS_VALUE); + + if (TextUtils.isEmpty(analyticsCategory) + || TextUtils.isEmpty(analyticsAction) + || TextUtils.isEmpty(analyticsValue)) { + return; + } + + // Assume that the analytic value being tracked could be a float value, but just cast + // to a long so that the analytic server can handle it. + long value; + try { + float floatValue = Float.parseFloat(analyticsValue); + value = (long) floatValue; + } catch (NumberFormatException nfe) { + return; + } + + Logger.get(getActivity()) + .sendHitEventAnalytics(analyticsCategory, analyticsAction, "" /* label */, value); + } catch (JSONException e) { + // Not an error; just a lookup key that doesn't have the right information. + } + } +} diff --git a/java/com/android/contacts/common/list/PinnedHeaderListAdapter.java b/java/com/android/contacts/common/list/PinnedHeaderListAdapter.java new file mode 100644 index 000000000..0bdcef084 --- /dev/null +++ b/java/com/android/contacts/common/list/PinnedHeaderListAdapter.java @@ -0,0 +1,159 @@ +/* + * 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.view.View; +import android.view.ViewGroup; +import com.android.common.widget.CompositeCursorAdapter; + +/** A subclass of {@link CompositeCursorAdapter} that manages pinned partition headers. */ +public abstract class PinnedHeaderListAdapter extends CompositeCursorAdapter + implements PinnedHeaderListView.PinnedHeaderAdapter { + + public static final int PARTITION_HEADER_TYPE = 0; + + private boolean mPinnedPartitionHeadersEnabled; + private boolean[] mHeaderVisibility; + + public PinnedHeaderListAdapter(Context context) { + super(context); + } + + public boolean getPinnedPartitionHeadersEnabled() { + return mPinnedPartitionHeadersEnabled; + } + + public void setPinnedPartitionHeadersEnabled(boolean flag) { + this.mPinnedPartitionHeadersEnabled = flag; + } + + @Override + public int getPinnedHeaderCount() { + if (mPinnedPartitionHeadersEnabled) { + return getPartitionCount(); + } else { + return 0; + } + } + + protected boolean isPinnedPartitionHeaderVisible(int partition) { + return getPinnedPartitionHeadersEnabled() + && hasHeader(partition) + && !isPartitionEmpty(partition); + } + + /** The default implementation creates the same type of view as a normal partition header. */ + @Override + public View getPinnedHeaderView(int partition, View convertView, ViewGroup parent) { + if (hasHeader(partition)) { + View view = null; + if (convertView != null) { + Integer headerType = (Integer) convertView.getTag(); + if (headerType != null && headerType == PARTITION_HEADER_TYPE) { + view = convertView; + } + } + if (view == null) { + view = newHeaderView(getContext(), partition, null, parent); + view.setTag(PARTITION_HEADER_TYPE); + view.setFocusable(false); + view.setEnabled(false); + } + bindHeaderView(view, partition, getCursor(partition)); + view.setLayoutDirection(parent.getLayoutDirection()); + return view; + } else { + return null; + } + } + + @Override + public void configurePinnedHeaders(PinnedHeaderListView listView) { + if (!getPinnedPartitionHeadersEnabled()) { + return; + } + + int size = getPartitionCount(); + + // Cache visibility bits, because we will need them several times later on + if (mHeaderVisibility == null || mHeaderVisibility.length != size) { + mHeaderVisibility = new boolean[size]; + } + for (int i = 0; i < size; i++) { + boolean visible = isPinnedPartitionHeaderVisible(i); + mHeaderVisibility[i] = visible; + if (!visible) { + listView.setHeaderInvisible(i, true); + } + } + + int headerViewsCount = listView.getHeaderViewsCount(); + + // Starting at the top, find and pin headers for partitions preceding the visible one(s) + int maxTopHeader = -1; + int topHeaderHeight = 0; + for (int i = 0; i < size; i++) { + if (mHeaderVisibility[i]) { + int position = listView.getPositionAt(topHeaderHeight) - headerViewsCount; + int partition = getPartitionForPosition(position); + if (i > partition) { + break; + } + + listView.setHeaderPinnedAtTop(i, topHeaderHeight, false); + topHeaderHeight += listView.getPinnedHeaderHeight(i); + maxTopHeader = i; + } + } + + // Starting at the bottom, find and pin headers for partitions following the visible one(s) + int maxBottomHeader = size; + int bottomHeaderHeight = 0; + int listHeight = listView.getHeight(); + for (int i = size; --i > maxTopHeader; ) { + if (mHeaderVisibility[i]) { + int position = listView.getPositionAt(listHeight - bottomHeaderHeight) - headerViewsCount; + if (position < 0) { + break; + } + + int partition = getPartitionForPosition(position - 1); + if (partition == -1 || i <= partition) { + break; + } + + int height = listView.getPinnedHeaderHeight(i); + bottomHeaderHeight += height; + + listView.setHeaderPinnedAtBottom(i, listHeight - bottomHeaderHeight, false); + maxBottomHeader = i; + } + } + + // Headers in between the top-pinned and bottom-pinned should be hidden + for (int i = maxTopHeader + 1; i < maxBottomHeader; i++) { + if (mHeaderVisibility[i]) { + listView.setHeaderInvisible(i, isPartitionEmpty(i)); + } + } + } + + @Override + public int getScrollPositionForHeader(int viewIndex) { + return getPositionForPartition(viewIndex); + } +} diff --git a/java/com/android/contacts/common/list/PinnedHeaderListView.java b/java/com/android/contacts/common/list/PinnedHeaderListView.java new file mode 100644 index 000000000..33c68b68c --- /dev/null +++ b/java/com/android/contacts/common/list/PinnedHeaderListView.java @@ -0,0 +1,563 @@ +/* + * 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.graphics.Canvas; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ListAdapter; +import com.android.dialer.util.ViewUtil; + +/** + * A ListView that maintains a header pinned at the top of the list. The pinned header can be pushed + * up and dissolved as needed. + */ +public class PinnedHeaderListView extends AutoScrollListView + implements OnScrollListener, OnItemSelectedListener { + + private static final int MAX_ALPHA = 255; + private static final int TOP = 0; + private static final int BOTTOM = 1; + private static final int FADING = 2; + private static final int DEFAULT_ANIMATION_DURATION = 20; + private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 100; + private PinnedHeaderAdapter mAdapter; + private int mSize; + private PinnedHeader[] mHeaders; + private RectF mBounds = new RectF(); + private OnScrollListener mOnScrollListener; + private OnItemSelectedListener mOnItemSelectedListener; + private int mScrollState; + private boolean mScrollToSectionOnHeaderTouch = false; + private boolean mHeaderTouched = false; + private int mAnimationDuration = DEFAULT_ANIMATION_DURATION; + private boolean mAnimating; + private long mAnimationTargetTime; + private int mHeaderPaddingStart; + private int mHeaderWidth; + + public PinnedHeaderListView(Context context) { + this(context, null, android.R.attr.listViewStyle); + } + + public PinnedHeaderListView(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.listViewStyle); + } + + public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + super.setOnScrollListener(this); + super.setOnItemSelectedListener(this); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + super.onLayout(changed, l, t, r, b); + mHeaderPaddingStart = getPaddingStart(); + mHeaderWidth = r - l - mHeaderPaddingStart - getPaddingEnd(); + } + + @Override + public void setAdapter(ListAdapter adapter) { + mAdapter = (PinnedHeaderAdapter) adapter; + super.setAdapter(adapter); + } + + @Override + public void setOnScrollListener(OnScrollListener onScrollListener) { + mOnScrollListener = onScrollListener; + super.setOnScrollListener(this); + } + + @Override + public void setOnItemSelectedListener(OnItemSelectedListener listener) { + mOnItemSelectedListener = listener; + super.setOnItemSelectedListener(this); + } + + public void setScrollToSectionOnHeaderTouch(boolean value) { + mScrollToSectionOnHeaderTouch = value; + } + + @Override + public void onScroll( + AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + if (mAdapter != null) { + int count = mAdapter.getPinnedHeaderCount(); + if (count != mSize) { + mSize = count; + if (mHeaders == null) { + mHeaders = new PinnedHeader[mSize]; + } else if (mHeaders.length < mSize) { + PinnedHeader[] headers = mHeaders; + mHeaders = new PinnedHeader[mSize]; + System.arraycopy(headers, 0, mHeaders, 0, headers.length); + } + } + + for (int i = 0; i < mSize; i++) { + if (mHeaders[i] == null) { + mHeaders[i] = new PinnedHeader(); + } + mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this); + } + + mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration; + mAdapter.configurePinnedHeaders(this); + invalidateIfAnimating(); + } + if (mOnScrollListener != null) { + mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount); + } + } + + @Override + protected float getTopFadingEdgeStrength() { + // Disable vertical fading at the top when the pinned header is present + return mSize > 0 ? 0 : super.getTopFadingEdgeStrength(); + } + + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + mScrollState = scrollState; + if (mOnScrollListener != null) { + mOnScrollListener.onScrollStateChanged(this, scrollState); + } + } + + /** + * Ensures that the selected item is positioned below the top-pinned headers and above the + * bottom-pinned ones. + */ + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + int height = getHeight(); + + int windowTop = 0; + int windowBottom = height; + + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + if (header.state == TOP) { + windowTop = header.y + header.height; + } else if (header.state == BOTTOM) { + windowBottom = header.y; + break; + } + } + } + + View selectedView = getSelectedView(); + if (selectedView != null) { + if (selectedView.getTop() < windowTop) { + setSelectionFromTop(position, windowTop); + } else if (selectedView.getBottom() > windowBottom) { + setSelectionFromTop(position, windowBottom - selectedView.getHeight()); + } + } + + if (mOnItemSelectedListener != null) { + mOnItemSelectedListener.onItemSelected(parent, view, position, id); + } + } + + @Override + public void onNothingSelected(AdapterView<?> parent) { + if (mOnItemSelectedListener != null) { + mOnItemSelectedListener.onNothingSelected(parent); + } + } + + public int getPinnedHeaderHeight(int viewIndex) { + ensurePinnedHeaderLayout(viewIndex); + return mHeaders[viewIndex].view.getHeight(); + } + + /** + * Set header to be pinned at the top. + * + * @param viewIndex index of the header view + * @param y is position of the header in pixels. + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) { + ensurePinnedHeaderLayout(viewIndex); + PinnedHeader header = mHeaders[viewIndex]; + header.visible = true; + header.y = y; + header.state = TOP; + + // TODO perhaps we should animate at the top as well + header.animating = false; + } + + /** + * Set header to be pinned at the bottom. + * + * @param viewIndex index of the header view + * @param y is position of the header in pixels. + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) { + ensurePinnedHeaderLayout(viewIndex); + PinnedHeader header = mHeaders[viewIndex]; + header.state = BOTTOM; + if (header.animating) { + header.targetTime = mAnimationTargetTime; + header.sourceY = header.y; + header.targetY = y; + } else if (animate && (header.y != y || !header.visible)) { + if (header.visible) { + header.sourceY = header.y; + } else { + header.visible = true; + header.sourceY = y + header.height; + } + header.animating = true; + header.targetVisible = true; + header.targetTime = mAnimationTargetTime; + header.targetY = y; + } else { + header.visible = true; + header.y = y; + } + } + + /** + * Set header to be pinned at the top of the first visible item. + * + * @param viewIndex index of the header view + * @param position is position of the header in pixels. + */ + public void setFadingHeader(int viewIndex, int position, boolean fade) { + ensurePinnedHeaderLayout(viewIndex); + + View child = getChildAt(position - getFirstVisiblePosition()); + if (child == null) { + return; + } + + PinnedHeader header = mHeaders[viewIndex]; + header.visible = true; + header.state = FADING; + header.alpha = MAX_ALPHA; + header.animating = false; + + int top = getTotalTopPinnedHeaderHeight(); + header.y = top; + if (fade) { + int bottom = child.getBottom() - top; + int headerHeight = header.height; + if (bottom < headerHeight) { + int portion = bottom - headerHeight; + header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight; + header.y = top + portion; + } + } + } + + /** + * Makes header invisible. + * + * @param viewIndex index of the header view + * @param animate true if the transition to the new coordinate should be animated + */ + public void setHeaderInvisible(int viewIndex, boolean animate) { + PinnedHeader header = mHeaders[viewIndex]; + if (header.visible && (animate || header.animating) && header.state == BOTTOM) { + header.sourceY = header.y; + if (!header.animating) { + header.visible = true; + header.targetY = getBottom() + header.height; + } + header.animating = true; + header.targetTime = mAnimationTargetTime; + header.targetVisible = false; + } else { + header.visible = false; + } + } + + private void ensurePinnedHeaderLayout(int viewIndex) { + View view = mHeaders[viewIndex].view; + if (view.isLayoutRequested()) { + ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); + int widthSpec; + int heightSpec; + + if (layoutParams != null && layoutParams.width > 0) { + widthSpec = View.MeasureSpec.makeMeasureSpec(layoutParams.width, View.MeasureSpec.EXACTLY); + } else { + widthSpec = View.MeasureSpec.makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY); + } + + if (layoutParams != null && layoutParams.height > 0) { + heightSpec = + View.MeasureSpec.makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY); + } else { + heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + } + view.measure(widthSpec, heightSpec); + int height = view.getMeasuredHeight(); + mHeaders[viewIndex].height = height; + view.layout(0, 0, view.getMeasuredWidth(), height); + } + } + + /** Returns the sum of heights of headers pinned to the top. */ + public int getTotalTopPinnedHeaderHeight() { + for (int i = mSize; --i >= 0; ) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.state == TOP) { + return header.y + header.height; + } + } + return 0; + } + + /** Returns the list item position at the specified y coordinate. */ + public int getPositionAt(int y) { + do { + int position = pointToPosition(getPaddingLeft() + 1, y); + if (position != -1) { + return position; + } + // If position == -1, we must have hit a separator. Let's examine + // a nearby pixel + y--; + } while (y > 0); + return 0; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + mHeaderTouched = false; + if (super.onInterceptTouchEvent(ev)) { + return true; + } + + if (mScrollState == SCROLL_STATE_IDLE) { + final int y = (int) ev.getY(); + final int x = (int) ev.getX(); + for (int i = mSize; --i >= 0; ) { + PinnedHeader header = mHeaders[i]; + // For RTL layouts, this also takes into account that the scrollbar is on the left + // side. + final int padding = getPaddingLeft(); + if (header.visible + && header.y <= y + && header.y + header.height > y + && x >= padding + && padding + header.view.getWidth() >= x) { + mHeaderTouched = true; + if (mScrollToSectionOnHeaderTouch && ev.getAction() == MotionEvent.ACTION_DOWN) { + return smoothScrollToPartition(i); + } else { + return true; + } + } + } + } + + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (mHeaderTouched) { + if (ev.getAction() == MotionEvent.ACTION_UP) { + mHeaderTouched = false; + } + return true; + } + return super.onTouchEvent(ev); + } + + private boolean smoothScrollToPartition(int partition) { + if (mAdapter == null) { + return false; + } + final int position = mAdapter.getScrollPositionForHeader(partition); + if (position == -1) { + return false; + } + + int offset = 0; + for (int i = 0; i < partition; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + offset += header.height; + } + } + smoothScrollToPositionFromTop( + position + getHeaderViewsCount(), offset, DEFAULT_SMOOTH_SCROLL_DURATION); + return true; + } + + private void invalidateIfAnimating() { + mAnimating = false; + for (int i = 0; i < mSize; i++) { + if (mHeaders[i].animating) { + mAnimating = true; + invalidate(); + return; + } + } + } + + @Override + protected void dispatchDraw(Canvas canvas) { + long currentTime = mAnimating ? System.currentTimeMillis() : 0; + + int top = 0; + int bottom = getBottom(); + boolean hasVisibleHeaders = false; + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible) { + hasVisibleHeaders = true; + if (header.state == BOTTOM && header.y < bottom) { + bottom = header.y; + } else if (header.state == TOP || header.state == FADING) { + int newTop = header.y + header.height; + if (newTop > top) { + top = newTop; + } + } + } + } + + if (hasVisibleHeaders) { + canvas.save(); + } + + super.dispatchDraw(canvas); + + if (hasVisibleHeaders) { + canvas.restore(); + + // If the first item is visible and if it has a positive top that is greater than the + // first header's assigned y-value, use that for the first header's y value. This way, + // the header inherits any padding applied to the list view. + if (mSize > 0 && getFirstVisiblePosition() == 0) { + View firstChild = getChildAt(0); + PinnedHeader firstHeader = mHeaders[0]; + + if (firstHeader != null) { + int firstHeaderTop = firstChild != null ? firstChild.getTop() : 0; + firstHeader.y = Math.max(firstHeader.y, firstHeaderTop); + } + } + + // First draw top headers, then the bottom ones to handle the Z axis correctly + for (int i = mSize; --i >= 0; ) { + PinnedHeader header = mHeaders[i]; + if (header.visible && (header.state == TOP || header.state == FADING)) { + drawHeader(canvas, header, currentTime); + } + } + + for (int i = 0; i < mSize; i++) { + PinnedHeader header = mHeaders[i]; + if (header.visible && header.state == BOTTOM) { + drawHeader(canvas, header, currentTime); + } + } + } + + invalidateIfAnimating(); + } + + private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) { + if (header.animating) { + int timeLeft = (int) (header.targetTime - currentTime); + if (timeLeft <= 0) { + header.y = header.targetY; + header.visible = header.targetVisible; + header.animating = false; + } else { + header.y = + header.targetY + (header.sourceY - header.targetY) * timeLeft / mAnimationDuration; + } + } + if (header.visible) { + View view = header.view; + int saveCount = canvas.save(); + int translateX = + ViewUtil.isViewLayoutRtl(this) + ? getWidth() - mHeaderPaddingStart - view.getWidth() + : mHeaderPaddingStart; + canvas.translate(translateX, header.y); + if (header.state == FADING) { + mBounds.set(0, 0, view.getWidth(), view.getHeight()); + canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); + } + view.draw(canvas); + canvas.restoreToCount(saveCount); + } + } + + /** Adapter interface. The list adapter must implement this interface. */ + public interface PinnedHeaderAdapter { + + /** Returns the overall number of pinned headers, visible or not. */ + int getPinnedHeaderCount(); + + /** Creates or updates the pinned header view. */ + View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent); + + /** + * Configures the pinned headers to match the visible list items. The adapter should call {@link + * PinnedHeaderListView#setHeaderPinnedAtTop}, {@link + * PinnedHeaderListView#setHeaderPinnedAtBottom}, {@link PinnedHeaderListView#setFadingHeader} + * or {@link PinnedHeaderListView#setHeaderInvisible}, for each header that needs to change its + * position or visibility. + */ + void configurePinnedHeaders(PinnedHeaderListView listView); + + /** + * Returns the list position to scroll to if the pinned header is touched. Return -1 if the list + * does not need to be scrolled. + */ + int getScrollPositionForHeader(int viewIndex); + } + + private static final class PinnedHeader { + + View view; + boolean visible; + int y; + int height; + int alpha; + int state; + + boolean animating; + boolean targetVisible; + int sourceY; + int targetY; + long targetTime; + } +} diff --git a/java/com/android/contacts/common/list/ViewPagerTabStrip.java b/java/com/android/contacts/common/list/ViewPagerTabStrip.java new file mode 100644 index 000000000..969a6d342 --- /dev/null +++ b/java/com/android/contacts/common/list/ViewPagerTabStrip.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2014 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.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import com.android.contacts.common.R; + +public class ViewPagerTabStrip extends LinearLayout { + + private final Paint mSelectedUnderlinePaint; + private int mSelectedUnderlineThickness; + private int mIndexForSelection; + private float mSelectionOffset; + + public ViewPagerTabStrip(Context context) { + this(context, null); + } + + public ViewPagerTabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + + final Resources res = context.getResources(); + + mSelectedUnderlineThickness = res.getDimensionPixelSize(R.dimen.tab_selected_underline_height); + int underlineColor = res.getColor(R.color.tab_selected_underline_color); + int backgroundColor = res.getColor(R.color.contactscommon_actionbar_background_color); + + mSelectedUnderlinePaint = new Paint(); + mSelectedUnderlinePaint.setColor(underlineColor); + + setBackgroundColor(backgroundColor); + setWillNotDraw(false); + } + + /** + * Notifies this view that view pager has been scrolled. We save the tab index and selection + * offset for interpolating the position and width of selection underline. + */ + void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mIndexForSelection = position; + mSelectionOffset = positionOffset; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + int childCount = getChildCount(); + + // Thick colored underline below the current selection + if (childCount > 0) { + View selectedTitle = getChildAt(mIndexForSelection); + + if (selectedTitle == null) { + // The view pager's tab count changed but we weren't notified yet. Ignore this draw + // pass, when we get a new selection we will update and draw the selection strip in + // the correct place. + return; + } + int selectedLeft = selectedTitle.getLeft(); + int selectedRight = selectedTitle.getRight(); + final boolean isRtl = isRtl(); + final boolean hasNextTab = + isRtl ? mIndexForSelection > 0 : (mIndexForSelection < (getChildCount() - 1)); + if ((mSelectionOffset > 0.0f) && hasNextTab) { + // Draw the selection partway between the tabs + View nextTitle = getChildAt(mIndexForSelection + (isRtl ? -1 : 1)); + int nextLeft = nextTitle.getLeft(); + int nextRight = nextTitle.getRight(); + + selectedLeft = + (int) (mSelectionOffset * nextLeft + (1.0f - mSelectionOffset) * selectedLeft); + selectedRight = + (int) (mSelectionOffset * nextRight + (1.0f - mSelectionOffset) * selectedRight); + } + + int height = getHeight(); + canvas.drawRect( + selectedLeft, + height - mSelectedUnderlineThickness, + selectedRight, + height, + mSelectedUnderlinePaint); + } + } + + private boolean isRtl() { + return getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; + } +} diff --git a/java/com/android/contacts/common/list/ViewPagerTabs.java b/java/com/android/contacts/common/list/ViewPagerTabs.java new file mode 100644 index 000000000..34f623ef4 --- /dev/null +++ b/java/com/android/contacts/common/list/ViewPagerTabs.java @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2014 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.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Outline; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; +import com.android.contacts.common.R; +import com.android.dialer.compat.CompatUtils; + +/** + * Lightweight implementation of ViewPager tabs. This looks similar to traditional actionBar tabs, + * but allows for the view containing the tabs to be placed anywhere on screen. Text-related + * attributes can also be assigned in XML - these will get propogated to the child TextViews + * automatically. + */ +public class ViewPagerTabs extends HorizontalScrollView implements ViewPager.OnPageChangeListener { + + private static final ViewOutlineProvider VIEW_BOUNDS_OUTLINE_PROVIDER; + private static final int TAB_SIDE_PADDING_IN_DPS = 10; + // TODO: This should use <declare-styleable> in the future + private static final int[] ATTRS = + new int[] { + android.R.attr.textSize, + android.R.attr.textStyle, + android.R.attr.textColor, + android.R.attr.textAllCaps + }; + + static { + if (CompatUtils.isLollipopCompatible()) { + VIEW_BOUNDS_OUTLINE_PROVIDER = + new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRect(0, 0, view.getWidth(), view.getHeight()); + } + }; + } else { + VIEW_BOUNDS_OUTLINE_PROVIDER = null; + } + } + + /** + * Linearlayout that will contain the TextViews serving as tabs. This is the only child of the + * parent HorizontalScrollView. + */ + final int mTextStyle; + + final ColorStateList mTextColor; + final int mTextSize; + final boolean mTextAllCaps; + ViewPager mPager; + int mPrevSelected = -1; + int mSidePadding; + private ViewPagerTabStrip mTabStrip; + private int[] mTabIcons; + // For displaying the unread count next to the tab icon. + private int[] mUnreadCounts; + + public ViewPagerTabs(Context context) { + this(context, null); + } + + public ViewPagerTabs(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ViewPagerTabs(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setFillViewport(true); + + mSidePadding = (int) (getResources().getDisplayMetrics().density * TAB_SIDE_PADDING_IN_DPS); + + final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS); + mTextSize = a.getDimensionPixelSize(0, 0); + mTextStyle = a.getInt(1, 0); + mTextColor = a.getColorStateList(2); + mTextAllCaps = a.getBoolean(3, false); + + mTabStrip = new ViewPagerTabStrip(context); + addView( + mTabStrip, + new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + a.recycle(); + + if (CompatUtils.isLollipopCompatible()) { + // enable shadow casting from view bounds + setOutlineProvider(VIEW_BOUNDS_OUTLINE_PROVIDER); + } + } + + public void setViewPager(ViewPager viewPager) { + mPager = viewPager; + addTabs(mPager.getAdapter()); + } + + /** + * Set the tab icons and initialize an array for unread counts the same length as the icon array. + * + * @param tabIcons An array representing the tab icons in order. + */ + public void configureTabIcons(int[] tabIcons) { + mTabIcons = tabIcons; + mUnreadCounts = new int[tabIcons.length]; + } + + public void setUnreadCount(int count, int position) { + if (mUnreadCounts == null || position >= mUnreadCounts.length) { + return; + } + mUnreadCounts[position] = count; + } + + private void addTabs(PagerAdapter adapter) { + mTabStrip.removeAllViews(); + + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + addTab(adapter.getPageTitle(i), i); + } + } + + private void addTab(CharSequence tabTitle, final int position) { + View tabView; + if (mTabIcons != null && position < mTabIcons.length) { + View layout = LayoutInflater.from(getContext()).inflate(R.layout.unread_count_tab, null); + View iconView = layout.findViewById(R.id.icon); + iconView.setBackgroundResource(mTabIcons[position]); + iconView.setContentDescription(tabTitle); + TextView textView = (TextView) layout.findViewById(R.id.count); + if (mUnreadCounts != null && mUnreadCounts[position] > 0) { + textView.setText(Integer.toString(mUnreadCounts[position])); + textView.setVisibility(View.VISIBLE); + iconView.setContentDescription( + getResources() + .getQuantityString( + R.plurals.tab_title_with_unread_items, + mUnreadCounts[position], + tabTitle.toString(), + mUnreadCounts[position])); + } else { + textView.setVisibility(View.INVISIBLE); + iconView.setContentDescription(getResources().getString(R.string.tab_title, tabTitle)); + } + tabView = layout; + } else { + final TextView textView = new TextView(getContext()); + textView.setText(tabTitle); + textView.setBackgroundResource(R.drawable.view_pager_tab_background); + + // Assign various text appearance related attributes to child views. + if (mTextStyle > 0) { + textView.setTypeface(textView.getTypeface(), mTextStyle); + } + if (mTextSize > 0) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + } + if (mTextColor != null) { + textView.setTextColor(mTextColor); + } + textView.setAllCaps(mTextAllCaps); + textView.setGravity(Gravity.CENTER); + + tabView = textView; + } + + tabView.setOnClickListener( + new OnClickListener() { + @Override + public void onClick(View v) { + mPager.setCurrentItem(getRtlPosition(position)); + } + }); + + tabView.setOnLongClickListener(new OnTabLongClickListener(position)); + + tabView.setPadding(mSidePadding, 0, mSidePadding, 0); + + mTabStrip.addView( + tabView, + position, + new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT, 1)); + + // Default to the first child being selected + if (position == 0) { + mPrevSelected = 0; + tabView.setSelected(true); + } + } + + /** + * Remove a tab at a certain index. + * + * @param index The index of the tab view we wish to remove. + */ + public void removeTab(int index) { + View view = mTabStrip.getChildAt(index); + if (view != null) { + mTabStrip.removeView(view); + } + } + + /** + * Refresh a tab at a certain index by removing it and reconstructing it. + * + * @param index The index of the tab view we wish to update. + */ + public void updateTab(int index) { + removeTab(index); + + if (index < mPager.getAdapter().getCount()) { + addTab(mPager.getAdapter().getPageTitle(index), index); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + mTabStrip.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + if (mPrevSelected >= 0 && mPrevSelected < tabStripChildCount) { + mTabStrip.getChildAt(mPrevSelected).setSelected(false); + } + final View selectedChild = mTabStrip.getChildAt(position); + selectedChild.setSelected(true); + + // Update scroll position + final int scrollPos = selectedChild.getLeft() - (getWidth() - selectedChild.getWidth()) / 2; + smoothScrollTo(scrollPos, 0); + mPrevSelected = position; + } + + @Override + public void onPageScrollStateChanged(int state) {} + + private int getRtlPosition(int position) { + if (getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + return mTabStrip.getChildCount() - 1 - position; + } + return position; + } + + /** Simulates actionbar tab behavior by showing a toast with the tab title when long clicked. */ + private class OnTabLongClickListener implements OnLongClickListener { + + final int mPosition; + + public OnTabLongClickListener(int position) { + mPosition = position; + } + + @Override + public boolean onLongClick(View v) { + final int[] screenPos = new int[2]; + getLocationOnScreen(screenPos); + + final Context context = getContext(); + final int width = getWidth(); + final int height = getHeight(); + final int screenWidth = context.getResources().getDisplayMetrics().widthPixels; + + Toast toast = + Toast.makeText(context, mPager.getAdapter().getPageTitle(mPosition), Toast.LENGTH_SHORT); + + // Show the toast under the tab + toast.setGravity( + Gravity.TOP | Gravity.CENTER_HORIZONTAL, + (screenPos[0] + width / 2) - screenWidth / 2, + screenPos[1] + height); + + toast.show(); + return true; + } + } +} |