diff options
Diffstat (limited to 'src/com/android/messaging/ui/contact')
11 files changed, 2058 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java new file mode 100644 index 0000000..9c1393d --- /dev/null +++ b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AccessibilityUtil; + +public class AddContactsConfirmationDialog implements DialogInterface.OnClickListener { + private final Context mContext; + private final Uri mAvatarUri; + private final String mNormalizedDestination; + + public AddContactsConfirmationDialog(final Context context, final Uri avatarUri, + final String normalizedDestination) { + mContext = context; + mAvatarUri = avatarUri; + mNormalizedDestination = normalizedDestination; + } + + public void show() { + final int confirmAddContactStringId = R.string.add_contact_confirmation; + final int cancelStringId = android.R.string.cancel; + final AlertDialog alertDialog = new AlertDialog.Builder(mContext) + .setTitle(R.string.add_contact_confirmation_dialog_title) + .setView(createBodyView()) + .setPositiveButton(confirmAddContactStringId, this) + .setNegativeButton(cancelStringId, null) + .create(); + alertDialog.show(); + final Resources resources = mContext.getResources(); + final Button cancelButton = alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE); + if (cancelButton != null) { + cancelButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color)); + } + final Button addButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE); + if (addButton != null) { + addButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color)); + } + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + UIIntents.get().launchAddContactActivity(mContext, mNormalizedDestination); + } + + private View createBodyView() { + final View view = LayoutInflater.from(mContext).inflate( + R.layout.add_contacts_confirmation_dialog_body, null); + final ContactIconView iconView = (ContactIconView) view.findViewById(R.id.contact_icon); + iconView.setImageResourceUri(mAvatarUri); + final TextView textView = (TextView) view.findViewById(R.id.participant_name); + textView.setText(mNormalizedDestination); + // Accessibility reason : in case phone numbers are mixed in the display name, + // we need to vocalize it for talkback. + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + mContext.getResources(), mNormalizedDestination); + textView.setContentDescription(vocalizedDisplayName); + return view; + } +} diff --git a/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java new file mode 100644 index 0000000..7263c54 --- /dev/null +++ b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; + +import com.android.messaging.R; +import com.android.messaging.ui.CustomHeaderPagerListViewHolder; +import com.android.messaging.ui.contact.ContactListItemView.HostInterface; + +/** + * Holds the all contacts view for the contact picker's view pager. + */ +public class AllContactsListViewHolder extends CustomHeaderPagerListViewHolder { + public AllContactsListViewHolder(final Context context, final HostInterface clivHostInterface) { + super(context, new ContactListAdapter(context, null, clivHostInterface, + true /* needAlphabetHeader */)); + } + + @Override + protected int getLayoutResId() { + return R.layout.all_contacts_list_view; + } + + @Override + protected int getPageTitleResId() { + return R.string.contact_picker_all_contacts_tab_title; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return R.id.all_contacts_list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} diff --git a/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java new file mode 100644 index 0000000..7df62de --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; +import android.graphics.drawable.StateListDrawable; +import android.net.Uri; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.android.ex.chips.DropdownChipLayouter; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactRecipientEntryUtils; + +/** + * An implementation for {@link DropdownChipLayouter}. Layouts the dropdown + * list in the ContactRecipientAutoCompleteView in Material style. + */ +public class ContactDropdownLayouter extends DropdownChipLayouter { + private final ContactListItemView.HostInterface mClivHostInterface; + + public ContactDropdownLayouter(final LayoutInflater inflater, final Context context, + final ContactListItemView.HostInterface clivHostInterface) { + super(inflater, context); + mClivHostInterface = new ContactListItemView.HostInterface() { + + @Override + public void onContactListItemClicked(final ContactListItemData item, + final ContactListItemView view) { + // The chips UI will handle auto-complete item click events, so No-op here. + } + + @Override + public boolean isContactSelected(final ContactListItemData item) { + // In chips drop down we don't show any selected checkmark per design. + return false; + } + }; + } + + /** + * Bind a drop down view to a RecipientEntry. We'd like regular dropdown items (BASE_RECIPIENT) + * to behave the same as regular ContactListItemViews, while using the chips library's + * item styling for alternates dropdown items (happens when you click on a chip). + */ + @Override + public View bindView(final View convertView, final ViewGroup parent, final RecipientEntry entry, + final int position, AdapterType type, final String substring, + final StateListDrawable deleteDrawable) { + if (type != AdapterType.BASE_RECIPIENT) { + if (type == AdapterType.SINGLE_RECIPIENT) { + // Treat single recipients the same way as alternates. The base implementation of + // single recipients would try to simplify the destination by tokenizing. We'd + // like to always show the full destination address per design request. + type = AdapterType.RECIPIENT_ALTERNATES; + } + return super.bindView(convertView, parent, entry, position, type, substring, + deleteDrawable); + } + + // Default to show all the information + // RTL : To format contact name and detail if they happen to be phone numbers. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String displayName = bidiFormatter.unicodeWrap( + ContactRecipientEntryUtils.getDisplayNameForContactList(entry), + TextDirectionHeuristicsCompat.LTR); + final String destination = bidiFormatter.unicodeWrap( + ContactRecipientEntryUtils.formatDestination(entry), + TextDirectionHeuristicsCompat.LTR); + final View itemView = reuseOrInflateView(convertView, parent, type); + + // Bold the string that is matched. + final CharSequence[] styledResults = + getStyledResults(substring, displayName, destination); + + Assert.isTrue(itemView instanceof ContactListItemView); + final ContactListItemView contactListItemView = (ContactListItemView) itemView; + contactListItemView.setImageClickHandlerDisabled(true); + contactListItemView.bind(entry, styledResults[0], styledResults[1], + mClivHostInterface, (type == AdapterType.SINGLE_RECIPIENT)); + return itemView; + } + + @Override + protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view, + AdapterType type) { + if (showImage && view instanceof ContactIconView) { + final ContactIconView contactView = (ContactIconView) view; + // These show contact cards by default, but that isn't what we want here + contactView.setImageClickHandlerDisabled(true); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(entry)); + contactView.setImageResourceUri(avatarUri); + } else { + super.bindIconToView(showImage, entry, view, type); + } + } + + @Override + protected int getItemLayoutResId(AdapterType type) { + switch (type) { + case BASE_RECIPIENT: + return R.layout.contact_list_item_view; + case RECIPIENT_ALTERNATES: + return R.layout.chips_alternates_dropdown_item; + default: + return R.layout.chips_alternates_dropdown_item; + } + } + + @Override + protected int getAlternateItemLayoutResId(AdapterType type) { + return getItemLayoutResId(type); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactListAdapter.java b/src/com/android/messaging/ui/contact/ContactListAdapter.java new file mode 100644 index 0000000..d466b61 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactListAdapter.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.SectionIndexer; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +public class ContactListAdapter extends CursorAdapter implements SectionIndexer { + private final ContactListItemView.HostInterface mClivHostInterface; + private final boolean mNeedAlphabetHeader; + private ContactSectionIndexer mSectionIndexer; + + public ContactListAdapter(final Context context, final Cursor cursor, + final ContactListItemView.HostInterface clivHostInterface, + final boolean needAlphabetHeader) { + super(context, cursor, 0); + mClivHostInterface = clivHostInterface; + mNeedAlphabetHeader = needAlphabetHeader; + mSectionIndexer = new ContactSectionIndexer(cursor); + } + + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + Assert.isTrue(view instanceof ContactListItemView); + final ContactListItemView contactListItemView = (ContactListItemView) view; + String alphabetHeader = null; + if (mNeedAlphabetHeader) { + final int position = cursor.getPosition(); + final int section = mSectionIndexer.getSectionForPosition(position); + // Check if the position is the first in the section. + if (mSectionIndexer.getPositionForSection(section) == position) { + alphabetHeader = (String) mSectionIndexer.getSections()[section]; + } + } + contactListItemView.bind(cursor, mClivHostInterface, mNeedAlphabetHeader, alphabetHeader); + } + + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + return layoutInflater.inflate(R.layout.contact_list_item_view, parent, false); + } + + @Override + public Cursor swapCursor(final Cursor newCursor) { + mSectionIndexer = new ContactSectionIndexer(newCursor); + return super.swapCursor(newCursor); + } + + @Override + public Object[] getSections() { + return mSectionIndexer.getSections(); + } + + @Override + public int getPositionForSection(final int sectionIndex) { + return mSectionIndexer.getPositionForSection(sectionIndex); + } + + @Override + public int getSectionForPosition(final int position) { + return mSectionIndexer.getSectionForPosition(position); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactListItemView.java b/src/com/android/messaging/ui/contact/ContactListItemView.java new file mode 100644 index 0000000..6904da6 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactListItemView.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * The view for a single entry in a contact list. + */ +public class ContactListItemView extends LinearLayout implements OnClickListener { + public interface HostInterface { + void onContactListItemClicked(ContactListItemData item, ContactListItemView view); + boolean isContactSelected(ContactListItemData item); + } + + @VisibleForTesting + final ContactListItemData mData; + private TextView mContactNameTextView; + private TextView mContactDetailsTextView; + private TextView mContactDetailTypeTextView; + private TextView mAlphabetHeaderTextView; + private ContactIconView mContactIconView; + private ImageView mContactCheckmarkView; + private HostInterface mHostInterface; + private boolean mShouldShowAlphabetHeader; + + public ContactListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createContactListItemData(); + } + + @Override + protected void onFinishInflate () { + mContactNameTextView = (TextView) findViewById(R.id.contact_name); + mContactDetailsTextView = (TextView) findViewById(R.id.contact_details); + mContactDetailTypeTextView = (TextView) findViewById(R.id.contact_detail_type); + mAlphabetHeaderTextView = (TextView) findViewById(R.id.alphabet_header); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + mContactCheckmarkView = (ImageView) findViewById(R.id.contact_checkmark); + } + + /** + * Fills in the data associated with this view by binding to a contact cursor provided by + * ContactUtil. + * @param cursor the contact cursor. + * @param hostInterface host interface to this view. + * @param shouldShowAlphabetHeader whether an alphabetical header should shown on the side + * of this view. If {@code headerLabel} is empty, we will still leave space for it. + * @param headerLabel the alphabetical header on the side of this view, if it should be shown. + */ + public void bind(final Cursor cursor, final HostInterface hostInterface, + final boolean shouldShowAlphabetHeader, final String headerLabel) { + mData.bind(cursor, headerLabel); + mHostInterface = hostInterface; + mShouldShowAlphabetHeader = shouldShowAlphabetHeader; + setOnClickListener(this); + updateViewAppearance(); + } + + /** + * Binds a RecipientEntry. This is used by the chips text view's dropdown layout. + * @param recipientEntry the source RecipientEntry provided by ContactDropdownLayouter, which + * was in turn directly from one of the existing chips, or from filtered results + * generated by ContactRecipientAdapter. + * @param styledName display name where the portion that matches the search text is bold. + * @param styledDestination number where the portion that matches the search text is bold. + * @param hostInterface host interface to this view. + * @param isSingleRecipient whether this item is shown as the only line item in the single + * recipient drop down from the chips view. If this is the case, we always show the + * contact avatar even if it's not a first-level entry. + */ + public void bind(final RecipientEntry recipientEntry, final CharSequence styledName, + final CharSequence styledDestination, final HostInterface hostInterface, + final boolean isSingleRecipient) { + mData.bind(recipientEntry, styledName, styledDestination, isSingleRecipient); + mHostInterface = hostInterface; + mShouldShowAlphabetHeader = false; + updateViewAppearance(); + } + + private void updateViewAppearance() { + mContactNameTextView.setText(mData.getDisplayName()); + mContactDetailsTextView.setText(mData.getDestination()); + mContactDetailTypeTextView.setText(Phone.getTypeLabel(getResources(), + mData.getDestinationType(), mData.getDestinationLabel())); + final RecipientEntry recipientEntry = mData.getRecipientEntry(); + final String destinationString = String.valueOf(mData.getDestination()); + if (mData.getIsSimpleContactItem()) { + // This is a special number-with-avatar type of contact (for unknown contact chips + // and for direct "send to destination" item). In this case, make sure we only show + // the display name (phone number) and the avatar and hide everything else. + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(recipientEntry)); + mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(), + mData.getLookupKey(), destinationString); + mContactIconView.setVisibility(VISIBLE); + mContactCheckmarkView.setVisibility(GONE); + mContactDetailTypeTextView.setVisibility(GONE); + mContactDetailsTextView.setVisibility(GONE); + mContactNameTextView.setVisibility(VISIBLE); + } else if (mData.getIsFirstLevel()) { + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(recipientEntry)); + mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(), + mData.getLookupKey(), destinationString); + mContactIconView.setVisibility(VISIBLE); + mContactNameTextView.setVisibility(VISIBLE); + final boolean isSelected = mHostInterface.isContactSelected(mData); + setSelected(isSelected); + mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); + mContactDetailsTextView.setVisibility(VISIBLE); + mContactDetailTypeTextView.setVisibility(VISIBLE); + } else { + mContactIconView.setImageResourceUri(null); + mContactIconView.setVisibility(INVISIBLE); + mContactNameTextView.setVisibility(GONE); + final boolean isSelected = mHostInterface.isContactSelected(mData); + setSelected(isSelected); + mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); + mContactDetailsTextView.setVisibility(VISIBLE); + mContactDetailTypeTextView.setVisibility(VISIBLE); + } + + if (mShouldShowAlphabetHeader) { + mAlphabetHeaderTextView.setVisibility(VISIBLE); + mAlphabetHeaderTextView.setText(mData.getAlphabetHeader()); + } else { + mAlphabetHeaderTextView.setVisibility(GONE); + } + } + + /** + * {@inheritDoc} from OnClickListener + */ + @Override + public void onClick(final View v) { + Assert.isTrue(v == this); + Assert.isTrue(mHostInterface != null); + mHostInterface.onContactListItemClicked(mData, this); + } + + public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) { + mContactIconView.setImageClickHandlerDisabled(isHandlerDisabled); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactPickerFragment.java b/src/com/android/messaging/ui/contact/ContactPickerFragment.java new file mode 100644 index 0000000..d803087 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactPickerFragment.java @@ -0,0 +1,607 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.app.Activity; +import android.app.Fragment; +import android.database.Cursor; +import android.graphics.Rect; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.transition.Explode; +import android.transition.Transition; +import android.transition.Transition.EpicenterCallback; +import android.transition.TransitionManager; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.CustomHeaderPagerViewHolder; +import com.android.messaging.ui.CustomHeaderViewPager; +import com.android.messaging.ui.animation.ViewGroupItemVerticalExplodeAnimation; +import com.android.messaging.ui.contact.ContactRecipientAutoCompleteView.ContactChipsChangeListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Set; + + +/** + * Shows lists of contacts to start conversations with. + */ +public class ContactPickerFragment extends Fragment implements ContactPickerDataListener, + ContactListItemView.HostInterface, ContactChipsChangeListener, OnMenuItemClickListener, + GetOrCreateConversationActionListener { + public static final String FRAGMENT_TAG = "contactpicker"; + + // Undefined contact picker mode. We should never be in this state after the host activity has + // been created. + public static final int MODE_UNDEFINED = 0; + + // The initial contact picker mode for starting a new conversation with one contact. + public static final int MODE_PICK_INITIAL_CONTACT = 1; + + // The contact picker mode where one initial contact has been picked and we are showing + // only the chips edit box. + public static final int MODE_CHIPS_ONLY = 2; + + // The contact picker mode for picking more contacts after starting the initial 1-1. + public static final int MODE_PICK_MORE_CONTACTS = 3; + + // The contact picker mode when max number of participants is reached. + public static final int MODE_PICK_MAX_PARTICIPANTS = 4; + + public interface ContactPickerFragmentHost { + void onGetOrCreateNewConversation(String conversationId); + void onBackButtonPressed(); + void onInitiateAddMoreParticipants(); + void onParticipantCountChanged(boolean canAddMoreParticipants); + void invalidateActionBar(); + } + + @VisibleForTesting + final Binding<ContactPickerData> mBinding = BindingBase.createBinding(this); + + private ContactPickerFragmentHost mHost; + private ContactRecipientAutoCompleteView mRecipientTextView; + private CustomHeaderViewPager mCustomHeaderViewPager; + private AllContactsListViewHolder mAllContactsListViewHolder; + private FrequentContactsListViewHolder mFrequentContactsListViewHolder; + private View mRootView; + private View mPendingExplodeView; + private View mComposeDivider; + private Toolbar mToolbar; + private int mContactPickingMode = MODE_UNDEFINED; + + // Keeps track of the currently selected phone numbers in the chips view to enable fast lookup. + private Set<String> mSelectedPhoneNumbers = null; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mAllContactsListViewHolder = new AllContactsListViewHolder(getActivity(), this); + mFrequentContactsListViewHolder = new FrequentContactsListViewHolder(getActivity(), this); + + if (ContactUtil.hasReadContactsPermission()) { + mBinding.bind(DataModel.get().createContactPickerData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + } + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.contact_picker_fragment, container, false); + mRecipientTextView = (ContactRecipientAutoCompleteView) + view.findViewById(R.id.recipient_text_view); + mRecipientTextView.setThreshold(0); + mRecipientTextView.setDropDownAnchor(R.id.compose_contact_divider); + + mRecipientTextView.setContactChipsListener(this); + mRecipientTextView.setDropdownChipLayouter(new ContactDropdownLayouter(inflater, + getActivity(), this)); + mRecipientTextView.setAdapter(new ContactRecipientAdapter(getActivity(), this)); + mRecipientTextView.addTextChangedListener(new TextWatcher() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + } + + @Override + public void afterTextChanged(final Editable s) { + updateTextInputButtonsVisibility(); + } + }); + + final CustomHeaderPagerViewHolder[] viewHolders = { + mFrequentContactsListViewHolder, + mAllContactsListViewHolder }; + + mCustomHeaderViewPager = (CustomHeaderViewPager) view.findViewById(R.id.contact_pager); + mCustomHeaderViewPager.setViewHolders(viewHolders); + mCustomHeaderViewPager.setViewPagerTabHeight(CustomHeaderViewPager.DEFAULT_TAB_STRIP_SIZE); + mCustomHeaderViewPager.setBackgroundColor(getResources() + .getColor(R.color.contact_picker_background)); + + // The view pager defaults to the frequent contacts page. + mCustomHeaderViewPager.setCurrentItem(0); + + mToolbar = (Toolbar) view.findViewById(R.id.toolbar); + mToolbar.setNavigationIcon(R.drawable.ic_arrow_back_light); + mToolbar.setNavigationContentDescription(R.string.back); + mToolbar.setNavigationOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHost.onBackButtonPressed(); + } + }); + + mToolbar.inflateMenu(R.menu.compose_menu); + mToolbar.setOnMenuItemClickListener(this); + + mComposeDivider = view.findViewById(R.id.compose_contact_divider); + mRootView = view; + return view; + } + + /** + * {@inheritDoc} + * + * Called when the host activity has been created. At this point, the host activity should + * have set the contact picking mode for us so that we may update our visuals. + */ + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + Assert.isTrue(mContactPickingMode != MODE_UNDEFINED); + updateVisualsForContactPickingMode(false /* animate */); + mHost.invalidateActionBar(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + // We could not have bound to the data if the permission was denied. + if (mBinding.isBound()) { + mBinding.unbind(); + } + + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + } + + @Override + public boolean onMenuItemClick(final MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.action_ime_dialpad_toggle: + final int baseInputType = InputType.TYPE_TEXT_FLAG_MULTI_LINE; + if ((mRecipientTextView.getInputType() & InputType.TYPE_CLASS_PHONE) != + InputType.TYPE_CLASS_PHONE) { + mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_PHONE); + menuItem.setIcon(R.drawable.ic_ime_light); + } else { + mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_TEXT); + menuItem.setIcon(R.drawable.ic_numeric_dialpad); + } + ImeUtil.get().showImeKeyboard(getActivity(), mRecipientTextView); + return true; + + case R.id.action_add_more_participants: + mHost.onInitiateAddMoreParticipants(); + return true; + + case R.id.action_confirm_participants: + maybeGetOrCreateConversation(); + return true; + + case R.id.action_delete_text: + Assert.equals(MODE_PICK_INITIAL_CONTACT, mContactPickingMode); + mRecipientTextView.setText(""); + return true; + } + return false; + } + + @Override // From ContactPickerDataListener + public void onAllContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + mAllContactsListViewHolder.onContactsCursorUpdated(data); + } + + @Override // From ContactPickerDataListener + public void onFrequentContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + mFrequentContactsListViewHolder.onContactsCursorUpdated(data); + if (data != null && data.getCount() == 0) { + // Show the all contacts list when there's no frequents. + mCustomHeaderViewPager.setCurrentItem(1); + } + } + + @Override // From ContactListItemView.HostInterface + public void onContactListItemClicked(final ContactListItemData item, + final ContactListItemView view) { + if (!isContactSelected(item)) { + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + mPendingExplodeView = view; + } + mRecipientTextView.appendRecipientEntry(item.getRecipientEntry()); + } else if (mContactPickingMode != MODE_PICK_INITIAL_CONTACT) { + mRecipientTextView.removeRecipientEntry(item.getRecipientEntry()); + } + } + + @Override // From ContactListItemView.HostInterface + public boolean isContactSelected(final ContactListItemData item) { + return mSelectedPhoneNumbers != null && + mSelectedPhoneNumbers.contains(PhoneUtils.getDefault().getCanonicalBySystemLocale( + item.getRecipientEntry().getDestination())); + } + + /** + * Call this immediately after attaching the fragment, or when there's a ui state change that + * changes our host (i.e. restore from saved instance state). + */ + public void setHost(final ContactPickerFragmentHost host) { + mHost = host; + } + + public void setContactPickingMode(final int mode, final boolean animate) { + if (mContactPickingMode != mode) { + // Guard against impossible transitions. + Assert.isTrue( + // We may start from undefined mode to any mode when we are restoring state. + (mContactPickingMode == MODE_UNDEFINED) || + (mContactPickingMode == MODE_PICK_INITIAL_CONTACT && mode == MODE_CHIPS_ONLY) || + (mContactPickingMode == MODE_CHIPS_ONLY && mode == MODE_PICK_MORE_CONTACTS) || + (mContactPickingMode == MODE_PICK_MORE_CONTACTS + && mode == MODE_PICK_MAX_PARTICIPANTS) || + (mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS + && mode == MODE_PICK_MORE_CONTACTS)); + + mContactPickingMode = mode; + updateVisualsForContactPickingMode(animate); + } + } + + private void showImeKeyboard() { + Assert.notNull(mRecipientTextView); + mRecipientTextView.requestFocus(); + + // showImeKeyboard() won't work until the layout is ready, so wait until layout is complete + // before showing the soft keyboard. + UiUtils.doOnceAfterLayoutChange(mRootView, new Runnable() { + @Override + public void run() { + final Activity activity = getActivity(); + if (activity != null) { + ImeUtil.get().showImeKeyboard(activity, mRecipientTextView); + } + } + }); + mRecipientTextView.invalidate(); + } + + private void updateVisualsForContactPickingMode(final boolean animate) { + // Don't update visuals if the visuals haven't been inflated yet. + if (mRootView != null) { + final Menu menu = mToolbar.getMenu(); + final MenuItem addMoreParticipantsItem = menu.findItem( + R.id.action_add_more_participants); + final MenuItem confirmParticipantsItem = menu.findItem( + R.id.action_confirm_participants); + switch (mContactPickingMode) { + case MODE_PICK_INITIAL_CONTACT: + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(false); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + mRecipientTextView.setEnabled(true); + showImeKeyboard(); + break; + + case MODE_CHIPS_ONLY: + if (animate) { + if (mPendingExplodeView == null) { + // The user didn't click on any contact item, so use the toolbar as + // the view to "explode." + mPendingExplodeView = mToolbar; + } + startExplodeTransitionForContactLists(false /* show */); + + ViewGroupItemVerticalExplodeAnimation.startAnimationForView( + mCustomHeaderViewPager, mPendingExplodeView, mRootView, + true /* snapshotView */, UiUtils.COMPOSE_TRANSITION_DURATION); + showHideContactPagerWithAnimation(false /* show */); + } else { + mCustomHeaderViewPager.setVisibility(View.GONE); + } + + addMoreParticipantsItem.setVisible(true); + confirmParticipantsItem.setVisible(false); + mComposeDivider.setVisibility(View.VISIBLE); + mRecipientTextView.setEnabled(true); + break; + + case MODE_PICK_MORE_CONTACTS: + if (animate) { + // Correctly set the start visibility state for the view pager and + // individual list items (hidden initially), so that the transition + // manager can properly track the visibility change for the explode. + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + toggleContactListItemsVisibilityForPendingTransition(false /* show */); + startExplodeTransitionForContactLists(true /* show */); + } + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(true); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + mRecipientTextView.setEnabled(true); + showImeKeyboard(); + break; + + case MODE_PICK_MAX_PARTICIPANTS: + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(true); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + // TODO: Verify that this is okay for accessibility + mRecipientTextView.setEnabled(false); + break; + + default: + Assert.fail("Unsupported contact picker mode!"); + break; + } + updateTextInputButtonsVisibility(); + } + } + + private void updateTextInputButtonsVisibility() { + final Menu menu = mToolbar.getMenu(); + final MenuItem keypadToggleItem = menu.findItem(R.id.action_ime_dialpad_toggle); + final MenuItem deleteTextItem = menu.findItem(R.id.action_delete_text); + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + if (TextUtils.isEmpty(mRecipientTextView.getText())) { + deleteTextItem.setVisible(false); + keypadToggleItem.setVisible(true); + } else { + deleteTextItem.setVisible(true); + keypadToggleItem.setVisible(false); + } + } else { + deleteTextItem.setVisible(false); + keypadToggleItem.setVisible(false); + } + } + + private void maybeGetOrCreateConversation() { + final ArrayList<ParticipantData> participants = + mRecipientTextView.getRecipientParticipantDataForConversationCreation(); + if (ContactPickerData.isTooManyParticipants(participants.size())) { + UiUtils.showToast(R.string.too_many_participants); + } else if (participants.size() > 0 && mMonitor == null) { + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, + null, this); + } + } + + /** + * Watches changes in contact chips to determine possible state transitions (e.g. creating + * the initial conversation, adding more participants or finish the current conversation) + */ + @Override + public void onContactChipsChanged(final int oldCount, final int newCount) { + Assert.isTrue(oldCount != newCount); + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + // Initial picking mode. Start a conversation once a recipient has been picked. + maybeGetOrCreateConversation(); + } else if (mContactPickingMode == MODE_CHIPS_ONLY) { + // oldCount == 0 means we are restoring from savedInstanceState to add the existing + // chips, don't switch to "add more participants" mode in this case. + if (oldCount > 0 && mRecipientTextView.isFocused()) { + // Chips only mode. The user may have picked an additional contact or deleted the + // only existing contact. Either way, switch to picking more participants mode. + mHost.onInitiateAddMoreParticipants(); + } + } + mHost.onParticipantCountChanged(ContactPickerData.getCanAddMoreParticipants(newCount)); + + // Refresh our local copy of the selected chips set to keep it up-to-date. + mSelectedPhoneNumbers = mRecipientTextView.getSelectedDestinations(); + invalidateContactLists(); + } + + /** + * Listens for notification that invalid contacts have been removed during resolving them. + * These contacts were not local contacts, valid email, or valid phone numbers + */ + @Override + public void onInvalidContactChipsPruned(final int prunedCount) { + Assert.isTrue(prunedCount > 0); + UiUtils.showToast(R.plurals.add_invalid_contact_error, prunedCount); + } + + /** + * Listens for notification that the user has pressed enter/done on the keyboard with all + * contacts in place and we should create or go to the existing conversation now + */ + @Override + public void onEntryComplete() { + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT || + mContactPickingMode == MODE_PICK_MORE_CONTACTS || + mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS) { + // Avoid multiple calls to create in race cases (hit done right after selecting contact) + maybeGetOrCreateConversation(); + } + } + + private void invalidateContactLists() { + mAllContactsListViewHolder.invalidateList(); + mFrequentContactsListViewHolder.invalidateList(); + } + + /** + * Kicks off a scene transition that animates visibility changes of individual contact list + * items via explode animation. + * @param show whether the contact lists are to be shown or hidden. + */ + private void startExplodeTransitionForContactLists(final boolean show) { + if (!OsUtil.isAtLeastL()) { + // Explode animation is not supported pre-L. + return; + } + final Explode transition = new Explode(); + final Rect epicenter = mPendingExplodeView == null ? null : + UiUtils.getMeasuredBoundsOnScreen(mPendingExplodeView); + transition.setDuration(UiUtils.COMPOSE_TRANSITION_DURATION); + transition.setInterpolator(UiUtils.EASE_IN_INTERPOLATOR); + transition.setEpicenterCallback(new EpicenterCallback() { + @Override + public Rect onGetEpicenter(final Transition transition) { + return epicenter; + } + }); + + // Kick off the delayed scene explode transition. Anything happens after this line in this + // method before the next frame will be tracked by the transition manager for visibility + // changes and animated accordingly. + TransitionManager.beginDelayedTransition(mCustomHeaderViewPager, + transition); + + toggleContactListItemsVisibilityForPendingTransition(show); + } + + /** + * Toggle the visibility of contact list items in the contact lists for them to be tracked by + * the transition manager for pending explode transition. + */ + private void toggleContactListItemsVisibilityForPendingTransition(final boolean show) { + if (!OsUtil.isAtLeastL()) { + // Explode animation is not supported pre-L. + return; + } + mAllContactsListViewHolder.toggleVisibilityForPendingTransition(show, mPendingExplodeView); + mFrequentContactsListViewHolder.toggleVisibilityForPendingTransition(show, + mPendingExplodeView); + } + + private void showHideContactPagerWithAnimation(final boolean show) { + final boolean isPagerVisible = (mCustomHeaderViewPager.getVisibility() == View.VISIBLE); + if (show == isPagerVisible) { + return; + } + + mCustomHeaderViewPager.animate().alpha(show ? 1F : 0F) + .setStartDelay(!show ? UiUtils.COMPOSE_TRANSITION_DURATION : 0) + .withStartAction(new Runnable() { + @Override + public void run() { + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mCustomHeaderViewPager.setAlpha(show ? 0F : 1F); + } + }) + .withEndAction(new Runnable() { + @Override + public void run() { + mCustomHeaderViewPager.setVisibility(show ? View.VISIBLE : View.GONE); + mCustomHeaderViewPager.setAlpha(1F); + } + }); + } + + @Override + public void onContactCustomColorLoaded(final ContactPickerData data) { + mBinding.ensureBound(data); + invalidateContactLists(); + } + + public void updateActionBar(final ActionBar actionBar) { + // Hide the action bar for contact picker mode. The custom ToolBar containing chips UI + // etc. will take the spot of the action bar. + actionBar.hide(); + UiUtils.setStatusBarColor(getActivity(), + getResources().getColor(R.color.compose_notification_bar_background)); + } + + private GetOrCreateConversationActionMonitor mMonitor; + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId) { + Assert.isTrue(monitor == mMonitor); + Assert.isTrue(conversationId != null); + + mRecipientTextView.setInputType(InputType.TYPE_TEXT_FLAG_MULTI_LINE | + InputType.TYPE_CLASS_TEXT); + mHost.onGetOrCreateNewConversation(conversationId); + + mMonitor = null; + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data) { + Assert.isTrue(monitor == mMonitor); + LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed"); + mMonitor = null; + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java new file mode 100644 index 0000000..25f422e --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.database.MergeCursor; +import android.support.v4.util.Pair; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; +import android.widget.Filter; + +import com.android.ex.chips.BaseRecipientAdapter; +import com.android.ex.chips.RecipientAlternatesAdapter; +import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle, + * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and + * contact lookup that relies on ContactUtil. It provides data source and filtering ability + * for {@link ContactRecipientAutoCompleteView} + */ +public final class ContactRecipientAdapter extends BaseRecipientAdapter { + public ContactRecipientAdapter(final Context context, + final ContactListItemView.HostInterface clivHost) { + this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost); + } + + public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount, + final int queryMode, final ContactListItemView.HostInterface clivHost) { + super(context, preferredMaxResultCount, queryMode); + setPhotoManager(new ContactRecipientPhotoManager(context, clivHost)); + } + + @Override + public boolean forceShowAddress() { + // We should always use the SingleRecipientAddressAdapter + // And never use the RecipientAlternatesAdapter + return true; + } + + @Override + public Filter getFilter() { + return new ContactFilter(); + } + + /** + * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete + * results. + */ + public class ContactFilter extends Filter { + // Used to sort filtered contacts when it has combined results from email and phone. + private final RecipientEntryComparator mComparator = new RecipientEntryComparator(); + + /** + * Returns a cursor containing the filtered results in contacts given the search text, + * and a boolean indicating whether the results are sorted. + * + * The queries are synchronously performed since this is not run on the main thread. + * + * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS. + * If this is the case, perform two queries on phone number followed by email and + * return the merged results. + */ + @DoesNotRunOnMainThread + private Pair<Cursor, Boolean> getFilteredResultsCursor(final Context context, + final String searchText) { + Assert.isNotMainThread(); + if (BugleGservices.get().getBoolean( + BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS, + BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) { + return Pair.create((Cursor) new MergeCursor(new Cursor[] { + ContactUtil.filterPhones(getContext(), searchText) + .performSynchronousQuery(), + ContactUtil.filterEmails(getContext(), searchText) + .performSynchronousQuery() + }), false /* the merged cursor is not sorted */); + } else { + return Pair.create(ContactUtil.filterDestination(getContext(), searchText) + .performSynchronousQuery(), true); + } + } + + @Override + protected FilterResults performFiltering(final CharSequence constraint) { + Assert.isNotMainThread(); + final FilterResults results = new FilterResults(); + + // No query, return empty results. + if (TextUtils.isEmpty(constraint)) { + clearTempEntries(); + return results; + } + + final String searchText = constraint.toString(); + + // Query for auto-complete results, since performFiltering() is not done on the + // main thread, perform the cursor loader queries directly. + final Pair<Cursor, Boolean> filteredResults = getFilteredResultsCursor(getContext(), + searchText); + final Cursor cursor = filteredResults.first; + final boolean sorted = filteredResults.second; + if (cursor != null) { + try { + final List<RecipientEntry> entries = new ArrayList<RecipientEntry>(); + + // First check if the constraint is a valid SMS destination. If so, add the + // destination as a suggestion item to the drop down. + if (PhoneUtils.isValidSmsMmsDestination(searchText)) { + entries.add(ContactRecipientEntryUtils + .constructSendToDestinationEntry(searchText)); + } + + HashSet<Long> existingContactIds = new HashSet<Long>(); + while (cursor.moveToNext()) { + // Make sure there's only one first-level contact (i.e. contact for which + // we show the avatar picture and name) for every contact id. + final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + final boolean isFirstLevel = !existingContactIds.contains(contactId); + if (isFirstLevel) { + existingContactIds.add(contactId); + } + entries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor, + isFirstLevel)); + } + + if (!sorted) { + Collections.sort(entries, mComparator); + } + results.values = entries; + results.count = 1; + + } finally { + cursor.close(); + } + } + return results; + } + + @Override + protected void publishResults(final CharSequence constraint, final FilterResults results) { + mCurrentConstraint = constraint; + clearTempEntries(); + + if (results.values != null) { + @SuppressWarnings("unchecked") + final List<RecipientEntry> entries = (List<RecipientEntry>) results.values; + updateEntries(entries); + } else { + updateEntries(Collections.<RecipientEntry>emptyList()); + } + } + + private class RecipientEntryComparator implements Comparator<RecipientEntry> { + private final Collator mCollator; + + public RecipientEntryComparator() { + mCollator = Collator.getInstance(Locale.getDefault()); + mCollator.setStrength(Collator.PRIMARY); + } + + /** + * Compare two RecipientEntry's, first by locale-aware display name comparison, then by + * contact id comparison, finally by first-level-ness comparison. + */ + @Override + public int compare(RecipientEntry lhs, RecipientEntry rhs) { + // Send-to-destinations always appear before everything else. + final boolean sendToLhs = ContactRecipientEntryUtils + .isSendToDestinationContact(lhs); + final boolean sendToRhs = ContactRecipientEntryUtils + .isSendToDestinationContact(lhs); + if (sendToLhs != sendToRhs) { + if (sendToLhs) { + return -1; + } else if (sendToRhs) { + return 1; + } + } + + final int displayNameCompare = mCollator.compare(lhs.getDisplayName(), + rhs.getDisplayName()); + if (displayNameCompare != 0) { + return displayNameCompare; + } + + // Long.compare could accomplish the following three lines, but this is only + // available in API 19+ + final long lhsContactId = lhs.getContactId(); + final long rhsContactId = rhs.getContactId(); + final int contactCompare = lhsContactId < rhsContactId ? -1 : + (lhsContactId == rhsContactId ? 0 : 1); + if (contactCompare != 0) { + return contactCompare; + } + + // These are the same contact. Make sure first-level contacts always + // appear at the front. + if (lhs.isFirstLevel()) { + return -1; + } else if (rhs.isFirstLevel()) { + return 1; + } else { + return 0; + } + } + } + } + + /** + * Called when we need to substitute temporary recipient chips with better alternatives. + * For example, if a list of comma-delimited phone numbers are pasted into the edit box, + * we want to be able to look up in the ContactUtil for exact matches and get contact + * details such as name and photo thumbnail for the contact to display a better chip. + */ + @Override + public void getMatchingRecipients(final ArrayList<String> inAddresses, + final RecipientMatchCallback callback) { + final int addressesSize = Math.min( + RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size()); + final HashSet<String> addresses = new HashSet<String>(); + for (int i = 0; i < addressesSize; i++) { + final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); + addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); + } + + final Map<String, RecipientEntry> recipientEntries = + new HashMap<String, RecipientEntry>(); + // query for each address + for (final String address : addresses) { + final Cursor cursor = ContactUtil.lookupDestination(getContext(), address) + .performSynchronousQuery(); + if (cursor != null) { + try { + if (cursor.moveToNext()) { + // There may be multiple matches to the same number, always take the + // first match. + // TODO: May need to consider if there's an existing conversation + // that matches this particular contact and prioritize that contact. + final RecipientEntry entry = + ContactUtil.createRecipientEntryForPhoneQuery(cursor, true); + recipientEntries.put(address, entry); + } + + } finally { + cursor.close(); + } + } + } + + // report matches + callback.matchesFound(recipientEntries); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java new file mode 100644 index 0000000..c7c2731 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.os.AsyncTask; +import android.support.v7.appcompat.R; +import android.text.Editable; +import android.text.TextPaint; +import android.text.TextWatcher; +import android.text.util.Rfc822Tokenizer; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEditTextView; +import com.android.ex.chips.RecipientEntry; +import com.android.ex.chips.recipientchip.DrawableRecipientChip; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips. + * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of + * recipients in the form of a ParticipantData list. + */ +public class ContactRecipientAutoCompleteView extends RecipientEditTextView { + public interface ContactChipsChangeListener { + void onContactChipsChanged(int oldCount, int newCount); + void onInvalidContactChipsPruned(int prunedCount); + void onEntryComplete(); + } + + private final int mTextHeight; + private ContactChipsChangeListener mChipsChangeListener; + + /** + * Watches changes in contact chips to determine possible state transitions. + */ + private class ContactChipsWatcher implements TextWatcher { + /** + * Tracks the old chips count before text changes. Note that we currently don't compare + * the entire chip sets but just the cheaper-to-do before and after counts, because + * the chips view don't allow for replacing chips. + */ + private int mLastChipsCount = 0; + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + // We don't take mLastChipsCount from here but from the last afterTextChanged() run. + // The reason is because at this point, any chip spans to be removed is already removed + // from s in the chips text view. + } + + @Override + public void afterTextChanged(final Editable s) { + final int currentChipsCount = s.getSpans(0, s.length(), + DrawableRecipientChip.class).length; + if (currentChipsCount != mLastChipsCount) { + // When a sanitizing task is running, we don't want to notify any chips count + // change, but we do want to track the last chip count. + if (mChipsChangeListener != null && mCurrentSanitizeTask == null) { + mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount); + } + mLastChipsCount = currentChipsCount; + } + } + } + + private static final String TEXT_HEIGHT_SAMPLE = "a"; + + public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) { + super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs); + + // Get the height of the text, given the currently set font face and size. + final Rect textBounds = new Rect(0, 0, 0, 0); + final TextPaint paint = getPaint(); + paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds); + mTextHeight = textBounds.height(); + + setTokenizer(new Rfc822Tokenizer()); + addTextChangedListener(new ContactChipsWatcher()); + setOnFocusListShrinkRecipients(false); + + setBackground(context.getResources().getDrawable( + R.drawable.abc_textfield_search_default_mtrl_alpha)); + } + + public void setContactChipsListener(final ContactChipsChangeListener listener) { + mChipsChangeListener = listener; + } + + /** + * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the + * chip actually replaced/removed on the UI thread. + */ + private class ChipReplacementTuple { + public final DrawableRecipientChip removedChip; + public final RecipientEntry replacedChipEntry; + + public ChipReplacementTuple(final DrawableRecipientChip removedChip, + final RecipientEntry replacedChipEntry) { + this.removedChip = removedChip; + this.replacedChipEntry = replacedChipEntry; + } + } + + /** + * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new + * conversation with the given chips). + */ + private class AsyncContactChipSanitizeTask extends + AsyncTask<Void, ChipReplacementTuple, Integer> { + + @Override + protected Integer doInBackground(final Void... params) { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + int invalidChipsRemoved = 0; + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null) { + if (entry.isValid()) { + if (RecipientEntry.isCreatedRecipient(entry.getContactId()) || + ContactRecipientEntryUtils.isSendToDestinationContact(entry)) { + // This is a generated/send-to contact chip, try to look it up and + // display a chip for the corresponding local contact. + final Cursor lookupResult = ContactUtil.lookupDestination(getContext(), + entry.getDestination()).performSynchronousQuery(); + if (lookupResult != null && lookupResult.moveToNext()) { + // Found a match, remove the generated entry and replace with + // a better local entry. + publishProgress(new ChipReplacementTuple(recipient, + ContactUtil.createRecipientEntryForPhoneQuery( + lookupResult, true))); + } else if (PhoneUtils.isValidSmsMmsDestination( + entry.getDestination())){ + // No match was found, but we have a valid destination so let's at + // least create an entry that shows an avatar. + publishProgress(new ChipReplacementTuple(recipient, + ContactRecipientEntryUtils.constructNumberWithAvatarEntry( + entry.getDestination()))); + } else { + // Not a valid contact. Remove and show an error. + publishProgress(new ChipReplacementTuple(recipient, null)); + invalidChipsRemoved++; + } + } + } else { + publishProgress(new ChipReplacementTuple(recipient, null)); + invalidChipsRemoved++; + } + } + } + return invalidChipsRemoved; + } + + @Override + protected void onProgressUpdate(final ChipReplacementTuple... values) { + for (final ChipReplacementTuple tuple : values) { + if (tuple.removedChip != null) { + final Editable text = getText(); + final int chipStart = text.getSpanStart(tuple.removedChip); + final int chipEnd = text.getSpanEnd(tuple.removedChip); + if (chipStart >= 0 && chipEnd >= 0) { + text.delete(chipStart, chipEnd); + } + + if (tuple.replacedChipEntry != null) { + appendRecipientEntry(tuple.replacedChipEntry); + } + } + } + } + + @Override + protected void onPostExecute(final Integer invalidChipsRemoved) { + mCurrentSanitizeTask = null; + if (invalidChipsRemoved > 0) { + mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved); + } + } + } + + /** + * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that + * all sanitization tasks are serially executed so as not to interfere with each other. + */ + private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor(); + + private AsyncContactChipSanitizeTask mCurrentSanitizeTask; + + /** + * Whenever the caller wants to start a new conversation with the list of chips we have, + * make sure we asynchronously: + * 1. Remove invalid chips. + * 2. Attempt to resolve unknown contacts to known local contacts. + * 3. Convert still unknown chips to chips with generated avatar. + * + * Note that we don't need to perform this synchronously since we can + * resolve any unknown contacts to local contacts when needed. + */ + private void sanitizeContactChips() { + if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) { + mCurrentSanitizeTask.cancel(false); + mCurrentSanitizeTask = null; + } + mCurrentSanitizeTask = new AsyncContactChipSanitizeTask(); + mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR); + } + + /** + * Returns a list of ParticipantData from the entered chips in order to create + * new conversation. + */ + public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + final ArrayList<ParticipantData> contacts = + new ArrayList<ParticipantData>(recips.length); + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null && entry.isValid() && entry.getDestination() != null && + PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) { + contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry())); + } + } + sanitizeContactChips(); + return contacts; + } + + /**c + * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the + * consumer with determining quickly whether a contact is currently selected. + */ + public Set<String> getSelectedDestinations() { + Set<String> set = new HashSet<String>(); + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null && entry.isValid() && entry.getDestination() != null) { + set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale( + entry.getDestination())); + } + } + return set; + } + + @Override + public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE) { + mChipsChangeListener.onEntryComplete(); + } + return super.onEditorAction(view, actionId, event); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java new file mode 100644 index 0000000..d69ba64 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; +import android.net.Uri; + +import com.android.ex.chips.PhotoManager; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; + +/** + * An implementation of {@link PhotoManager} that hooks up the chips UI's photos with our own + * {@link MediaResourceManager} for retrieving and caching contact avatars. + */ +public class ContactRecipientPhotoManager implements PhotoManager { + private static final String IMAGE_BYTES_REQUEST_STATIC_BINDING_ID = "imagebytes"; + private final Context mContext; + private final int mIconSize; + private final ContactListItemView.HostInterface mClivHostInterface; + + public ContactRecipientPhotoManager(final Context context, + final ContactListItemView.HostInterface clivHostInterface) { + mContext = context; + mIconSize = context.getResources().getDimensionPixelSize( + R.dimen.compose_message_chip_height) - context.getResources().getDimensionPixelSize( + R.dimen.compose_message_chip_padding) * 2; + mClivHostInterface = clivHostInterface; + } + + /** + * {@inheritDoc} + */ + @Override + public void populatePhotoBytesAsync(final RecipientEntry entry, + final PhotoManagerCallback callback) { + // Post all media resource request to the main thread. + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(entry)); + final AvatarRequestDescriptor descriptor = + new AvatarRequestDescriptor(avatarUri, mIconSize, mIconSize); + final BindableMediaRequest<ImageResource> req = descriptor.buildAsyncMediaRequest( + mContext, + new MediaResourceLoadListener<ImageResource>() { + @Override + public void onMediaResourceLoaded(final MediaRequest<ImageResource> request, + final ImageResource resource, final boolean isCached) { + entry.setPhotoBytes(resource.getBytes()); + callback.onPhotoBytesAsynchronouslyPopulated(); + } + + @Override + public void onMediaResourceLoadError(final MediaRequest<ImageResource> request, + final Exception exception) { + LogUtil.e(LogUtil.BUGLE_TAG, "Photo bytes loading failed due to " + + exception + " request key=" + request.getKey()); + + // Fall back to the default avatar image. + callback.onPhotoBytesAsyncLoadFailed(); + }}); + + // Statically bind the request since it's not bound to any specific piece of UI. + req.bind(IMAGE_BYTES_REQUEST_STATIC_BINDING_ID); + + Factory.get().getMediaResourceManager().requestMediaResourceAsync(req); + } + }); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactSectionIndexer.java b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java new file mode 100644 index 0000000..1d5abf3 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.database.Cursor; +import android.os.Bundle; +import android.provider.ContactsContract.Contacts; +import android.text.TextUtils; +import android.widget.SectionIndexer; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Indexes contact alphabetical sections so we can report to the fast scrolling list view + * where we are in the list when the user scrolls through the contact list, allowing us to show + * alphabetical indicators for the fast scroller as well as list section headers. + */ +public class ContactSectionIndexer implements SectionIndexer { + private String[] mSections; + private ArrayList<Integer> mSectionStartingPositions; + private static final String BLANK_HEADER_STRING = " "; + + public ContactSectionIndexer(final Cursor contactsCursor) { + buildIndexer(contactsCursor); + } + + @Override + public Object[] getSections() { + return mSections; + } + + @Override + public int getPositionForSection(final int sectionIndex) { + if (mSectionStartingPositions.isEmpty()) { + return 0; + } + // Clamp to the bounds of the section position array per Android API doc. + return mSectionStartingPositions.get( + Math.max(Math.min(sectionIndex, mSectionStartingPositions.size() - 1), 0)); + } + + @Override + public int getSectionForPosition(final int position) { + if (mSectionStartingPositions.isEmpty()) { + return 0; + } + + // Perform a binary search on the starting positions of the sections to the find the + // section for the position. + int left = 0; + int right = mSectionStartingPositions.size() - 1; + + // According to getSectionForPosition()'s doc, we should always clamp the value when the + // position is out of bound. + if (position <= mSectionStartingPositions.get(left)) { + return left; + } else if (position >= mSectionStartingPositions.get(right)) { + return right; + } + + while (left <= right) { + final int mid = (left + right) / 2; + final int startingPos = mSectionStartingPositions.get(mid); + final int nextStartingPos = mSectionStartingPositions.get(mid + 1); + if (position >= startingPos && position < nextStartingPos) { + return mid; + } else if (position < startingPos) { + right = mid - 1; + } else if (position >= nextStartingPos) { + left = mid + 1; + } + } + Assert.fail("Invalid section indexer state: couldn't find section for pos " + position); + return -1; + } + + private boolean buildIndexerFromCursorExtras(final Cursor cursor) { + if (cursor == null) { + return false; + } + final Bundle cursorExtras = cursor.getExtras(); + if (cursorExtras == null) { + return false; + } + final String[] sections = cursorExtras.getStringArray( + Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); + final int[] counts = cursorExtras.getIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); + if (sections == null || counts == null) { + return false; + } + + if (sections.length != counts.length) { + return false; + } + + this.mSections = sections; + mSectionStartingPositions = new ArrayList<Integer>(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(); + } + + mSectionStartingPositions.add(position); + position += counts[i]; + } + return true; + } + + private void buildIndexerFromDisplayNames(final Cursor cursor) { + // Loop through the contact cursor and get the starting position for each first character. + // The result is stored into two arrays, one for the section header (i.e. the first + // character), and one for the starting position, which is guaranteed to be sorted in + // ascending order. + final ArrayList<String> sections = new ArrayList<String>(); + mSectionStartingPositions = new ArrayList<Integer>(); + if (cursor != null) { + cursor.moveToPosition(-1); + int currentPosition = 0; + while (cursor.moveToNext()) { + // The sort key is typically the contact's display name, so for example, a contact + // named "Bob" will go into section "B". The Contacts provider generally uses a + // a slightly more sophisticated heuristic, but as a fallback this is good enough. + final String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY); + final String section = TextUtils.isEmpty(sortKey) ? BLANK_HEADER_STRING : + sortKey.substring(0, 1).toUpperCase(); + + final int lastIndex = sections.size() - 1; + final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null; + if (!TextUtils.equals(currentSection, section)) { + sections.add(section); + mSectionStartingPositions.add(currentPosition); + } + currentPosition++; + } + } + mSections = new String[sections.size()]; + sections.toArray(mSections); + } + + private void buildIndexer(final Cursor cursor) { + // First check if we get indexer label extras from the contact provider; if not, fall back + // to building from display names. + if (!buildIndexerFromCursorExtras(cursor)) { + LogUtil.w(LogUtil.BUGLE_TAG, "contact provider didn't provide contact label " + + "information, fall back to using display name!"); + buildIndexerFromDisplayNames(cursor); + } + } +} diff --git a/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java new file mode 100644 index 0000000..1f3c795 --- /dev/null +++ b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 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.messaging.ui.contact; + +import android.content.Context; + +import com.android.messaging.R; +import com.android.messaging.ui.CustomHeaderPagerListViewHolder; +import com.android.messaging.ui.contact.ContactListItemView.HostInterface; + +/** + * Holds the frequent contacts view for the contact picker's view pager. + */ +public class FrequentContactsListViewHolder extends CustomHeaderPagerListViewHolder { + public FrequentContactsListViewHolder(final Context context, + final HostInterface clivHostInterface) { + super(context, new ContactListAdapter(context, null, clivHostInterface, + false /* needAlphabetHeader */)); + } + + @Override + protected int getLayoutResId() { + return R.layout.frequent_contacts_list_view; + } + + @Override + protected int getPageTitleResId() { + return R.string.contact_picker_frequents_tab_title; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return R.id.frequent_contacts_list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} |