summaryrefslogtreecommitdiffstats
path: root/src/com/android/messaging/ui/conversation
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/messaging/ui/conversation')
-rw-r--r--src/com/android/messaging/ui/conversation/ComposeMessageView.java962
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationActivity.java379
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationActivityUiState.java306
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationFastScroller.java489
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationFragment.java1662
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationInput.java103
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationInputManager.java550
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java117
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java132
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageView.java1206
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationSimSelector.java128
-rw-r--r--src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java92
-rw-r--r--src/com/android/messaging/ui/conversation/LaunchConversationActivity.java134
-rw-r--r--src/com/android/messaging/ui/conversation/MessageBubbleBackground.java47
-rw-r--r--src/com/android/messaging/ui/conversation/MessageDetailsDialog.java381
-rw-r--r--src/com/android/messaging/ui/conversation/SimIconView.java51
-rw-r--r--src/com/android/messaging/ui/conversation/SimSelectorItemView.java90
-rw-r--r--src/com/android/messaging/ui/conversation/SimSelectorView.java169
18 files changed, 6998 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/conversation/ComposeMessageView.java b/src/com/android/messaging/ui/conversation/ComposeMessageView.java
new file mode 100644
index 0000000..17f8f74
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ComposeMessageView.java
@@ -0,0 +1,962 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.text.Editable;
+import android.text.Html;
+import android.text.InputFilter;
+import android.text.InputFilter.LengthFilter;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.view.ContextThemeWrapper;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.inputmethod.EditorInfo;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask;
+import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.sms.MmsConfig;
+import com.android.messaging.ui.AttachmentPreview;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.PlainTextEditText;
+import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.MediaUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * This view contains the UI required to generate and send messages.
+ */
+public class ComposeMessageView extends LinearLayout
+ implements TextView.OnEditorActionListener, DraftMessageDataListener, TextWatcher,
+ ConversationInputSink {
+
+ public interface IComposeMessageViewHost extends
+ DraftMessageData.DraftMessageSubscriptionDataProvider {
+ void sendMessage(MessageData message);
+ void onComposeEditTextFocused();
+ void onAttachmentsCleared();
+ void onAttachmentsChanged(final boolean haveAttachments);
+ void displayPhoto(Uri photoUri, Rect imageBounds, boolean isDraft);
+ void promptForSelfPhoneNumber();
+ boolean isReadyForAction();
+ void warnOfMissingActionConditions(final boolean sending,
+ final Runnable commandToRunAfterActionConditionResolved);
+ void warnOfExceedingMessageLimit(final boolean showAttachmentChooser,
+ boolean tooManyVideos);
+ void notifyOfAttachmentLoadFailed();
+ void showAttachmentChooser();
+ boolean shouldShowSubjectEditor();
+ boolean shouldHideAttachmentsWhenSimSelectorShown();
+ Uri getSelfSendButtonIconUri();
+ int overrideCounterColor();
+ int getAttachmentsClearedFlags();
+ }
+
+ public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10;
+
+ // There is no draft and there is no need for the SIM selector
+ private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1;
+ // There is no draft but we need to show the SIM selector
+ private static final int SEND_WIDGET_MODE_SIM_SELECTOR = 2;
+ // There is a draft
+ private static final int SEND_WIDGET_MODE_SEND_BUTTON = 3;
+
+ private PlainTextEditText mComposeEditText;
+ private PlainTextEditText mComposeSubjectText;
+ private TextView mCharCounter;
+ private TextView mMmsIndicator;
+ private SimIconView mSelfSendIcon;
+ private ImageButton mSendButton;
+ private View mSubjectView;
+ private ImageButton mDeleteSubjectButton;
+ private AttachmentPreview mAttachmentPreview;
+ private ImageButton mAttachMediaButton;
+
+ private final Binding<DraftMessageData> mBinding;
+ private IComposeMessageViewHost mHost;
+ private final Context mOriginalContext;
+ private int mSendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
+
+ // Shared data model object binding from the conversation.
+ private ImmutableBindingRef<ConversationData> mConversationDataModel;
+
+ // Centrally manages all the mutual exclusive UI components accepting user input, i.e.
+ // media picker, IME keyboard and SIM selector.
+ private ConversationInputManager mInputManager;
+
+ private final ConversationDataListener mDataListener = new SimpleConversationDataListener() {
+ @Override
+ public void onConversationMetadataUpdated(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override
+ public void onConversationParticipantDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ updateOnSelfSubscriptionChange();
+ updateVisualsOnDraftChanged();
+ }
+ };
+
+ public ComposeMessageView(final Context context, final AttributeSet attrs) {
+ super(new ContextThemeWrapper(context, R.style.ColorAccentBlueOverrideStyle), attrs);
+ mOriginalContext = context;
+ mBinding = BindingBase.createBinding(this);
+ }
+
+ /**
+ * Host calls this to bind view to DraftMessageData object
+ */
+ public void bind(final DraftMessageData data, final IComposeMessageViewHost host) {
+ mHost = host;
+ mBinding.bind(data);
+ data.addListener(this);
+ data.setSubscriptionDataProvider(host);
+
+ final int counterColor = mHost.overrideCounterColor();
+ if (counterColor != -1) {
+ mCharCounter.setTextColor(counterColor);
+ }
+ }
+
+ /**
+ * Host calls this to unbind view
+ */
+ public void unbind() {
+ mBinding.unbind();
+ mHost = null;
+ mInputManager.onDetach();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mComposeEditText = (PlainTextEditText) findViewById(
+ R.id.compose_message_text);
+ mComposeEditText.setOnEditorActionListener(this);
+ mComposeEditText.addTextChangedListener(this);
+ mComposeEditText.setOnFocusChangeListener(new OnFocusChangeListener() {
+ @Override
+ public void onFocusChange(final View v, final boolean hasFocus) {
+ if (v == mComposeEditText && hasFocus) {
+ mHost.onComposeEditTextFocused();
+ }
+ }
+ });
+ mComposeEditText.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View arg0) {
+ if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
+ hideSimSelector();
+ }
+ }
+ });
+
+ // onFinishInflate() is called before self is loaded from db. We set the default text
+ // limit here, and apply the real limit later in updateOnSelfSubscriptionChange().
+ mComposeEditText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
+ .getMaxTextLimit()) });
+
+ mSelfSendIcon = (SimIconView) findViewById(R.id.self_send_icon);
+ mSelfSendIcon.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ boolean shown = mInputManager.toggleSimSelector(true /* animate */,
+ getSelfSubscriptionListEntry());
+ hideAttachmentsWhenShowingSims(shown);
+ }
+ });
+ mSelfSendIcon.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View v) {
+ if (mHost.shouldShowSubjectEditor()) {
+ showSubjectEditor();
+ } else {
+ boolean shown = mInputManager.toggleSimSelector(true /* animate */,
+ getSelfSubscriptionListEntry());
+ hideAttachmentsWhenShowingSims(shown);
+ }
+ return true;
+ }
+ });
+
+ mComposeSubjectText = (PlainTextEditText) findViewById(
+ R.id.compose_subject_text);
+ // We need the listener to change the avatar to the send button when the user starts
+ // typing a subject without a message.
+ mComposeSubjectText.addTextChangedListener(this);
+ // onFinishInflate() is called before self is loaded from db. We set the default text
+ // limit here, and apply the real limit later in updateOnSelfSubscriptionChange().
+ mComposeSubjectText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID)
+ .getMaxSubjectLength())});
+
+ mDeleteSubjectButton = (ImageButton) findViewById(R.id.delete_subject_button);
+ mDeleteSubjectButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ hideSubjectEditor();
+ mComposeSubjectText.setText(null);
+ mBinding.getData().setMessageSubject(null);
+ }
+ });
+
+ mSubjectView = findViewById(R.id.subject_view);
+
+ mSendButton = (ImageButton) findViewById(R.id.send_message_button);
+ mSendButton.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ sendMessageInternal(true /* checkMessageSize */);
+ }
+ });
+ mSendButton.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View arg0) {
+ boolean shown = mInputManager.toggleSimSelector(true /* animate */,
+ getSelfSubscriptionListEntry());
+ hideAttachmentsWhenShowingSims(shown);
+ if (mHost.shouldShowSubjectEditor()) {
+ showSubjectEditor();
+ }
+ return true;
+ }
+ });
+ mSendButton.setAccessibilityDelegate(new AccessibilityDelegate() {
+ @Override
+ public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
+ super.onPopulateAccessibilityEvent(host, event);
+ // When the send button is long clicked, we want TalkBack to announce the real
+ // action (select SIM or edit subject), as opposed to "long press send button."
+ if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_LONG_CLICKED) {
+ event.getText().clear();
+ event.getText().add(getResources()
+ .getText(shouldShowSimSelector(mConversationDataModel.getData()) ?
+ R.string.send_button_long_click_description_with_sim_selector :
+ R.string.send_button_long_click_description_no_sim_selector));
+ // Make this an announcement so TalkBack will read our custom message.
+ event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT);
+ }
+ }
+ });
+
+ mAttachMediaButton =
+ (ImageButton) findViewById(R.id.attach_media_button);
+ mAttachMediaButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View clickView) {
+ // Showing the media picker is treated as starting to compose the message.
+ mInputManager.showHideMediaPicker(true /* show */, true /* animate */);
+ }
+ });
+
+ mAttachmentPreview = (AttachmentPreview) findViewById(R.id.attachment_draft_view);
+ mAttachmentPreview.setComposeMessageView(this);
+
+ mCharCounter = (TextView) findViewById(R.id.char_counter);
+ mMmsIndicator = (TextView) findViewById(R.id.mms_indicator);
+ }
+
+ private void hideAttachmentsWhenShowingSims(final boolean simPickerVisible) {
+ if (!mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
+ return;
+ }
+ final boolean haveAttachments = mBinding.getData().hasAttachments();
+ if (simPickerVisible && haveAttachments) {
+ mHost.onAttachmentsChanged(false);
+ mAttachmentPreview.hideAttachmentPreview();
+ } else {
+ mHost.onAttachmentsChanged(haveAttachments);
+ mAttachmentPreview.onAttachmentsChanged(mBinding.getData());
+ }
+ }
+
+ public void setInputManager(final ConversationInputManager inputManager) {
+ mInputManager = inputManager;
+ }
+
+ public void setConversationDataModel(final ImmutableBindingRef<ConversationData> refDataModel) {
+ mConversationDataModel = refDataModel;
+ mConversationDataModel.getData().addConversationDataListener(mDataListener);
+ }
+
+ ImmutableBindingRef<DraftMessageData> getDraftDataModel() {
+ return BindingBase.createBindingReference(mBinding);
+ }
+
+ // returns true if it actually shows the subject editor and false if already showing
+ private boolean showSubjectEditor() {
+ // show the subject editor
+ if (mSubjectView.getVisibility() == View.GONE) {
+ mSubjectView.setVisibility(View.VISIBLE);
+ mSubjectView.requestFocus();
+ return true;
+ }
+ return false;
+ }
+
+ private void hideSubjectEditor() {
+ mSubjectView.setVisibility(View.GONE);
+ mComposeEditText.requestFocus();
+ }
+
+ /**
+ * {@inheritDoc} from TextView.OnEditorActionListener
+ */
+ @Override // TextView.OnEditorActionListener.onEditorAction
+ public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_SEND) {
+ sendMessageInternal(true /* checkMessageSize */);
+ return true;
+ }
+ return false;
+ }
+
+ private void sendMessageInternal(final boolean checkMessageSize) {
+ LogUtil.i(LogUtil.BUGLE_TAG, "UI initiated message sending in conversation " +
+ mBinding.getData().getConversationId());
+ if (mBinding.getData().isCheckingDraft()) {
+ // Don't send message if we are currently checking draft for sending.
+ LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: still checking draft");
+ return;
+ }
+ // Check the host for pre-conditions about any action.
+ if (mHost.isReadyForAction()) {
+ mInputManager.showHideSimSelector(false /* show */, true /* animate */);
+ final String messageToSend = mComposeEditText.getText().toString();
+ mBinding.getData().setMessageText(messageToSend);
+ final String subject = mComposeSubjectText.getText().toString();
+ mBinding.getData().setMessageSubject(subject);
+ // Asynchronously check the draft against various requirements before sending.
+ mBinding.getData().checkDraftForAction(checkMessageSize,
+ mHost.getConversationSelfSubId(), new CheckDraftTaskCallback() {
+ @Override
+ public void onDraftChecked(DraftMessageData data, int result) {
+ mBinding.ensureBound(data);
+ switch (result) {
+ case CheckDraftForSendTask.RESULT_PASSED:
+ // Continue sending after check succeeded.
+ final MessageData message = mBinding.getData()
+ .prepareMessageForSending(mBinding);
+ if (message != null && message.hasContent()) {
+ playSentSound();
+ mHost.sendMessage(message);
+ hideSubjectEditor();
+ if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) {
+ AccessibilityUtil.announceForAccessibilityCompat(
+ ComposeMessageView.this, null,
+ R.string.sending_message);
+ }
+ }
+ break;
+
+ case CheckDraftForSendTask.RESULT_HAS_PENDING_ATTACHMENTS:
+ // Cannot send while there's still attachment(s) being loaded.
+ UiUtils.showToastAtBottom(
+ R.string.cant_send_message_while_loading_attachments);
+ break;
+
+ case CheckDraftForSendTask.RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS:
+ mHost.promptForSelfPhoneNumber();
+ break;
+
+ case CheckDraftForSendTask.RESULT_MESSAGE_OVER_LIMIT:
+ Assert.isTrue(checkMessageSize);
+ mHost.warnOfExceedingMessageLimit(
+ true /*sending*/, false /* tooManyVideos */);
+ break;
+
+ case CheckDraftForSendTask.RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED:
+ Assert.isTrue(checkMessageSize);
+ mHost.warnOfExceedingMessageLimit(
+ true /*sending*/, true /* tooManyVideos */);
+ break;
+
+ case CheckDraftForSendTask.RESULT_SIM_NOT_READY:
+ // Cannot send if there is no active subscription
+ UiUtils.showToastAtBottom(
+ R.string.cant_send_message_without_active_subscription);
+ break;
+
+ default:
+ break;
+ }
+ }
+ }, mBinding);
+ } else {
+ mHost.warnOfMissingActionConditions(true /*sending*/,
+ new Runnable() {
+ @Override
+ public void run() {
+ sendMessageInternal(checkMessageSize);
+ }
+
+ });
+ }
+ }
+
+ public static void playSentSound() {
+ // Check if this setting is enabled before playing
+ final BuglePrefs prefs = BuglePrefs.getApplicationPrefs();
+ final Context context = Factory.get().getApplicationContext();
+ final String prefKey = context.getString(R.string.send_sound_pref_key);
+ final boolean defaultValue = context.getResources().getBoolean(
+ R.bool.send_sound_pref_default);
+ if (!prefs.getBoolean(prefKey, defaultValue)) {
+ return;
+ }
+ MediaUtil.get().playSound(context, R.raw.message_sent, null /* completionListener */);
+ }
+
+ /**
+ * {@inheritDoc} from DraftMessageDataListener
+ */
+ @Override // From DraftMessageDataListener
+ public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
+ // As this is called asynchronously when message read check bound before updating text
+ mBinding.ensureBound(data);
+
+ // We have to cache the values of the DraftMessageData because when we set
+ // mComposeEditText, its onTextChanged calls updateVisualsOnDraftChanged,
+ // which immediately reloads the text from the subject and message fields and replaces
+ // what's in the DraftMessageData.
+
+ final String subject = data.getMessageSubject();
+ final String message = data.getMessageText();
+
+ if ((changeFlags & DraftMessageData.MESSAGE_SUBJECT_CHANGED) ==
+ DraftMessageData.MESSAGE_SUBJECT_CHANGED) {
+ mComposeSubjectText.setText(subject);
+
+ // Set the cursor selection to the end since setText resets it to the start
+ mComposeSubjectText.setSelection(mComposeSubjectText.getText().length());
+ }
+
+ if ((changeFlags & DraftMessageData.MESSAGE_TEXT_CHANGED) ==
+ DraftMessageData.MESSAGE_TEXT_CHANGED) {
+ mComposeEditText.setText(message);
+
+ // Set the cursor selection to the end since setText resets it to the start
+ mComposeEditText.setSelection(mComposeEditText.getText().length());
+ }
+
+ if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) ==
+ DraftMessageData.ATTACHMENTS_CHANGED) {
+ final boolean haveAttachments = mAttachmentPreview.onAttachmentsChanged(data);
+ mHost.onAttachmentsChanged(haveAttachments);
+ }
+
+ if ((changeFlags & DraftMessageData.SELF_CHANGED) == DraftMessageData.SELF_CHANGED) {
+ updateOnSelfSubscriptionChange();
+ }
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override // From DraftMessageDataListener
+ public void onDraftAttachmentLimitReached(final DraftMessageData data) {
+ mBinding.ensureBound(data);
+ mHost.warnOfExceedingMessageLimit(false /* sending */, false /* tooManyVideos */);
+ }
+
+ private void updateOnSelfSubscriptionChange() {
+ // Refresh the length filters according to the selected self's MmsConfig.
+ mComposeEditText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId())
+ .getMaxTextLimit()) });
+ mComposeSubjectText.setFilters(new InputFilter[] {
+ new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId())
+ .getMaxSubjectLength())});
+ }
+
+ @Override
+ public void onMediaItemsSelected(final Collection<MessagePartData> items) {
+ mBinding.getData().addAttachments(items);
+ announceMediaItemState(true /*isSelected*/);
+ }
+
+ @Override
+ public void onMediaItemsUnselected(final MessagePartData item) {
+ mBinding.getData().removeAttachment(item);
+ announceMediaItemState(false /*isSelected*/);
+ }
+
+ @Override
+ public void onPendingAttachmentAdded(final PendingAttachmentData pendingItem) {
+ mBinding.getData().addPendingAttachment(pendingItem, mBinding);
+ resumeComposeMessage();
+ }
+
+ private void announceMediaItemState(final boolean isSelected) {
+ final Resources res = getContext().getResources();
+ final String announcement = isSelected ? res.getString(
+ R.string.mediapicker_gallery_item_selected_content_description) :
+ res.getString(R.string.mediapicker_gallery_item_unselected_content_description);
+ AccessibilityUtil.announceForAccessibilityCompat(
+ this, null, announcement);
+ }
+
+ private void announceAttachmentState() {
+ if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) {
+ int attachmentCount = mBinding.getData().getReadOnlyAttachments().size()
+ + mBinding.getData().getReadOnlyPendingAttachments().size();
+ final String announcement = getContext().getResources().getQuantityString(
+ R.plurals.attachment_changed_accessibility_announcement,
+ attachmentCount, attachmentCount);
+ AccessibilityUtil.announceForAccessibilityCompat(
+ this, null, announcement);
+ }
+ }
+
+ @Override
+ public void resumeComposeMessage() {
+ mComposeEditText.requestFocus();
+ mInputManager.showHideImeKeyboard(true, true);
+ announceAttachmentState();
+ }
+
+ public void clearAttachments() {
+ mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags());
+ mHost.onAttachmentsCleared();
+ }
+
+ public void requestDraftMessage(boolean clearLocalDraft) {
+ mBinding.getData().loadFromStorage(mBinding, null, clearLocalDraft);
+ }
+
+ public void setDraftMessage(final MessageData message) {
+ mBinding.getData().loadFromStorage(mBinding, message, false);
+ }
+
+ public void writeDraftMessage() {
+ final String messageText = mComposeEditText.getText().toString();
+ mBinding.getData().setMessageText(messageText);
+
+ final String subject = mComposeSubjectText.getText().toString();
+ mBinding.getData().setMessageSubject(subject);
+
+ mBinding.getData().saveToStorage(mBinding);
+ }
+
+ private void updateConversationSelfId(final String selfId, final boolean notify) {
+ mBinding.getData().setSelfId(selfId, notify);
+ }
+
+ private Uri getSelfSendButtonIconUri() {
+ final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri();
+ if (overridenSelfUri != null) {
+ return overridenSelfUri;
+ }
+ final SubscriptionListEntry subscriptionListEntry = getSelfSubscriptionListEntry();
+
+ if (subscriptionListEntry != null) {
+ return subscriptionListEntry.selectedIconUri;
+ }
+
+ // Fall back to default self-avatar in the base case.
+ final ParticipantData self = mConversationDataModel.getData().getDefaultSelfParticipant();
+ return self == null ? null : AvatarUriUtil.createAvatarUri(self);
+ }
+
+ private SubscriptionListEntry getSelfSubscriptionListEntry() {
+ return mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant(
+ mBinding.getData().getSelfId(), false /* excludeDefault */);
+ }
+
+ private boolean isDataLoadedForMessageSend() {
+ // Check data loading prerequisites for sending a message.
+ return mConversationDataModel != null && mConversationDataModel.isBound() &&
+ mConversationDataModel.getData().getParticipantsLoaded();
+ }
+
+ private void updateVisualsOnDraftChanged() {
+ final String messageText = mComposeEditText.getText().toString();
+ final DraftMessageData draftMessageData = mBinding.getData();
+ draftMessageData.setMessageText(messageText);
+
+ final String subject = mComposeSubjectText.getText().toString();
+ draftMessageData.setMessageSubject(subject);
+ if (!TextUtils.isEmpty(subject)) {
+ mSubjectView.setVisibility(View.VISIBLE);
+ }
+
+ final boolean hasMessageText = (TextUtils.getTrimmedLength(messageText) > 0);
+ final boolean hasSubject = (TextUtils.getTrimmedLength(subject) > 0);
+ final boolean hasWorkingDraft = hasMessageText || hasSubject ||
+ mBinding.getData().hasAttachments();
+
+ // Update the SMS text counter.
+ final int messageCount = draftMessageData.getNumMessagesToBeSent();
+ final int codePointsRemaining = draftMessageData.getCodePointsRemainingInCurrentMessage();
+ // Show the counter only if:
+ // - We are not in MMS mode
+ // - We are going to send more than one message OR we are getting close
+ boolean showCounter = false;
+ if (!draftMessageData.getIsMms() && (messageCount > 1 ||
+ codePointsRemaining <= CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN)) {
+ showCounter = true;
+ }
+
+ if (showCounter) {
+ // Update the remaining characters and number of messages required.
+ final String counterText = messageCount > 1 ? codePointsRemaining + " / " +
+ messageCount : String.valueOf(codePointsRemaining);
+ mCharCounter.setText(counterText);
+ mCharCounter.setVisibility(View.VISIBLE);
+ } else {
+ mCharCounter.setVisibility(View.INVISIBLE);
+ }
+
+ // Update the send message button. Self icon uri might be null if self participant data
+ // and/or conversation metadata hasn't been loaded by the host.
+ final Uri selfSendButtonUri = getSelfSendButtonIconUri();
+ int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
+ if (selfSendButtonUri != null) {
+ if (hasWorkingDraft && isDataLoadedForMessageSend()) {
+ UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
+ if (isOverriddenAvatarAGroup()) {
+ // If the host has overriden the avatar to show a group avatar where the
+ // send button sits, we have to hide the group avatar because it can be larger
+ // than the send button and pieces of the avatar will stick out from behind
+ // the send button.
+ UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null);
+ }
+ mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
+ sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
+ } else {
+ mSelfSendIcon.setImageResourceUri(selfSendButtonUri);
+ if (isOverriddenAvatarAGroup()) {
+ UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null);
+ }
+ UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null);
+ mMmsIndicator.setVisibility(INVISIBLE);
+ if (shouldShowSimSelector(mConversationDataModel.getData())) {
+ sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR;
+ }
+ }
+ } else {
+ mSelfSendIcon.setImageResourceUri(null);
+ }
+
+ if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) {
+ setSendButtonAccessibility(sendWidgetMode);
+ mSendWidgetMode = sendWidgetMode;
+ }
+
+ // Update the text hint on the message box depending on the attachment type.
+ final List<MessagePartData> attachments = draftMessageData.getReadOnlyAttachments();
+ final int attachmentCount = attachments.size();
+ if (attachmentCount == 0) {
+ final SubscriptionListEntry subscriptionListEntry =
+ mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant(
+ mBinding.getData().getSelfId(), false /* excludeDefault */);
+ if (subscriptionListEntry == null) {
+ mComposeEditText.setHint(R.string.compose_message_view_hint_text);
+ } else {
+ mComposeEditText.setHint(Html.fromHtml(getResources().getString(
+ R.string.compose_message_view_hint_text_multi_sim,
+ subscriptionListEntry.displayName)));
+ }
+ } else {
+ int type = -1;
+ for (final MessagePartData attachment : attachments) {
+ int newType;
+ if (attachment.isImage()) {
+ newType = ContentType.TYPE_IMAGE;
+ } else if (attachment.isAudio()) {
+ newType = ContentType.TYPE_AUDIO;
+ } else if (attachment.isVideo()) {
+ newType = ContentType.TYPE_VIDEO;
+ } else if (attachment.isVCard()) {
+ newType = ContentType.TYPE_VCARD;
+ } else {
+ newType = ContentType.TYPE_OTHER;
+ }
+
+ if (type == -1) {
+ type = newType;
+ } else if (type != newType || type == ContentType.TYPE_OTHER) {
+ type = ContentType.TYPE_OTHER;
+ break;
+ }
+ }
+
+ switch (type) {
+ case ContentType.TYPE_IMAGE:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_photo, attachmentCount));
+ break;
+
+ case ContentType.TYPE_AUDIO:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_audio, attachmentCount));
+ break;
+
+ case ContentType.TYPE_VIDEO:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_video, attachmentCount));
+ break;
+
+ case ContentType.TYPE_VCARD:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_vcard, attachmentCount));
+ break;
+
+ case ContentType.TYPE_OTHER:
+ mComposeEditText.setHint(getResources().getQuantityString(
+ R.plurals.compose_message_view_hint_text_attachments, attachmentCount));
+ break;
+
+ default:
+ Assert.fail("Unsupported attachment type!");
+ break;
+ }
+ }
+ }
+
+ private void setSendButtonAccessibility(final int sendWidgetMode) {
+ switch (sendWidgetMode) {
+ case SEND_WIDGET_MODE_SELF_AVATAR:
+ // No send button and no SIM selector; the self send button is no longer
+ // important for accessibility.
+ mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mSelfSendIcon.setContentDescription(null);
+ mSendButton.setVisibility(View.GONE);
+ setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR);
+ break;
+
+ case SEND_WIDGET_MODE_SIM_SELECTOR:
+ mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mSelfSendIcon.setContentDescription(getSimContentDescription());
+ setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SIM_SELECTOR);
+ break;
+
+ case SEND_WIDGET_MODE_SEND_BUTTON:
+ mMmsIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mMmsIndicator.setContentDescription(null);
+ setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SEND_BUTTON);
+ break;
+ }
+ }
+
+ private String getSimContentDescription() {
+ final SubscriptionListEntry sub = getSelfSubscriptionListEntry();
+ if (sub != null) {
+ return getResources().getString(
+ R.string.sim_selector_button_content_description_with_selection,
+ sub.displayName);
+ } else {
+ return getResources().getString(
+ R.string.sim_selector_button_content_description);
+ }
+ }
+
+ // Set accessibility traversal order of the components in the send widget.
+ private void setSendWidgetAccessibilityTraversalOrder(final int mode) {
+ if (OsUtil.isAtLeastL_MR1()) {
+ mAttachMediaButton.setAccessibilityTraversalBefore(R.id.compose_message_text);
+ switch (mode) {
+ case SEND_WIDGET_MODE_SIM_SELECTOR:
+ mComposeEditText.setAccessibilityTraversalBefore(R.id.self_send_icon);
+ break;
+ case SEND_WIDGET_MODE_SEND_BUTTON:
+ mComposeEditText.setAccessibilityTraversalBefore(R.id.send_message_button);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+
+ @Override
+ public void afterTextChanged(final Editable editable) {
+ }
+
+ @Override
+ public void beforeTextChanged(final CharSequence s, final int start, final int count,
+ final int after) {
+ if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) {
+ hideSimSelector();
+ }
+ }
+
+ private void hideSimSelector() {
+ if (mInputManager.showHideSimSelector(false /* show */, true /* animate */)) {
+ // Now that the sim selector has been hidden, reshow the attachments if they
+ // have been hidden.
+ hideAttachmentsWhenShowingSims(false /*simPickerVisible*/);
+ }
+ }
+
+ @Override
+ public void onTextChanged(final CharSequence s, final int start, final int before,
+ final int count) {
+ final BugleActionBarActivity activity = (mOriginalContext instanceof BugleActionBarActivity)
+ ? (BugleActionBarActivity) mOriginalContext : null;
+ if (activity != null && activity.getIsDestroyed()) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "got onTextChanged after onDestroy");
+
+ // if we get onTextChanged after the activity is destroyed then, ah, wtf
+ // b/18176615
+ // This appears to have occurred as the result of orientation change.
+ return;
+ }
+
+ mBinding.ensureBound();
+ updateVisualsOnDraftChanged();
+ }
+
+ @Override
+ public PlainTextEditText getComposeEditText() {
+ return mComposeEditText;
+ }
+
+ public void displayPhoto(final Uri photoUri, final Rect imageBounds) {
+ mHost.displayPhoto(photoUri, imageBounds, true /* isDraft */);
+ }
+
+ public void updateConversationSelfIdOnExternalChange(final String selfId) {
+ updateConversationSelfId(selfId, true /* notify */);
+ }
+
+ /**
+ * The selfId of the conversation. As soon as the DraftMessageData successfully loads (i.e.
+ * getSelfId() is non-null), the selfId in DraftMessageData is treated as the sole source
+ * of truth for conversation self id since it reflects any pending self id change the user
+ * makes in the UI.
+ */
+ public String getConversationSelfId() {
+ return mBinding.getData().getSelfId();
+ }
+
+ public void selectSim(SubscriptionListEntry subscriptionData) {
+ final String oldSelfId = getConversationSelfId();
+ final String newSelfId = subscriptionData.selfParticipantId;
+ Assert.notNull(newSelfId);
+ // Don't attempt to change self if self hasn't been loaded, or if self hasn't changed.
+ if (oldSelfId == null || TextUtils.equals(oldSelfId, newSelfId)) {
+ return;
+ }
+ updateConversationSelfId(newSelfId, true /* notify */);
+ }
+
+ public void hideAllComposeInputs(final boolean animate) {
+ mInputManager.hideAllInputs(animate);
+ }
+
+ public void saveInputState(final Bundle outState) {
+ mInputManager.onSaveInputState(outState);
+ }
+
+ public void resetMediaPickerState() {
+ mInputManager.resetMediaPickerState();
+ }
+
+ public boolean onBackPressed() {
+ return mInputManager.onBackPressed();
+ }
+
+ public boolean onNavigationUpPressed() {
+ return mInputManager.onNavigationUpPressed();
+ }
+
+ public boolean updateActionBar(final ActionBar actionBar) {
+ return mInputManager != null ? mInputManager.updateActionBar(actionBar) : false;
+ }
+
+ public static boolean shouldShowSimSelector(final ConversationData convData) {
+ return OsUtil.isAtLeastL_MR1() &&
+ convData.getSelfParticipantsCountExcludingDefault(true /* activeOnly */) > 1;
+ }
+
+ public void sendMessageIgnoreMessageSizeLimit() {
+ sendMessageInternal(false /* checkMessageSize */);
+ }
+
+ public void onAttachmentPreviewLongClicked() {
+ mHost.showAttachmentChooser();
+ }
+
+ @Override
+ public void onDraftAttachmentLoadFailed() {
+ mHost.notifyOfAttachmentLoadFailed();
+ }
+
+ private boolean isOverriddenAvatarAGroup() {
+ final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri();
+ if (overridenSelfUri == null) {
+ return false;
+ }
+ return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri));
+ }
+
+ @Override
+ public void setAccessibility(boolean enabled) {
+ if (enabled) {
+ mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
+ setSendButtonAccessibility(mSendWidgetMode);
+ } else {
+ mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.java b/src/com/android/messaging/ui/conversation/ConversationActivity.java
new file mode 100644
index 0000000..66310ea
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationActivity.java
@@ -0,0 +1,379 @@
+/*
+ * 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.conversation;
+
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.Intent;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.text.TextUtils;
+import android.view.MenuItem;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.contact.ContactPickerFragment;
+import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost;
+import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost;
+import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost;
+import com.android.messaging.ui.conversationlist.ConversationListActivity;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.UiUtils;
+
+public class ConversationActivity extends BugleActionBarActivity
+ implements ContactPickerFragmentHost, ConversationFragmentHost,
+ ConversationActivityUiStateHost {
+ public static final int FINISH_RESULT_CODE = 1;
+ private static final String SAVED_INSTANCE_STATE_UI_STATE_KEY = "uistate";
+
+ private ConversationActivityUiState mUiState;
+
+ // Fragment transactions cannot be performed after onSaveInstanceState() has been called since
+ // it will cause state loss. We don't want to call commitAllowingStateLoss() since it's
+ // dangerous. Therefore, we note when instance state is saved and avoid performing UI state
+ // updates concerning fragments past that point.
+ private boolean mInstanceStateSaved;
+
+ // Tracks whether onPause is called.
+ private boolean mIsPaused;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.conversation_activity);
+
+ final Intent intent = getIntent();
+
+ // Do our best to restore UI state from saved instance state.
+ if (savedInstanceState != null) {
+ mUiState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY);
+ } else {
+ if (intent.
+ getBooleanExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, false)) {
+ // See the comment in BugleWidgetService.getViewMoreConversationsView() why this
+ // is unfortunately necessary. The Bugle desktop widget can display a list of
+ // conversations. When there are more conversations that can be displayed in
+ // the widget, the last item is a "More conversations" item. The way widgets
+ // are built, the list items can only go to a single fill-in intent which points
+ // to this ConversationActivity. When the user taps on "More conversations", we
+ // really want to go to the ConversationList. This code makes that possible.
+ finish();
+ final Intent convListIntent = new Intent(this, ConversationListActivity.class);
+ convListIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(convListIntent);
+ return;
+ }
+ }
+
+ // If saved instance state doesn't offer a clue, get the info from the intent.
+ if (mUiState == null) {
+ final String conversationId = intent.getStringExtra(
+ UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ mUiState = new ConversationActivityUiState(conversationId);
+ }
+ mUiState.setHost(this);
+ mInstanceStateSaved = false;
+
+ // Don't animate UI state change for initial setup.
+ updateUiState(false /* animate */);
+
+ // See if we're getting called from a widget to directly display an image or video
+ final String extraToDisplay =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI);
+ if (!TextUtils.isEmpty(extraToDisplay)) {
+ final String contentType =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE);
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(
+ findViewById(R.id.conversation_and_compose_container));
+ if (ContentType.isImageType(contentType)) {
+ final Uri imagesUri = MessagingContentProvider.buildConversationImagesUri(
+ mUiState.getConversationId());
+ UIIntents.get().launchFullScreenPhotoViewer(
+ this, Uri.parse(extraToDisplay), bounds, imagesUri);
+ } else if (ContentType.isVideoType(contentType)) {
+ UIIntents.get().launchFullScreenVideoViewer(this, Uri.parse(extraToDisplay));
+ }
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ // After onSaveInstanceState() is called, future changes to mUiState won't update the UI
+ // anymore, because fragment transactions are not allowed past this point.
+ // For an activity recreation due to orientation change, the saved instance state keeps
+ // using the in-memory copy of the UI state instead of writing it to parcel as an
+ // optimization, so the UI state values may still change in response to, for example,
+ // focus change from the framework, making mUiState and actual UI inconsistent.
+ // Therefore, save an exact "snapshot" (clone) of the UI state object to make sure the
+ // restored UI state ALWAYS matches the actual restored UI components.
+ outState.putParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY, mUiState.clone());
+ mInstanceStateSaved = true;
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+
+ // we need to reset the mInstanceStateSaved flag since we may have just been restored from
+ // a previous onStop() instead of an onDestroy().
+ mInstanceStateSaved = false;
+ mIsPaused = false;
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ mIsPaused = true;
+ }
+
+ @Override
+ public void onWindowFocusChanged(final boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ final ConversationFragment conversationFragment = getConversationFragment();
+ // When the screen is turned on, the last used activity gets resumed, but it gets
+ // window focus only after the lock screen is unlocked.
+ if (hasFocus && conversationFragment != null) {
+ conversationFragment.setConversationFocus();
+ }
+ }
+
+ @Override
+ public void onDisplayHeightChanged(final int heightSpecification) {
+ super.onDisplayHeightChanged(heightSpecification);
+ invalidateActionBar();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ if (mUiState != null) {
+ mUiState.setHost(null);
+ }
+ }
+
+ @Override
+ public void updateActionBar(final ActionBar actionBar) {
+ super.updateActionBar(actionBar);
+ final ConversationFragment conversation = getConversationFragment();
+ final ContactPickerFragment contactPicker = getContactPicker();
+ if (contactPicker != null && mUiState.shouldShowContactPickerFragment()) {
+ contactPicker.updateActionBar(actionBar);
+ } else if (conversation != null && mUiState.shouldShowConversationFragment()) {
+ conversation.updateActionBar(actionBar);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem menuItem) {
+ if (super.onOptionsItemSelected(menuItem)) {
+ return true;
+ }
+ if (menuItem.getItemId() == android.R.id.home) {
+ onNavigationUpPressed();
+ return true;
+ }
+ return false;
+ }
+
+ public void onNavigationUpPressed() {
+ // Let the conversation fragment handle the navigation up press.
+ final ConversationFragment conversationFragment = getConversationFragment();
+ if (conversationFragment != null && conversationFragment.onNavigationUpPressed()) {
+ return;
+ }
+ onFinishCurrentConversation();
+ }
+
+ @Override
+ public void onBackPressed() {
+ // If action mode is active dismiss it
+ if (getActionMode() != null) {
+ dismissActionMode();
+ return;
+ }
+
+ // Let the conversation fragment handle the back press.
+ final ConversationFragment conversationFragment = getConversationFragment();
+ if (conversationFragment != null && conversationFragment.onBackPressed()) {
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ private ContactPickerFragment getContactPicker() {
+ return (ContactPickerFragment) getFragmentManager().findFragmentByTag(
+ ContactPickerFragment.FRAGMENT_TAG);
+ }
+
+ private ConversationFragment getConversationFragment() {
+ return (ConversationFragment) getFragmentManager().findFragmentByTag(
+ ConversationFragment.FRAGMENT_TAG);
+ }
+
+ @Override // From ContactPickerFragmentHost
+ public void onGetOrCreateNewConversation(final String conversationId) {
+ Assert.isTrue(conversationId != null);
+ mUiState.onGetOrCreateConversation(conversationId);
+ }
+
+ @Override // From ContactPickerFragmentHost
+ public void onBackButtonPressed() {
+ onBackPressed();
+ }
+
+ @Override // From ContactPickerFragmentHost
+ public void onInitiateAddMoreParticipants() {
+ mUiState.onAddMoreParticipants();
+ }
+
+
+ @Override
+ public void onParticipantCountChanged(final boolean canAddMoreParticipants) {
+ mUiState.onParticipantCountUpdated(canAddMoreParticipants);
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onStartComposeMessage() {
+ mUiState.onStartMessageCompose();
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onConversationMetadataUpdated() {
+ invalidateActionBar();
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onConversationMessagesUpdated(final int numberOfMessages) {
+ }
+
+ @Override // From ConversationFragmentHost
+ public void onConversationParticipantDataLoaded(final int numberOfParticipants) {
+ }
+
+ @Override // From ConversationFragmentHost
+ public boolean isActiveAndFocused() {
+ return !mIsPaused && hasWindowFocus();
+ }
+
+ @Override // From ConversationActivityUiStateListener
+ public void onConversationContactPickerUiStateChanged(final int oldState, final int newState,
+ final boolean animate) {
+ Assert.isTrue(oldState != newState);
+ updateUiState(animate);
+ }
+
+ private void updateUiState(final boolean animate) {
+ if (mInstanceStateSaved || mIsPaused) {
+ return;
+ }
+ Assert.notNull(mUiState);
+ final Intent intent = getIntent();
+ final String conversationId = mUiState.getConversationId();
+
+ final FragmentManager fragmentManager = getFragmentManager();
+ final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction();
+
+ final boolean needConversationFragment = mUiState.shouldShowConversationFragment();
+ final boolean needContactPickerFragment = mUiState.shouldShowContactPickerFragment();
+ ConversationFragment conversationFragment = getConversationFragment();
+
+ // Set up the conversation fragment.
+ if (needConversationFragment) {
+ Assert.notNull(conversationId);
+ if (conversationFragment == null) {
+ conversationFragment = new ConversationFragment();
+ fragmentTransaction.add(R.id.conversation_fragment_container,
+ conversationFragment, ConversationFragment.FRAGMENT_TAG);
+ }
+ final MessageData draftData = intent.getParcelableExtra(
+ UIIntents.UI_INTENT_EXTRA_DRAFT_DATA);
+ if (!needContactPickerFragment) {
+ // Once the user has committed the audience,remove the draft data from the
+ // intent to prevent reuse
+ intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA);
+ }
+ conversationFragment.setHost(this);
+ conversationFragment.setConversationInfo(this, conversationId, draftData);
+ } else if (conversationFragment != null) {
+ // Don't save draft to DB when removing conversation fragment and switching to
+ // contact picking mode. The draft is intended for the new group.
+ conversationFragment.suppressWriteDraft();
+ fragmentTransaction.remove(conversationFragment);
+ }
+
+ // Set up the contact picker fragment.
+ ContactPickerFragment contactPickerFragment = getContactPicker();
+ if (needContactPickerFragment) {
+ if (contactPickerFragment == null) {
+ contactPickerFragment = new ContactPickerFragment();
+ fragmentTransaction.add(R.id.contact_picker_fragment_container,
+ contactPickerFragment, ContactPickerFragment.FRAGMENT_TAG);
+ }
+ contactPickerFragment.setHost(this);
+ contactPickerFragment.setContactPickingMode(mUiState.getDesiredContactPickingMode(),
+ animate);
+ } else if (contactPickerFragment != null) {
+ fragmentTransaction.remove(contactPickerFragment);
+ }
+
+ fragmentTransaction.commit();
+ invalidateActionBar();
+ }
+
+ @Override
+ public void onFinishCurrentConversation() {
+ // Simply finish the current activity. The current design is to leave any empty
+ // conversations as is.
+ if (OsUtil.isAtLeastL()) {
+ finishAfterTransition();
+ } else {
+ finish();
+ }
+ }
+
+ @Override
+ public boolean shouldResumeComposeMessage() {
+ return mUiState.shouldResumeComposeMessage();
+ }
+
+ @Override
+ protected void onActivityResult(final int requestCode, final int resultCode,
+ final Intent data) {
+ if (requestCode == ConversationFragment.REQUEST_CHOOSE_ATTACHMENTS &&
+ resultCode == RESULT_OK) {
+ final ConversationFragment conversationFragment = getConversationFragment();
+ if (conversationFragment != null) {
+ conversationFragment.onAttachmentChoosen();
+ } else {
+ LogUtil.e(LogUtil.BUGLE_TAG, "ConversationFragment is missing after launching " +
+ "AttachmentChooserActivity!");
+ }
+ } else if (resultCode == FINISH_RESULT_CODE) {
+ finish();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java
new file mode 100644
index 0000000..1469c93
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java
@@ -0,0 +1,306 @@
+/*
+ * 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.conversation;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.messaging.ui.contact.ContactPickerFragment;
+import com.android.messaging.util.Assert;
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Keeps track of the different UI states that the ConversationActivity may be in. This acts as
+ * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the
+ * ConversationActivity about any state UI change so it can update the visuals. This class
+ * implements Parcelable and it's persisted across activity tear down and relaunch.
+ */
+public class ConversationActivityUiState implements Parcelable, Cloneable {
+ interface ConversationActivityUiStateHost {
+ void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate);
+ }
+
+ /*------ Overall UI states (conversation & contact picker) ------*/
+
+ /** Only a full screen conversation is showing. */
+ public static final int STATE_CONVERSATION_ONLY = 1;
+ /** Only a full screen contact picker is showing asking user to pick the initial contact. */
+ public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2;
+ /**
+ * Only a full screen contact picker is showing asking user to pick more participants. This
+ * happens after the user picked the initial contact, and then decide to go back and add more.
+ */
+ public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3;
+ /**
+ * Only a full screen contact picker is showing asking user to pick more participants. However
+ * user has reached max number of conversation participants and can add no more.
+ */
+ public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4;
+ /**
+ * A hybrid mode where the conversation view + contact chips view are showing. This happens
+ * right after the user picked the initial contact for which a 1-1 conversation is fetched or
+ * created.
+ */
+ public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5;
+
+ // The overall UI state of the ConversationActivity.
+ private int mConversationContactUiState;
+
+ // The currently displayed conversation (if any).
+ private String mConversationId;
+
+ // Indicates whether we should put focus in the compose message view when the
+ // ConversationFragment is attached. This is a transient state that's not persisted as
+ // part of the parcelable.
+ private boolean mPendingResumeComposeMessage = false;
+
+ // The owner ConversationActivity. This is not parceled since the instance always change upon
+ // object reuse.
+ private ConversationActivityUiStateHost mHost;
+
+ // Indicates the owning ConverastionActivity is in the process of updating its UI presentation
+ // to be in sync with the UI states. Outside of the UI updates, the UI states here should
+ // ALWAYS be consistent with the actual states of the activity.
+ private int mUiUpdateCount;
+
+ /**
+ * Create a new instance with an initial conversation id.
+ */
+ ConversationActivityUiState(final String conversationId) {
+ // The conversation activity may be initialized with only one of two states:
+ // Conversation-only (when there's a conversation id) or picking initial contact
+ // (when no conversation id is given).
+ mConversationId = conversationId;
+ mConversationContactUiState = conversationId == null ?
+ STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY;
+ }
+
+ public void setHost(final ConversationActivityUiStateHost host) {
+ mHost = host;
+ }
+
+ public boolean shouldShowConversationFragment() {
+ return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW ||
+ mConversationContactUiState == STATE_CONVERSATION_ONLY;
+ }
+
+ public boolean shouldShowContactPickerFragment() {
+ return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS ||
+ mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS ||
+ mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT ||
+ mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW;
+ }
+
+ /**
+ * Returns whether there's a pending request to resume message compose (i.e. set focus to
+ * the compose message view and show the soft keyboard). If so, this request will be served
+ * when the conversation fragment get created and resumed. This happens when the user commits
+ * participant selection for a group conversation and goes back to the conversation fragment.
+ * Since conversation fragment creation happens asynchronously, we issue and track this
+ * pending request for it to be eventually fulfilled.
+ */
+ public boolean shouldResumeComposeMessage() {
+ if (mPendingResumeComposeMessage) {
+ // This is a one-shot operation that just keeps track of the pending resume compose
+ // state. This is also a non-critical operation so we don't care about failure case.
+ mPendingResumeComposeMessage = false;
+ return true;
+ }
+ return false;
+ }
+
+ public int getDesiredContactPickingMode() {
+ switch (mConversationContactUiState) {
+ case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS:
+ return ContactPickerFragment.MODE_PICK_MORE_CONTACTS;
+ case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS:
+ return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS;
+ case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT:
+ return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT;
+ case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW:
+ return ContactPickerFragment.MODE_CHIPS_ONLY;
+ default:
+ Assert.fail("Invalid contact picking mode for ConversationActivity!");
+ return ContactPickerFragment.MODE_UNDEFINED;
+ }
+ }
+
+ public String getConversationId() {
+ return mConversationId;
+ }
+
+ /**
+ * Called whenever the contact picker fragment successfully fetched or created a conversation.
+ */
+ public void onGetOrCreateConversation(final String conversationId) {
+ int newState = STATE_CONVERSATION_ONLY;
+ if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) {
+ newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW;
+ } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS ||
+ mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) {
+ newState = STATE_CONVERSATION_ONLY;
+ } else {
+ // New conversation should only be created when we are in one of the contact picking
+ // modes.
+ Assert.fail("Invalid conversation activity state: can't create conversation!");
+ }
+ mConversationId = conversationId;
+ performUiStateUpdate(newState, true);
+ }
+
+ /**
+ * Called when the user started composing message. If we are in the hybrid chips state, we
+ * should commit to enter the conversation only state.
+ */
+ public void onStartMessageCompose() {
+ // This cannot happen when we are in one of the full-screen contact picking states.
+ Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT &&
+ mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS &&
+ mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS);
+ if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) {
+ performUiStateUpdate(STATE_CONVERSATION_ONLY, true);
+ }
+ }
+
+ /**
+ * Called when the user initiated an action to add more participants in the hybrid state,
+ * namely clicking on the "add more participants" button or entered a new contact chip via
+ * auto-complete.
+ */
+ public void onAddMoreParticipants() {
+ if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) {
+ mPendingResumeComposeMessage = true;
+ performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true);
+ } else {
+ // This is only possible in the hybrid state.
+ Assert.fail("Invalid conversation activity state: can't add more participants!");
+ }
+ }
+
+ /**
+ * Called each time the number of participants is updated to check against the limit and
+ * update the ui state accordingly.
+ */
+ public void onParticipantCountUpdated(final boolean canAddMoreParticipants) {
+ if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS
+ && !canAddMoreParticipants) {
+ performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false);
+ } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS
+ && canAddMoreParticipants) {
+ performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false);
+ }
+ }
+
+ private void performUiStateUpdate(final int conversationContactState, final boolean animate) {
+ // This starts one UI update cycle, during which we allow the conversation activity's
+ // UI presentation to be temporarily out of sync with the states here.
+ beginUiUpdate();
+
+ if (conversationContactState != mConversationContactUiState) {
+ final int oldState = mConversationContactUiState;
+ mConversationContactUiState = conversationContactState;
+ notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate);
+ }
+ endUiUpdate();
+ }
+
+ private void notifyOnOverallUiStateChanged(
+ final int oldState, final int newState, final boolean animate) {
+ // Always verify state validity whenever we have a state change.
+ assertValidState();
+ Assert.isTrue(isUiUpdateInProgress());
+
+ // Only do this if we are still attached to the host. mHost can be null if the host
+ // activity is already destroyed, but due to timing the contained UI components may still
+ // receive events such as focus change and trigger a callback to the Ui state. We'd like
+ // to guard against those cases.
+ if (mHost != null) {
+ mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate);
+ }
+ }
+
+ private void assertValidState() {
+ // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to
+ // start a conversation.
+ Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) ==
+ (mConversationId == null));
+ }
+
+ private void beginUiUpdate() {
+ mUiUpdateCount++;
+ }
+
+ private void endUiUpdate() {
+ if (--mUiUpdateCount < 0) {
+ Assert.fail("Unbalanced Ui updates!");
+ }
+ }
+
+ private boolean isUiUpdateInProgress() {
+ return mUiUpdateCount > 0;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(mConversationContactUiState);
+ dest.writeString(mConversationId);
+ }
+
+ private ConversationActivityUiState(final Parcel in) {
+ mConversationContactUiState = in.readInt();
+ mConversationId = in.readString();
+
+ // Always verify state validity whenever we initialize states.
+ assertValidState();
+ }
+
+ public static final Parcelable.Creator<ConversationActivityUiState> CREATOR
+ = new Parcelable.Creator<ConversationActivityUiState>() {
+ @Override
+ public ConversationActivityUiState createFromParcel(final Parcel in) {
+ return new ConversationActivityUiState(in);
+ }
+
+ @Override
+ public ConversationActivityUiState[] newArray(final int size) {
+ return new ConversationActivityUiState[size];
+ }
+ };
+
+ @Override
+ protected ConversationActivityUiState clone() {
+ try {
+ return (ConversationActivityUiState) super.clone();
+ } catch (CloneNotSupportedException e) {
+ Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " +
+ "reference?");
+ }
+ return null;
+ }
+
+ /**
+ * allows for overridding the internal UI state. Should never be called except by test code.
+ */
+ @VisibleForTesting
+ void testSetUiState(final int uiState) {
+ mConversationContactUiState = uiState;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java
new file mode 100644
index 0000000..b15f05a
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java
@@ -0,0 +1,489 @@
+/*
+ * 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.conversation;
+
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.graphics.drawable.StateListDrawable;
+import android.os.Handler;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.RecyclerView.AdapterDataObserver;
+import android.support.v7.widget.RecyclerView.ViewHolder;
+import android.util.StateSet;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.View.OnLayoutChangeListener;
+import android.view.ViewGroupOverlay;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within
+ * the conversation and allows quickly moving to another position by dragging the scrollbar thumb
+ * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the
+ * date/time of the first visible message at the current position.
+ */
+public class ConversationFastScroller extends RecyclerView.OnScrollListener implements
+ OnLayoutChangeListener, RecyclerView.OnItemTouchListener {
+
+ /**
+ * Creates a {@link ConversationFastScroller} instance, attached to the provided
+ * {@link RecyclerView}.
+ *
+ * @param rv the conversation RecyclerView
+ * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or
+ * {@code POSITION_LEFT_SIDE})
+ * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported
+ * (the feature requires Jellybean MR2 or newer)
+ */
+ public static ConversationFastScroller addTo(RecyclerView rv, int position) {
+ if (OsUtil.isAtLeastJB_MR2()) {
+ return new ConversationFastScroller(rv, position);
+ }
+ return null;
+ }
+
+ public static final int POSITION_RIGHT_SIDE = 0;
+ public static final int POSITION_LEFT_SIDE = 1;
+
+ private static final int MIN_PAGES_TO_ENABLE = 7;
+ private static final int SHOW_ANIMATION_DURATION_MS = 150;
+ private static final int HIDE_ANIMATION_DURATION_MS = 300;
+ private static final int HIDE_DELAY_MS = 1500;
+
+ private final Context mContext;
+ private final RecyclerView mRv;
+ private final ViewGroupOverlay mOverlay;
+ private final ImageView mTrackImageView;
+ private final ImageView mThumbImageView;
+ private final TextView mPreviewTextView;
+
+ private final int mTrackWidth;
+ private final int mThumbHeight;
+ private final int mPreviewHeight;
+ private final int mPreviewMinWidth;
+ private final int mPreviewMarginTop;
+ private final int mPreviewMarginLeftRight;
+ private final int mTouchSlop;
+
+ private final Rect mContainer = new Rect();
+ private final Handler mHandler = new Handler();
+
+ // Whether to render the scrollbar on the right side (otherwise it'll be on the left).
+ private final boolean mPosRight;
+
+ // Whether the scrollbar is currently visible (it may still be animating).
+ private boolean mVisible = false;
+
+ // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped).
+ private boolean mPendingHide = false;
+
+ // Whether the user is currently dragging the thumb up or down.
+ private boolean mDragging = false;
+
+ // Animations responsible for hiding the scrollbar & preview. May be null.
+ private AnimatorSet mHideAnimation;
+ private ObjectAnimator mHidePreviewAnimation;
+
+ private final Runnable mHideTrackRunnable = new Runnable() {
+ @Override
+ public void run() {
+ hide(true /* animate */);
+ mPendingHide = false;
+ }
+ };
+
+ private ConversationFastScroller(RecyclerView rv, int position) {
+ mContext = rv.getContext();
+ mRv = rv;
+ mRv.addOnLayoutChangeListener(this);
+ mRv.addOnScrollListener(this);
+ mRv.addOnItemTouchListener(this);
+ mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ updateScrollPos();
+ }
+ });
+ mPosRight = (position == POSITION_RIGHT_SIDE);
+
+ // Cache the dimensions we'll need during layout
+ final Resources res = mContext.getResources();
+ mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width);
+ mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height);
+ mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height);
+ mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width);
+ mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top);
+ mPreviewMarginLeftRight = res.getDimensionPixelOffset(
+ R.dimen.fastscroll_preview_margin_left_right);
+ mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop);
+
+ final LayoutInflater inflator = LayoutInflater.from(mContext);
+ mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null);
+ mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null);
+ mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null);
+
+ refreshConversationThemeColor();
+
+ // Add the fast scroll views to the overlay, so they are rendered above the list
+ mOverlay = rv.getOverlay();
+ mOverlay.add(mTrackImageView);
+ mOverlay.add(mThumbImageView);
+ mOverlay.add(mPreviewTextView);
+
+ hide(false /* animate */);
+ mPreviewTextView.setAlpha(0f);
+ }
+
+ public void refreshConversationThemeColor() {
+ mPreviewTextView.setBackground(
+ ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight));
+ if (OsUtil.isAtLeastL()) {
+ final StateListDrawable drawable = new StateListDrawable();
+ drawable.addState(new int[]{ android.R.attr.state_pressed },
+ ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */));
+ drawable.addState(StateSet.WILD_CARD,
+ ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */));
+ mThumbImageView.setImageDrawable(drawable);
+ } else {
+ // Android pre-L doesn't seem to handle a StateListDrawable containing a tinted
+ // drawable (it's rendered in the filter base color, which is red), so fall back to
+ // just the regular (non-pressed) drawable.
+ mThumbImageView.setImageDrawable(
+ ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */));
+ }
+ }
+
+ @Override
+ public void onScrollStateChanged(final RecyclerView view, final int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
+ // Only show the scrollbar once the user starts scrolling
+ if (!mVisible && isEnabled()) {
+ show();
+ }
+ cancelAnyPendingHide();
+ } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) {
+ // Hide the scrollbar again after scrolling stops
+ hideAfterDelay();
+ }
+ }
+
+ private boolean isEnabled() {
+ final int range = mRv.computeVerticalScrollRange();
+ final int extent = mRv.computeVerticalScrollExtent();
+
+ if (range == 0 || extent == 0) {
+ return false; // Conversation isn't long enough to scroll
+ }
+ // Only enable scrollbars for conversations long enough that they would require several
+ // flings to scroll through.
+ final float pages = (float) range / extent;
+ return (pages > MIN_PAGES_TO_ENABLE);
+ }
+
+ private void show() {
+ if (mHideAnimation != null && mHideAnimation.isRunning()) {
+ mHideAnimation.cancel();
+ }
+ // Slide the scrollbar in from the side
+ ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0);
+ ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0);
+ AnimatorSet animation = new AnimatorSet();
+ animation.playTogether(trackSlide, thumbSlide);
+ animation.setDuration(SHOW_ANIMATION_DURATION_MS);
+ animation.start();
+
+ mVisible = true;
+ updateScrollPos();
+ }
+
+ private void hideAfterDelay() {
+ cancelAnyPendingHide();
+ mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS);
+ mPendingHide = true;
+ }
+
+ private void cancelAnyPendingHide() {
+ if (mPendingHide) {
+ mHandler.removeCallbacks(mHideTrackRunnable);
+ }
+ }
+
+ private void hide(boolean animate) {
+ final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth;
+ if (animate) {
+ // Slide the scrollbar off to the side
+ ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X,
+ hiddenTranslationX);
+ ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X,
+ hiddenTranslationX);
+ mHideAnimation = new AnimatorSet();
+ mHideAnimation.playTogether(trackSlide, thumbSlide);
+ mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS);
+ mHideAnimation.start();
+ } else {
+ mTrackImageView.setTranslationX(hiddenTranslationX);
+ mThumbImageView.setTranslationX(hiddenTranslationX);
+ }
+
+ mVisible = false;
+ }
+
+ private void showPreview() {
+ if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) {
+ mHidePreviewAnimation.cancel();
+ }
+ mPreviewTextView.setAlpha(1f);
+ }
+
+ private void hidePreview() {
+ mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f);
+ mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS);
+ mHidePreviewAnimation.start();
+ }
+
+ @Override
+ public void onScrolled(final RecyclerView view, final int dx, final int dy) {
+ updateScrollPos();
+ }
+
+ private void updateScrollPos() {
+ if (!mVisible) {
+ return;
+ }
+ final int verticalScrollLength = mContainer.height() - mThumbHeight;
+ final int verticalScrollStart = mContainer.top + mThumbHeight / 2;
+
+ final float scrollRatio = computeScrollRatio();
+ final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio);
+ layoutThumb(thumbCenterY);
+
+ if (mDragging) {
+ updatePreviewText();
+ layoutPreview(thumbCenterY);
+ }
+ }
+
+ /**
+ * Returns the current position in the conversation, as a value between 0 and 1, inclusive.
+ * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on.
+ */
+ private float computeScrollRatio() {
+ final int range = mRv.computeVerticalScrollRange();
+ final int extent = mRv.computeVerticalScrollExtent();
+ int offset = mRv.computeVerticalScrollOffset();
+
+ if (range == 0 || extent == 0) {
+ // If the conversation doesn't scroll, we're at the bottom.
+ return 1.0f;
+ }
+ final int scrollRange = range - extent;
+ offset = Math.min(offset, scrollRange);
+ return offset / (float) scrollRange;
+ }
+
+ private void updatePreviewText() {
+ final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager();
+ final int pos = lm.findFirstVisibleItemPosition();
+ if (pos == RecyclerView.NO_POSITION) {
+ return;
+ }
+ final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos);
+ if (vh == null) {
+ // This can happen if the messages update while we're dragging the thumb.
+ return;
+ }
+ final ConversationMessageView messageView = (ConversationMessageView) vh.itemView;
+ final ConversationMessageData messageData = messageView.getData();
+ final long timestamp = messageData.getReceivedTimeStamp();
+ final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp);
+ mPreviewTextView.setText(timestampText);
+ }
+
+ @Override
+ public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
+ if (!mVisible) {
+ return false;
+ }
+ // If the user presses down on the scroll thumb, we'll start intercepting events from the
+ // RecyclerView so we can handle the move events while they're dragging it up/down.
+ final int action = e.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ if (isInsideThumb(e.getX(), e.getY())) {
+ startDrag();
+ return true;
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mDragging) {
+ return true;
+ }
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ if (mDragging) {
+ cancelDrag();
+ }
+ return false;
+ }
+ return false;
+ }
+
+ private boolean isInsideThumb(float x, float y) {
+ final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop;
+ final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop;
+
+ if (x < hitTargetLeft || x > hitTargetRight) {
+ return false;
+ }
+ if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public void onTouchEvent(RecyclerView rv, MotionEvent e) {
+ if (!mDragging) {
+ return;
+ }
+ final int action = e.getActionMasked();
+ switch (action) {
+ case MotionEvent.ACTION_MOVE:
+ handleDragMove(e.getY());
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ cancelDrag();
+ break;
+ }
+ }
+
+ @Override
+ public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
+ }
+
+ private void startDrag() {
+ mDragging = true;
+ mThumbImageView.setPressed(true);
+ updateScrollPos();
+ showPreview();
+ cancelAnyPendingHide();
+ }
+
+ private void handleDragMove(float y) {
+ final int verticalScrollLength = mContainer.height() - mThumbHeight;
+ final int verticalScrollStart = mContainer.top + (mThumbHeight / 2);
+
+ // Convert the desired position from px to a scroll position in the conversation.
+ float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength;
+ dragScrollRatio = Math.max(dragScrollRatio, 0.0f);
+ dragScrollRatio = Math.min(dragScrollRatio, 1.0f);
+
+ // Scroll the RecyclerView to a new position.
+ final int itemCount = mRv.getAdapter().getItemCount();
+ final int itemPos = (int)((itemCount - 1) * dragScrollRatio);
+ mRv.scrollToPosition(itemPos);
+ }
+
+ private void cancelDrag() {
+ mDragging = false;
+ mThumbImageView.setPressed(false);
+ hidePreview();
+ hideAfterDelay();
+ }
+
+ @Override
+ public void onLayoutChange(View v, int left, int top, int right, int bottom,
+ int oldLeft, int oldTop, int oldRight, int oldBottom) {
+ if (!mVisible) {
+ hide(false /* animate */);
+ }
+ // The container is the size of the RecyclerView that's visible on screen. We have to
+ // exclude the top padding, because it's usually hidden behind the conversation action bar.
+ mContainer.set(left, top + mRv.getPaddingTop(), right, bottom);
+ layoutTrack();
+ updateScrollPos();
+ }
+
+ private void layoutTrack() {
+ int trackHeight = Math.max(0, mContainer.height());
+ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY);
+ int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY);
+ mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec);
+
+ int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left;
+ int top = mContainer.top;
+ int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth);
+ int bottom = mContainer.bottom;
+ mTrackImageView.layout(left, top, right, bottom);
+ }
+
+ private void layoutThumb(int centerY) {
+ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY);
+ int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY);
+ mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec);
+
+ int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left;
+ int top = centerY - (mThumbImageView.getHeight() / 2);
+ int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth);
+ int bottom = top + mThumbHeight;
+ mThumbImageView.layout(left, top, right, bottom);
+ }
+
+ private void layoutPreview(int centerY) {
+ int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST);
+ int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY);
+ mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec);
+
+ // Ensure that the preview bubble is at least as wide as it is tall
+ if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) {
+ widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY);
+ mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec);
+ }
+ final int previewMinY = mContainer.top + mPreviewMarginTop;
+
+ final int left, right;
+ if (mPosRight) {
+ right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight;
+ left = right - mPreviewTextView.getMeasuredWidth();
+ } else {
+ left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight;
+ right = left + mPreviewTextView.getMeasuredWidth();
+ }
+
+ int bottom = centerY;
+ int top = bottom - mPreviewTextView.getMeasuredHeight();
+ if (top < previewMinY) {
+ top = previewMinY;
+ bottom = top + mPreviewTextView.getMeasuredHeight();
+ }
+ mPreviewTextView.layout(left, top, right, bottom);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java
new file mode 100644
index 0000000..a6a191a
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationFragment.java
@@ -0,0 +1,1662 @@
+/*
+ * 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.conversation;
+
+import android.Manifest;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.DownloadManager;
+import android.app.Fragment;
+import android.app.FragmentManager;
+import android.app.FragmentTransaction;
+import android.content.BroadcastReceiver;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.DialogInterface.OnCancelListener;
+import android.content.DialogInterface.OnClickListener;
+import android.content.DialogInterface.OnDismissListener;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.ColorDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Parcelable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.support.v4.text.BidiFormatter;
+import android.support.v4.text.TextDirectionHeuristicsCompat;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.DefaultItemAnimator;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.RecyclerView.ViewHolder;
+import android.text.TextUtils;
+import android.view.ActionMode;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.MessagingContentProvider;
+import com.android.messaging.datamodel.action.InsertNewMessageAction;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.ConversationParticipantsData;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.ui.AttachmentPreview;
+import com.android.messaging.ui.BugleActionBarActivity;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.ui.SnackBar;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.animation.PopupTransitionAnimation;
+import com.android.messaging.ui.contact.AddContactsConfirmationDialog;
+import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost;
+import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost;
+import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost;
+import com.android.messaging.ui.mediapicker.MediaPicker;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ChangeDefaultSmsAppHelper;
+import com.android.messaging.util.ContentType;
+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.SafeAsyncTask;
+import com.android.messaging.util.TextUtil;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows a list of messages/parts comprising a conversation.
+ */
+public class ConversationFragment extends Fragment implements ConversationDataListener,
+ IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost,
+ DraftMessageDataListener {
+
+ public interface ConversationFragmentHost extends ImeUtil.ImeStateHost {
+ void onStartComposeMessage();
+ void onConversationMetadataUpdated();
+ boolean shouldResumeComposeMessage();
+ void onFinishCurrentConversation();
+ void invalidateActionBar();
+ ActionMode startActionMode(ActionMode.Callback callback);
+ void dismissActionMode();
+ ActionMode getActionMode();
+ void onConversationMessagesUpdated(int numberOfMessages);
+ void onConversationParticipantDataLoaded(int numberOfParticipants);
+ boolean isActiveAndFocused();
+ }
+
+ public static final String FRAGMENT_TAG = "conversation";
+
+ static final int REQUEST_CHOOSE_ATTACHMENTS = 2;
+ private static final int JUMP_SCROLL_THRESHOLD = 15;
+ // We animate the message from draft to message list, if we the message doesn't show up in the
+ // list within this time limit, then we just do a fade in animation instead
+ public static final int MESSAGE_ANIMATION_MAX_WAIT = 500;
+
+ private ComposeMessageView mComposeMessageView;
+ private RecyclerView mRecyclerView;
+ private ConversationMessageAdapter mAdapter;
+ private ConversationFastScroller mFastScroller;
+
+ private View mConversationComposeDivider;
+ private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper;
+
+ private String mConversationId;
+ // If the fragment receives a draft as part of the invocation this is set
+ private MessageData mIncomingDraft;
+
+ // This binding keeps track of our associated ConversationData instance
+ // A binding should have the lifetime of the owning component,
+ // don't recreate, unbind and bind if you need new data
+ @VisibleForTesting
+ final Binding<ConversationData> mBinding = BindingBase.createBinding(this);
+
+ // Saved Instance State Data - only for temporal data which is nice to maintain but not
+ // critical for correctness.
+ private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState";
+ private Parcelable mListState;
+
+ private ConversationFragmentHost mHost;
+
+ protected List<Integer> mFilterResults;
+
+ // The minimum scrolling distance between RecyclerView's scroll change event beyong which
+ // a fling motion is considered fast, in which case we'll delay load image attachments for
+ // perf optimization.
+ private int mFastFlingThreshold;
+
+ // ConversationMessageView that is currently selected
+ private ConversationMessageView mSelectedMessage;
+
+ // Attachment data for the attachment within the selected message that was long pressed
+ private MessagePartData mSelectedAttachment;
+
+ // Normally, as soon as draft message is loaded, we trust the UI state held in
+ // ComposeMessageView to be the only source of truth (incl. the conversation self id). However,
+ // there can be external events that forces the UI state to change, such as SIM state changes
+ // or SIM auto-switching on receiving a message. This receiver is used to receive such
+ // local broadcast messages and reflect the change in the UI.
+ private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ final String conversationId =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID);
+ final String selfId =
+ intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID);
+ Assert.notNull(conversationId);
+ Assert.notNull(selfId);
+ if (TextUtils.equals(mBinding.getData().getConversationId(), conversationId)) {
+ mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId);
+ }
+ }
+ };
+
+ // Flag to prevent writing draft to DB on pause
+ private boolean mSuppressWriteDraft;
+
+ // Indicates whether local draft should be cleared due to external draft changes that must
+ // be reloaded from db
+ private boolean mClearLocalDraft;
+ private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel;
+
+ private boolean isScrolledToBottom() {
+ if (mRecyclerView.getChildCount() == 0) {
+ return true;
+ }
+ final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1);
+ int lastVisibleItem = ((LinearLayoutManager) mRecyclerView
+ .getLayoutManager()).findLastVisibleItemPosition();
+ if (lastVisibleItem < 0) {
+ // If the recyclerView height is 0, then the last visible item position is -1
+ // Try to compute the position of the last item, even though it's not visible
+ final long id = mRecyclerView.getChildItemId(lastView);
+ final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id);
+ if (holder != null) {
+ lastVisibleItem = holder.getAdapterPosition();
+ }
+ }
+ final int totalItemCount = mRecyclerView.getAdapter().getItemCount();
+ final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount);
+ return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight();
+ }
+
+ private void scrollToBottom(final boolean smoothScroll) {
+ if (mAdapter.getItemCount() > 0) {
+ scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll);
+ }
+ }
+
+ private int mScrollToDismissThreshold;
+ private final RecyclerView.OnScrollListener mListScrollListener =
+ new RecyclerView.OnScrollListener() {
+ // Keeps track of cumulative scroll delta during a scroll event, which we may use to
+ // hide the media picker & co.
+ private int mCumulativeScrollDelta;
+ private boolean mScrollToDismissHandled;
+ private boolean mWasScrolledToBottom = true;
+ private int mScrollState = RecyclerView.SCROLL_STATE_IDLE;
+
+ @Override
+ public void onScrollStateChanged(final RecyclerView view, final int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+ // Reset scroll states.
+ mCumulativeScrollDelta = 0;
+ mScrollToDismissHandled = false;
+ } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
+ mRecyclerView.getItemAnimator().endAnimations();
+ }
+ mScrollState = newState;
+ }
+
+ @Override
+ public void onScrolled(final RecyclerView view, final int dx, final int dy) {
+ if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING &&
+ !mScrollToDismissHandled) {
+ mCumulativeScrollDelta += dy;
+ // Dismiss the keyboard only when the user scroll up (into the past).
+ if (mCumulativeScrollDelta < -mScrollToDismissThreshold) {
+ mComposeMessageView.hideAllComposeInputs(false /* animate */);
+ mScrollToDismissHandled = true;
+ }
+ }
+ if (mWasScrolledToBottom != isScrolledToBottom()) {
+ mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1);
+ mWasScrolledToBottom = isScrolledToBottom();
+ }
+ }
+ };
+
+ private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() {
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ if (mSelectedMessage == null) {
+ return false;
+ }
+ final ConversationMessageData data = mSelectedMessage.getData();
+ final MenuInflater menuInflater = getActivity().getMenuInflater();
+ menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu);
+ menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage());
+ menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage());
+
+ // ShareActionProvider does not work with ActionMode. So we use a normal menu item.
+ menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage());
+ menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null);
+ menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage());
+
+ // TODO: We may want to support copying attachments in the future, but it's
+ // unclear which attachment to pick when we make this context menu at the message level
+ // instead of the part level
+ menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard());
+
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return true;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ final ConversationMessageData data = mSelectedMessage.getData();
+ final String messageId = data.getMessageId();
+ switch (menuItem.getItemId()) {
+ case R.id.save_attachment:
+ if (OsUtil.hasStoragePermission()) {
+ final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask(
+ getActivity());
+ for (final MessagePartData part : data.getAttachments()) {
+ saveAttachmentTask.addAttachmentToSave(part.getContentUri(),
+ part.getContentType());
+ }
+ if (saveAttachmentTask.getAttachmentCount() > 0) {
+ saveAttachmentTask.executeOnThreadPool();
+ mHost.dismissActionMode();
+ }
+ } else {
+ getActivity().requestPermissions(
+ new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0);
+ }
+ return true;
+ case R.id.action_delete_message:
+ if (mSelectedMessage != null) {
+ deleteMessage(messageId);
+ }
+ return true;
+ case R.id.action_download:
+ if (mSelectedMessage != null) {
+ retryDownload(messageId);
+ mHost.dismissActionMode();
+ }
+ return true;
+ case R.id.action_send:
+ if (mSelectedMessage != null) {
+ retrySend(messageId);
+ mHost.dismissActionMode();
+ }
+ return true;
+ case R.id.copy_text:
+ Assert.isTrue(data.hasText());
+ final ClipboardManager clipboard = (ClipboardManager) getActivity()
+ .getSystemService(Context.CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(
+ ClipData.newPlainText(null /* label */, data.getText()));
+ mHost.dismissActionMode();
+ return true;
+ case R.id.details_menu:
+ MessageDetailsDialog.show(
+ getActivity(), data, mBinding.getData().getParticipants(),
+ mBinding.getData().getSelfParticipantById(data.getSelfParticipantId()));
+ mHost.dismissActionMode();
+ return true;
+ case R.id.share_message_menu:
+ shareMessage(data);
+ mHost.dismissActionMode();
+ return true;
+ case R.id.forward_message_menu:
+ // TODO: Currently we are forwarding one part at a time, instead of
+ // the entire message. Change this to forwarding the entire message when we
+ // use message-based cursor in conversation.
+ final MessageData message = mBinding.getData().createForwardedMessage(data);
+ UIIntents.get().launchForwardMessageActivity(getActivity(), message);
+ mHost.dismissActionMode();
+ return true;
+ }
+ return false;
+ }
+
+ private void shareMessage(final ConversationMessageData data) {
+ // Figure out what to share.
+ MessagePartData attachmentToShare = mSelectedAttachment;
+ // If the user long-pressed on the background, we will share the text (if any)
+ // or the first attachment.
+ if (mSelectedAttachment == null
+ && TextUtil.isAllWhitespace(data.getText())) {
+ final List<MessagePartData> attachments = data.getAttachments();
+ if (attachments.size() > 0) {
+ attachmentToShare = attachments.get(0);
+ }
+ }
+
+ final Intent shareIntent = new Intent();
+ shareIntent.setAction(Intent.ACTION_SEND);
+ if (attachmentToShare == null) {
+ shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText());
+ shareIntent.setType("text/plain");
+ } else {
+ shareIntent.putExtra(
+ Intent.EXTRA_STREAM, attachmentToShare.getContentUri());
+ shareIntent.setType(attachmentToShare.getContentType());
+ }
+ final CharSequence title = getResources().getText(R.string.action_share);
+ startActivity(Intent.createChooser(shareIntent, title));
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ selectMessage(null);
+ }
+ };
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mFastFlingThreshold = getResources().getDimensionPixelOffset(
+ R.dimen.conversation_fast_fling_threshold);
+ mAdapter = new ConversationMessageAdapter(getActivity(), null, this,
+ null,
+ // Sets the item click listener on the Recycler item views.
+ new View.OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ final ConversationMessageView messageView = (ConversationMessageView) v;
+ handleMessageClick(messageView);
+ }
+ },
+ new View.OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View view) {
+ selectMessage((ConversationMessageView) view);
+ return true;
+ }
+ }
+ );
+ }
+
+ /**
+ * setConversationInfo() may be called before or after onCreate(). When a user initiate a
+ * conversation from compose, the ConversationActivity creates this fragment and calls
+ * setConversationInfo(), so it happens before onCreate(). However, when the activity is
+ * restored from saved instance state, the ConversationFragment is created automatically by
+ * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since
+ * the ability to start loading data depends on both methods being called, we need to start
+ * loading when onActivityCreated() is called, which is guaranteed to happen after both.
+ */
+ @Override
+ public void onActivityCreated(final Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ // Delay showing the message list until the participant list is loaded.
+ mRecyclerView.setVisibility(View.INVISIBLE);
+ mBinding.ensureBound();
+ mBinding.getData().init(getLoaderManager(), mBinding);
+
+ // Build the input manager with all its required dependencies and pass it along to the
+ // compose message view.
+ final ConversationInputManager inputManager = new ConversationInputManager(
+ getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(),
+ mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState);
+ mComposeMessageView.setInputManager(inputManager);
+ mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding));
+ mHost.invalidateActionBar();
+
+ mDraftMessageDataModel =
+ BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel());
+ mDraftMessageDataModel.getData().addListener(this);
+ }
+
+ public void onAttachmentChoosen() {
+ // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft
+ // and reload draft on resume.
+ mClearLocalDraft = true;
+ }
+
+ private int getScrollToMessagePosition() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return -1;
+ }
+
+ final Intent intent = activity.getIntent();
+ if (intent == null) {
+ return -1;
+ }
+
+ return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1);
+ }
+
+ private void clearScrollToMessagePosition() {
+ final Activity activity = getActivity();
+ if (activity == null) {
+ return;
+ }
+
+ final Intent intent = activity.getIntent();
+ if (intent == null) {
+ return;
+ }
+ intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1);
+ }
+
+ private final Handler mHandler = new Handler();
+
+ /**
+ * {@inheritDoc} from Fragment
+ */
+ @Override
+ public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
+ final Bundle savedInstanceState) {
+ final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
+ mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list);
+ final LinearLayoutManager manager = new LinearLayoutManager(getActivity());
+ manager.setStackFromEnd(true);
+ manager.setReverseLayout(false);
+ mRecyclerView.setHasFixedSize(true);
+ mRecyclerView.setLayoutManager(manager);
+ mRecyclerView.setItemAnimator(new DefaultItemAnimator() {
+ private final List<ViewHolder> mAddAnimations = new ArrayList<ViewHolder>();
+ private PopupTransitionAnimation mPopupTransitionAnimation;
+
+ @Override
+ public boolean animateAdd(final ViewHolder holder) {
+ final ConversationMessageView view =
+ (ConversationMessageView) holder.itemView;
+ final ConversationMessageData data = view.getData();
+ endAnimation(holder);
+ final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp();
+ if (data.getReceivedTimeStamp() ==
+ InsertNewMessageAction.getLastSentMessageTimestamp() &&
+ !data.getIsIncoming() &&
+ timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) {
+ final ConversationMessageBubbleView messageBubble =
+ (ConversationMessageBubbleView) view
+ .findViewById(R.id.message_content);
+ final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView);
+ final View composeBubbleView = mComposeMessageView.findViewById(
+ R.id.compose_message_text);
+ final Rect composeBubbleRect =
+ UiUtils.getMeasuredBoundsOnScreen(composeBubbleView);
+ final AttachmentPreview attachmentView =
+ (AttachmentPreview) mComposeMessageView.findViewById(
+ R.id.attachment_draft_view);
+ final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView);
+ if (attachmentView.getVisibility() == View.VISIBLE) {
+ startRect.top = attachmentRect.top;
+ } else {
+ startRect.top = composeBubbleRect.top;
+ }
+ startRect.top -= view.getPaddingTop();
+ startRect.bottom =
+ composeBubbleRect.bottom;
+ startRect.left += view.getPaddingRight();
+
+ view.setAlpha(0);
+ mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view);
+ mPopupTransitionAnimation.setOnStartCallback(new Runnable() {
+ @Override
+ public void run() {
+ final int startWidth = composeBubbleRect.width();
+ attachmentView.onMessageAnimationStart();
+ messageBubble.kickOffMorphAnimation(startWidth,
+ messageBubble.findViewById(R.id.message_text_and_info)
+ .getMeasuredWidth());
+ }
+ });
+ mPopupTransitionAnimation.setOnStopCallback(new Runnable() {
+ @Override
+ public void run() {
+ view.setAlpha(1);
+ }
+ });
+ mPopupTransitionAnimation.startAfterLayoutComplete();
+ mAddAnimations.add(holder);
+ return true;
+ } else {
+ return super.animateAdd(holder);
+ }
+ }
+
+ @Override
+ public void endAnimation(final ViewHolder holder) {
+ if (mAddAnimations.remove(holder)) {
+ holder.itemView.clearAnimation();
+ }
+ super.endAnimation(holder);
+ }
+
+ @Override
+ public void endAnimations() {
+ for (final ViewHolder holder : mAddAnimations) {
+ holder.itemView.clearAnimation();
+ }
+ mAddAnimations.clear();
+ if (mPopupTransitionAnimation != null) {
+ mPopupTransitionAnimation.cancel();
+ }
+ super.endAnimations();
+ }
+ });
+ mRecyclerView.setAdapter(mAdapter);
+
+ if (savedInstanceState != null) {
+ mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY);
+ }
+
+ mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider);
+ mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop();
+ mRecyclerView.addOnScrollListener(mListScrollListener);
+ mFastScroller = ConversationFastScroller.addTo(mRecyclerView,
+ UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE :
+ ConversationFastScroller.POSITION_RIGHT_SIDE);
+
+ mComposeMessageView = (ComposeMessageView)
+ view.findViewById(R.id.message_compose_view_container);
+ // Bind the compose message view to the DraftMessageData
+ mComposeMessageView.bind(DataModel.get().createDraftMessageData(
+ mBinding.getData().getConversationId()), this);
+
+ return view;
+ }
+
+ private void scrollToPosition(final int targetPosition, final boolean smoothScroll) {
+ if (smoothScroll) {
+ final int maxScrollDelta = JUMP_SCROLL_THRESHOLD;
+
+ final LinearLayoutManager layoutManager =
+ (LinearLayoutManager) mRecyclerView.getLayoutManager();
+ final int firstVisibleItemPosition =
+ layoutManager.findFirstVisibleItemPosition();
+ final int delta = targetPosition - firstVisibleItemPosition;
+ final int intermediatePosition;
+
+ if (delta > maxScrollDelta) {
+ intermediatePosition = Math.max(0, targetPosition - maxScrollDelta);
+ } else if (delta < -maxScrollDelta) {
+ final int count = layoutManager.getItemCount();
+ intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta);
+ } else {
+ intermediatePosition = -1;
+ }
+ if (intermediatePosition != -1) {
+ mRecyclerView.scrollToPosition(intermediatePosition);
+ }
+ mRecyclerView.smoothScrollToPosition(targetPosition);
+ } else {
+ mRecyclerView.scrollToPosition(targetPosition);
+ }
+ }
+
+ private int getScrollPositionFromBottom() {
+ final LinearLayoutManager layoutManager =
+ (LinearLayoutManager) mRecyclerView.getLayoutManager();
+ final int lastVisibleItem =
+ layoutManager.findLastVisibleItemPosition();
+ return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0);
+ }
+
+ /**
+ * Display a photo using the Photoviewer component.
+ */
+ @Override
+ public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) {
+ displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity());
+ }
+
+ public static void displayPhoto(final Uri photoUri, final Rect imageBounds,
+ final boolean isDraft, final String conversationId, final Activity activity) {
+ final Uri imagesUri =
+ isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId)
+ : MessagingContentProvider.buildConversationImagesUri(conversationId);
+ UIIntents.get().launchFullScreenPhotoViewer(
+ activity, photoUri, imageBounds, imagesUri);
+ }
+
+ private void selectMessage(final ConversationMessageView messageView) {
+ selectMessage(messageView, null /* attachment */);
+ }
+
+ private void selectMessage(final ConversationMessageView messageView,
+ final MessagePartData attachment) {
+ mSelectedMessage = messageView;
+ if (mSelectedMessage == null) {
+ mAdapter.setSelectedMessage(null);
+ mHost.dismissActionMode();
+ mSelectedAttachment = null;
+ return;
+ }
+ mSelectedAttachment = attachment;
+ mAdapter.setSelectedMessage(messageView.getData().getMessageId());
+ mHost.startActionMode(mMessageActionModeCallback);
+ }
+
+ @Override
+ public void onSaveInstanceState(final Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (mListState != null) {
+ outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState);
+ }
+ mComposeMessageView.saveInputState(outState);
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ if (mIncomingDraft == null) {
+ mComposeMessageView.requestDraftMessage(mClearLocalDraft);
+ } else {
+ mComposeMessageView.setDraftMessage(mIncomingDraft);
+ mIncomingDraft = null;
+ }
+ mClearLocalDraft = false;
+
+ // On resume, check if there's a pending request for resuming message compose. This
+ // may happen when the user commits the contact selection for a group conversation and
+ // goes from compose back to the conversation fragment.
+ if (mHost.shouldResumeComposeMessage()) {
+ mComposeMessageView.resumeComposeMessage();
+ }
+
+ setConversationFocus();
+
+ // On resume, invalidate all message views to show the updated timestamp.
+ mAdapter.notifyDataSetChanged();
+
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ mConversationSelfIdChangeReceiver,
+ new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION));
+ }
+
+ void setConversationFocus() {
+ if (mHost.isActiveAndFocused()) {
+ mBinding.getData().setFocus();
+ }
+ }
+
+ @Override
+ public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
+ if (mHost.getActionMode() != null) {
+ return;
+ }
+
+ inflater.inflate(R.menu.conversation_menu, menu);
+
+ final ConversationData data = mBinding.getData();
+
+ // Disable the "people & options" item if we haven't loaded participants yet.
+ menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded());
+
+ // See if we can show add contact action.
+ final ParticipantData participant = data.getOtherParticipant();
+ final boolean addContactActionVisible = (participant != null
+ && TextUtils.isEmpty(participant.getLookupKey()));
+ menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible);
+
+ // See if we should show archive or unarchive.
+ final boolean isArchived = data.getIsArchived();
+ menu.findItem(R.id.action_archive).setVisible(!isArchived);
+ menu.findItem(R.id.action_unarchive).setVisible(isArchived);
+
+ // Conditionally enable the phone call button.
+ final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() &&
+ data.getParticipantPhoneNumber() != null);
+ menu.findItem(R.id.action_call).setVisible(supportCallAction);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(final MenuItem item) {
+ switch (item.getItemId()) {
+ case R.id.action_people_and_options:
+ Assert.isTrue(mBinding.getData().getParticipantsLoaded());
+ UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId);
+ return true;
+
+ case R.id.action_call:
+ final String phoneNumber = mBinding.getData().getParticipantPhoneNumber();
+ Assert.notNull(phoneNumber);
+ final View targetView = getActivity().findViewById(R.id.action_call);
+ Point centerPoint;
+ if (targetView != null) {
+ final int screenLocation[] = new int[2];
+ targetView.getLocationOnScreen(screenLocation);
+ final int centerX = screenLocation[0] + targetView.getWidth() / 2;
+ final int centerY = screenLocation[1] + targetView.getHeight() / 2;
+ centerPoint = new Point(centerX, centerY);
+ } else {
+ // In the overflow menu, just use the center of the screen.
+ final Display display = getActivity().getWindowManager().getDefaultDisplay();
+ centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2);
+ }
+ UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint);
+ return true;
+
+ case R.id.action_archive:
+ mBinding.getData().archiveConversation(mBinding);
+ closeConversation(mConversationId);
+ return true;
+
+ case R.id.action_unarchive:
+ mBinding.getData().unarchiveConversation(mBinding);
+ return true;
+
+ case R.id.action_settings:
+ return true;
+
+ case R.id.action_add_contact:
+ final ParticipantData participant = mBinding.getData().getOtherParticipant();
+ Assert.notNull(participant);
+ final String destination = participant.getNormalizedDestination();
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant);
+ (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show();
+ return true;
+
+ case R.id.action_delete:
+ if (isReadyForAction()) {
+ new AlertDialog.Builder(getActivity())
+ .setTitle(getResources().getQuantityString(
+ R.plurals.delete_conversations_confirmation_dialog_title, 1))
+ .setPositiveButton(R.string.delete_conversation_confirmation_button,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ deleteConversation();
+ }
+ })
+ .setNegativeButton(R.string.delete_conversation_decline_button, null)
+ .show();
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ }
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ /**
+ * {@inheritDoc} from ConversationDataListener
+ */
+ @Override
+ public void onConversationMessagesCursorUpdated(final ConversationData data,
+ final Cursor cursor, final ConversationMessageData newestMessage,
+ final boolean isSync) {
+ mBinding.ensureBound(data);
+
+ // This needs to be determined before swapping cursor, which may change the scroll state.
+ final boolean scrolledToBottom = isScrolledToBottom();
+ final int positionFromBottom = getScrollPositionFromBottom();
+
+ // If participants not loaded, assume 1:1 since that's the 99% case
+ final boolean oneOnOne =
+ !data.getParticipantsLoaded() || data.getOtherParticipant() != null;
+ mAdapter.setOneOnOne(oneOnOne, false /* invalidate */);
+
+ // Ensure that the action bar is updated with the current data.
+ invalidateOptionsMenu();
+ final Cursor oldCursor = mAdapter.swapCursor(cursor);
+
+ if (cursor != null && oldCursor == null) {
+ if (mListState != null) {
+ mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
+ // RecyclerView restores scroll states without triggering scroll change events, so
+ // we need to manually ensure that they are correctly handled.
+ mListScrollListener.onScrolled(mRecyclerView, 0, 0);
+ }
+ }
+
+ if (isSync) {
+ // This is a message sync. Syncing messages changes cursor item count, which would
+ // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same
+ // relative position from the bottom (because RV is stacked from bottom), so that it
+ // stays relatively put as we sync.
+ final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0);
+ scrollToPosition(position, false /* smoothScroll */);
+ } else if (newestMessage != null) {
+ // Show a snack bar notification if we are not scrolled to the bottom and the new
+ // message is an incoming message.
+ if (!scrolledToBottom && newestMessage.getIsIncoming()) {
+ // If the conversation activity is started but not resumed (if another dialog
+ // activity was in the foregrond), we will show a system notification instead of
+ // the snack bar.
+ if (mBinding.getData().isFocused()) {
+ UiUtils.showSnackBarWithCustomAction(getActivity(),
+ getView().getRootView(),
+ getString(R.string.in_conversation_notify_new_message_text),
+ SnackBar.Action.createCustomAction(new Runnable() {
+ @Override
+ public void run() {
+ scrollToBottom(true /* smoothScroll */);
+ mComposeMessageView.hideAllComposeInputs(false /* animate */);
+ }
+ },
+ getString(R.string.in_conversation_notify_new_message_action)),
+ null /* interactions */,
+ SnackBar.Placement.above(mComposeMessageView));
+ }
+ } else {
+ // We are either already scrolled to the bottom or this is an outgoing message,
+ // scroll to the bottom to reveal it.
+ // Don't smooth scroll if we were already at the bottom; instead, we scroll
+ // immediately so RecyclerView's view animation will take place.
+ scrollToBottom(!scrolledToBottom);
+ }
+ }
+
+ if (cursor != null) {
+ mHost.onConversationMessagesUpdated(cursor.getCount());
+
+ // Are we coming from a widget click where we're told to scroll to a particular item?
+ final int scrollToPos = getScrollToMessagePosition();
+ if (scrollToPos >= 0) {
+ if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) {
+ LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " +
+ " scrollToPos: " + scrollToPos +
+ " cursorCount: " + cursor.getCount());
+ }
+ scrollToPosition(scrollToPos, true /*smoothScroll*/);
+ clearScrollToMessagePosition();
+ }
+ }
+
+ mHost.invalidateActionBar();
+ }
+
+ /**
+ * {@inheritDoc} from ConversationDataListener
+ */
+ @Override
+ public void onConversationMetadataUpdated(final ConversationData conversationData) {
+ mBinding.ensureBound(conversationData);
+
+ if (mSelectedMessage != null && mSelectedAttachment != null) {
+ // We may have just sent a message and the temp attachment we selected is now gone.
+ // and it was replaced with some new attachment. Since we don't know which one it
+ // is we shouldn't reselect it (unless there is just one) In the multi-attachment
+ // case we would just deselect the message and allow the user to reselect, otherwise we
+ // may act on old temp data and may crash.
+ final List<MessagePartData> currentAttachments = mSelectedMessage.getData().getAttachments();
+ if (currentAttachments.size() == 1) {
+ mSelectedAttachment = currentAttachments.get(0);
+ } else if (!currentAttachments.contains(mSelectedAttachment)) {
+ selectMessage(null);
+ }
+ }
+ // Ensure that the action bar is updated with the current data.
+ invalidateOptionsMenu();
+ mHost.onConversationMetadataUpdated();
+ mAdapter.notifyDataSetChanged();
+ }
+
+ public void setConversationInfo(final Context context, final String conversationId,
+ final MessageData draftData) {
+ // TODO: Eventually I would like the Factory to implement
+ // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId));
+ if (!mBinding.isBound()) {
+ mConversationId = conversationId;
+ mIncomingDraft = draftData;
+ mBinding.bind(DataModel.get().createConversationData(context, this, conversationId));
+ } else {
+ Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId));
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ // Unbind all the views that we bound to data
+ if (mComposeMessageView != null) {
+ mComposeMessageView.unbind();
+ }
+
+ // And unbind this fragment from its data
+ mBinding.unbind();
+ mConversationId = null;
+ }
+
+ void suppressWriteDraft() {
+ mSuppressWriteDraft = true;
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (mComposeMessageView != null && !mSuppressWriteDraft) {
+ mComposeMessageView.writeDraftMessage();
+ }
+ mSuppressWriteDraft = false;
+ mBinding.getData().unsetFocus();
+ mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
+
+ LocalBroadcastManager.getInstance(getActivity())
+ .unregisterReceiver(mConversationSelfIdChangeReceiver);
+ }
+
+ @Override
+ public void onConfigurationChanged(final Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ mRecyclerView.getItemAnimator().endAnimations();
+ }
+
+ // TODO: Remove isBound and replace it with ensureBound after b/15704674.
+ public boolean isBound() {
+ return mBinding.isBound();
+ }
+
+ private FragmentManager getFragmentManagerToUse() {
+ return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager();
+ }
+
+ public MediaPicker getMediaPicker() {
+ return (MediaPicker) getFragmentManagerToUse().findFragmentByTag(
+ MediaPicker.FRAGMENT_TAG);
+ }
+
+ @Override
+ public void sendMessage(final MessageData message) {
+ if (isReadyForAction()) {
+ if (ensureKnownRecipients()) {
+ // Merge the caption text from attachments into the text body of the messages
+ message.consolidateText();
+
+ mBinding.getData().sendMessage(mBinding, message);
+ mComposeMessageView.resetMediaPickerState();
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded");
+ }
+ } else {
+ warnOfMissingActionConditions(true /*sending*/,
+ new Runnable() {
+ @Override
+ public void run() {
+ sendMessage(message);
+ }
+ });
+ }
+ }
+
+ public void setHost(final ConversationFragmentHost host) {
+ mHost = host;
+ }
+
+ public String getConversationName() {
+ return mBinding.getData().getConversationName();
+ }
+
+ @Override
+ public void onComposeEditTextFocused() {
+ mHost.onStartComposeMessage();
+ }
+
+ @Override
+ public void onAttachmentsCleared() {
+ // When attachments are removed, reset transient media picker state such as image selection.
+ mComposeMessageView.resetMediaPickerState();
+ }
+
+ /**
+ * Called to check if all conditions are nominal and a "go" for some action, such as deleting
+ * a message, that requires this app to be the default app. This is also a precondition
+ * required for sending a draft.
+ * @return true if all conditions are nominal and we're ready to send a message
+ */
+ @Override
+ public boolean isReadyForAction() {
+ return UiUtils.isReadyForAction();
+ }
+
+ /**
+ * When there's some condition that prevents an operation, such as sending a message,
+ * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair
+ * that condition.
+ * @param sending - true if we're called during a sending operation
+ * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds
+ * positively to the condition prompt and resolves the condition. If null,
+ * the user will be shown a toast to tap the send button again.
+ */
+ @Override
+ public void warnOfMissingActionConditions(final boolean sending,
+ final Runnable commandToRunAfterActionConditionResolved) {
+ if (mChangeDefaultSmsAppHelper == null) {
+ mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
+ }
+ mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending,
+ commandToRunAfterActionConditionResolved, mComposeMessageView,
+ getView().getRootView(),
+ getActivity(), this);
+ }
+
+ private boolean ensureKnownRecipients() {
+ final ConversationData conversationData = mBinding.getData();
+
+ if (!conversationData.getParticipantsLoaded()) {
+ // We can't tell yet whether or not we have an unknown recipient
+ return false;
+ }
+
+ final ConversationParticipantsData participants = conversationData.getParticipants();
+ for (final ParticipantData participant : participants) {
+
+
+ if (participant.isUnknownSender()) {
+ UiUtils.showToast(R.string.unknown_sender);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public void retryDownload(final String messageId) {
+ if (isReadyForAction()) {
+ mBinding.getData().downloadMessage(mBinding, messageId);
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ }
+ }
+
+ public void retrySend(final String messageId) {
+ if (isReadyForAction()) {
+ if (ensureKnownRecipients()) {
+ mBinding.getData().resendMessage(mBinding, messageId);
+ }
+ } else {
+ warnOfMissingActionConditions(true /*sending*/,
+ new Runnable() {
+ @Override
+ public void run() {
+ retrySend(messageId);
+ }
+
+ });
+ }
+ }
+
+ void deleteMessage(final String messageId) {
+ if (isReadyForAction()) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
+ .setTitle(R.string.delete_message_confirmation_dialog_title)
+ .setMessage(R.string.delete_message_confirmation_dialog_text)
+ .setPositiveButton(R.string.delete_message_confirmation_button,
+ new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ mBinding.getData().deleteMessage(mBinding, messageId);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null);
+ if (OsUtil.isAtLeastJB_MR1()) {
+ builder.setOnDismissListener(new OnDismissListener() {
+ @Override
+ public void onDismiss(final DialogInterface dialog) {
+ mHost.dismissActionMode();
+ }
+ });
+ } else {
+ builder.setOnCancelListener(new OnCancelListener() {
+ @Override
+ public void onCancel(final DialogInterface dialog) {
+ mHost.dismissActionMode();
+ }
+ });
+ }
+ builder.create().show();
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ mHost.dismissActionMode();
+ }
+ }
+
+ public void deleteConversation() {
+ if (isReadyForAction()) {
+ final Context context = getActivity();
+ mBinding.getData().deleteConversation(mBinding);
+ closeConversation(mConversationId);
+ } else {
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ }
+ }
+
+ @Override
+ public void closeConversation(final String conversationId) {
+ if (TextUtils.equals(conversationId, mConversationId)) {
+ mHost.onFinishCurrentConversation();
+ // TODO: Explicitly transition to ConversationList (or just go back)?
+ }
+ }
+
+ @Override
+ public void onConversationParticipantDataLoaded(final ConversationData data) {
+ mBinding.ensureBound(data);
+ if (mBinding.getData().getParticipantsLoaded()) {
+ final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null;
+ mAdapter.setOneOnOne(oneOnOne, true /* invalidate */);
+
+ // refresh the options menu which will enable the "people & options" item.
+ invalidateOptionsMenu();
+
+ mHost.invalidateActionBar();
+
+ mRecyclerView.setVisibility(View.VISIBLE);
+ mHost.onConversationParticipantDataLoaded
+ (mBinding.getData().getNumberOfParticipantsExcludingSelf());
+ }
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(final ConversationData data) {
+ mBinding.ensureBound(data);
+ mAdapter.notifyDataSetChanged();
+ }
+
+ @Override
+ public void promptForSelfPhoneNumber() {
+ if (mComposeMessageView != null) {
+ // Avoid bug in system which puts soft keyboard over dialog after orientation change
+ ImeUtil.hideSoftInput(getActivity(), mComposeMessageView);
+ }
+
+ final FragmentTransaction ft = getActivity().getFragmentManager().beginTransaction();
+ final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog
+ .newInstance(getConversationSelfSubId());
+ dialog.setTargetFragment(this, 0/*requestCode*/);
+ dialog.show(ft, null/*tag*/);
+ }
+
+ @Override
+ public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
+ if (mChangeDefaultSmsAppHelper == null) {
+ mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper();
+ }
+ mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null);
+ }
+
+ public boolean hasMessages() {
+ return mAdapter != null && mAdapter.getItemCount() > 0;
+ }
+
+ public boolean onBackPressed() {
+ if (mComposeMessageView.onBackPressed()) {
+ return true;
+ }
+ return false;
+ }
+
+ public boolean onNavigationUpPressed() {
+ return mComposeMessageView.onNavigationUpPressed();
+ }
+
+ @Override
+ public boolean onAttachmentClick(final ConversationMessageView messageView,
+ final MessagePartData attachment, final Rect imageBounds, final boolean longPress) {
+ if (longPress) {
+ selectMessage(messageView, attachment);
+ return true;
+ } else if (messageView.getData().getOneClickResendMessage()) {
+ handleMessageClick(messageView);
+ return true;
+ }
+
+ if (attachment.isImage()) {
+ displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */);
+ }
+
+ if (attachment.isVCard()) {
+ UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri());
+ }
+
+ return false;
+ }
+
+ private void handleMessageClick(final ConversationMessageView messageView) {
+ if (messageView != mSelectedMessage) {
+ final ConversationMessageData data = messageView.getData();
+ final boolean isReadyToSend = isReadyForAction();
+ if (data.getOneClickResendMessage()) {
+ // Directly resend the message on tap if it's failed
+ retrySend(data.getMessageId());
+ selectMessage(null);
+ } else if (data.getShowResendMessage() && isReadyToSend) {
+ // Select the message to show the resend/download/delete options
+ selectMessage(messageView);
+ } else if (data.getShowDownloadMessage() && isReadyToSend) {
+ // Directly download the message on tap
+ retryDownload(data.getMessageId());
+ } else {
+ // Let the toast from warnOfMissingActionConditions show and skip
+ // selecting
+ warnOfMissingActionConditions(false /*sending*/,
+ null /*commandToRunAfterActionConditionResolved*/);
+ selectMessage(null);
+ }
+ } else {
+ selectMessage(null);
+ }
+ }
+
+ private static class AttachmentToSave {
+ public final Uri uri;
+ public final String contentType;
+ public Uri persistedUri;
+
+ AttachmentToSave(final Uri uri, final String contentType) {
+ this.uri = uri;
+ this.contentType = contentType;
+ }
+ }
+
+ public static class SaveAttachmentTask extends SafeAsyncTask<Void, Void, Void> {
+ private final Context mContext;
+ private final List<AttachmentToSave> mAttachmentsToSave = new ArrayList<>();
+
+ public SaveAttachmentTask(final Context context, final Uri contentUri,
+ final String contentType) {
+ mContext = context;
+ addAttachmentToSave(contentUri, contentType);
+ }
+
+ public SaveAttachmentTask(final Context context) {
+ mContext = context;
+ }
+
+ public void addAttachmentToSave(final Uri contentUri, final String contentType) {
+ mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType));
+ }
+
+ public int getAttachmentCount() {
+ return mAttachmentsToSave.size();
+ }
+
+ @Override
+ protected Void doInBackgroundTimed(final Void... arg) {
+ final File appDir = new File(Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_PICTURES),
+ mContext.getResources().getString(R.string.app_name));
+ final File downloadDir = Environment.getExternalStoragePublicDirectory(
+ Environment.DIRECTORY_DOWNLOADS);
+ for (final AttachmentToSave attachment : mAttachmentsToSave) {
+ final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType)
+ || ContentType.isVideoType(attachment.contentType);
+ attachment.persistedUri = UriUtil.persistContent(attachment.uri,
+ isImageOrVideo ? appDir : downloadDir, attachment.contentType);
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void result) {
+ int failCount = 0;
+ int imageCount = 0;
+ int videoCount = 0;
+ int otherCount = 0;
+ for (final AttachmentToSave attachment : mAttachmentsToSave) {
+ if (attachment.persistedUri == null) {
+ failCount++;
+ continue;
+ }
+
+ // Inform MediaScanner about the new file
+ final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
+ scanFileIntent.setData(attachment.persistedUri);
+ mContext.sendBroadcast(scanFileIntent);
+
+ if (ContentType.isImageType(attachment.contentType)) {
+ imageCount++;
+ } else if (ContentType.isVideoType(attachment.contentType)) {
+ videoCount++;
+ } else {
+ otherCount++;
+ // Inform DownloadManager of the file so it will show in the "downloads" app
+ final DownloadManager downloadManager =
+ (DownloadManager) mContext.getSystemService(
+ Context.DOWNLOAD_SERVICE);
+ final String filePath = attachment.persistedUri.getPath();
+ final File file = new File(filePath);
+
+ if (file.exists()) {
+ downloadManager.addCompletedDownload(
+ file.getName() /* title */,
+ mContext.getString(
+ R.string.attachment_file_description) /* description */,
+ true /* isMediaScannerScannable */,
+ attachment.contentType,
+ file.getAbsolutePath(),
+ file.length(),
+ false /* showNotification */);
+ }
+ }
+ }
+
+ String message;
+ if (failCount > 0) {
+ message = mContext.getResources().getQuantityString(
+ R.plurals.attachment_save_error, failCount, failCount);
+ } else {
+ int messageId = R.plurals.attachments_saved;
+ if (otherCount > 0) {
+ if (imageCount + videoCount == 0) {
+ messageId = R.plurals.attachments_saved_to_downloads;
+ }
+ } else {
+ if (videoCount == 0) {
+ messageId = R.plurals.photos_saved_to_album;
+ } else if (imageCount == 0) {
+ messageId = R.plurals.videos_saved_to_album;
+ } else {
+ messageId = R.plurals.attachments_saved_to_album;
+ }
+ }
+ final String appName = mContext.getResources().getString(R.string.app_name);
+ final int count = imageCount + videoCount + otherCount;
+ message = mContext.getResources().getQuantityString(
+ messageId, count, count, appName);
+ }
+ UiUtils.showToastAtBottom(message);
+ }
+ }
+
+ private void invalidateOptionsMenu() {
+ final Activity activity = getActivity();
+ // TODO: Add the supportInvalidateOptionsMenu call to the host activity.
+ if (activity == null || !(activity instanceof BugleActionBarActivity)) {
+ return;
+ }
+ ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu();
+ }
+
+ @Override
+ public void setOptionsMenuVisibility(final boolean visible) {
+ setHasOptionsMenu(visible);
+ }
+
+ @Override
+ public int getConversationSelfSubId() {
+ final String selfParticipantId = mComposeMessageView.getConversationSelfId();
+ final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId);
+ // If the self id or the self participant data hasn't been loaded yet, fallback to
+ // the default setting.
+ return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId();
+ }
+
+ @Override
+ public void invalidateActionBar() {
+ mHost.invalidateActionBar();
+ }
+
+ @Override
+ public void dismissActionMode() {
+ mHost.dismissActionMode();
+ }
+
+ @Override
+ public void selectSim(final SubscriptionListEntry subscriptionData) {
+ mComposeMessageView.selectSim(subscriptionData);
+ mHost.onStartComposeMessage();
+ }
+
+ @Override
+ public void onStartComposeMessage() {
+ mHost.onStartComposeMessage();
+ }
+
+ @Override
+ public SubscriptionListEntry getSubscriptionEntryForSelfParticipant(
+ final String selfParticipantId, final boolean excludeDefault) {
+ // TODO: ConversationMessageView is the only one using this. We should probably
+ // inject this into the view during binding in the ConversationMessageAdapter.
+ return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId,
+ excludeDefault);
+ }
+
+ @Override
+ public SimSelectorView getSimSelectorView() {
+ return (SimSelectorView) getView().findViewById(R.id.sim_selector);
+ }
+
+ @Override
+ public MediaPicker createMediaPicker() {
+ return new MediaPicker(getActivity());
+ }
+
+ @Override
+ public void notifyOfAttachmentLoadFailed() {
+ UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message);
+ }
+
+ @Override
+ public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) {
+ warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId,
+ getActivity(), tooManyVideos);
+ }
+
+ public static void warnOfExceedingMessageLimit(final boolean sending,
+ final ComposeMessageView composeMessageView, final String conversationId,
+ final Activity activity, final boolean tooManyVideos) {
+ final AlertDialog.Builder builder =
+ new AlertDialog.Builder(activity)
+ .setTitle(R.string.mms_attachment_limit_reached);
+
+ if (sending) {
+ if (tooManyVideos) {
+ builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending);
+ } else {
+ builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending)
+ .setNegativeButton(R.string.attachment_limit_reached_send_anyway,
+ new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int which) {
+ composeMessageView.sendMessageIgnoreMessageSizeLimit();
+ }
+ });
+ }
+ builder.setPositiveButton(android.R.string.ok, new OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog, final int which) {
+ showAttachmentChooser(conversationId, activity);
+ }});
+ } else {
+ builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing)
+ .setPositiveButton(android.R.string.ok, null);
+ }
+ builder.show();
+ }
+
+ @Override
+ public void showAttachmentChooser() {
+ showAttachmentChooser(mConversationId, getActivity());
+ }
+
+ public static void showAttachmentChooser(final String conversationId,
+ final Activity activity) {
+ UIIntents.get().launchAttachmentChooserActivity(activity,
+ conversationId, REQUEST_CHOOSE_ATTACHMENTS);
+ }
+
+ private void updateActionAndStatusBarColor(final ActionBar actionBar) {
+ final int themeColor = ConversationDrawables.get().getConversationThemeColor();
+ actionBar.setBackgroundDrawable(new ColorDrawable(themeColor));
+ UiUtils.setStatusBarColor(getActivity(), themeColor);
+ }
+
+ public void updateActionBar(final ActionBar actionBar) {
+ if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) {
+ updateActionAndStatusBarColor(actionBar);
+ // We update this regardless of whether or not the action bar is showing so that we
+ // don't get a race when it reappears.
+ actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM);
+ actionBar.setDisplayHomeAsUpEnabled(true);
+ // Reset the back arrow to its default
+ actionBar.setHomeAsUpIndicator(0);
+ View customView = actionBar.getCustomView();
+ if (customView == null || customView.getId() != R.id.conversation_title_container) {
+ final LayoutInflater inflator = (LayoutInflater)
+ getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ customView = inflator.inflate(R.layout.action_bar_conversation_name, null);
+ customView.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(final View v) {
+ onBackPressed();
+ }
+ });
+ actionBar.setCustomView(customView);
+ }
+
+ final TextView conversationNameView =
+ (TextView) customView.findViewById(R.id.conversation_title);
+ final String conversationName = getConversationName();
+ if (!TextUtils.isEmpty(conversationName)) {
+ // RTL : To format conversation title if it happens to be phone numbers.
+ final BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ final String formattedName = bidiFormatter.unicodeWrap(
+ UiUtils.commaEllipsize(
+ conversationName,
+ conversationNameView.getPaint(),
+ conversationNameView.getWidth(),
+ getString(R.string.plus_one),
+ getString(R.string.plus_n)).toString(),
+ TextDirectionHeuristicsCompat.LTR);
+ conversationNameView.setText(formattedName);
+ // In case phone numbers are mixed in the conversation name, we need to vocalize it.
+ final String vocalizedConversationName =
+ AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName);
+ conversationNameView.setContentDescription(vocalizedConversationName);
+ getActivity().setTitle(conversationName);
+ } else {
+ final String appName = getString(R.string.app_name);
+ conversationNameView.setText(appName);
+ getActivity().setTitle(appName);
+ }
+
+ // When conversation is showing and media picker is not showing, then hide the action
+ // bar only when we are in landscape mode, with IME open.
+ if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) {
+ actionBar.hide();
+ } else {
+ actionBar.show();
+ }
+ }
+ }
+
+ @Override
+ public boolean shouldShowSubjectEditor() {
+ return true;
+ }
+
+ @Override
+ public boolean shouldHideAttachmentsWhenSimSelectorShown() {
+ return false;
+ }
+
+ @Override
+ public void showHideSimSelector(final boolean show) {
+ // no-op for now
+ }
+
+ @Override
+ public int getSimSelectorItemLayoutId() {
+ return R.layout.sim_selector_item_view;
+ }
+
+ @Override
+ public Uri getSelfSendButtonIconUri() {
+ return null; // use default button icon uri
+ }
+
+ @Override
+ public int overrideCounterColor() {
+ return -1; // don't override the color
+ }
+
+ @Override
+ public void onAttachmentsChanged(final boolean haveAttachments) {
+ // no-op for now
+ }
+
+ @Override
+ public void onDraftChanged(final DraftMessageData data, final int changeFlags) {
+ mDraftMessageDataModel.ensureBound(data);
+ // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore
+ // other changes. When the widget changes an attachment, we need to reload the draft.
+ if (changeFlags ==
+ (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) {
+ mClearLocalDraft = true; // force a reload of the draft in onResume
+ }
+ }
+
+ @Override
+ public void onDraftAttachmentLimitReached(final DraftMessageData data) {
+ // no-op for now
+ }
+
+ @Override
+ public void onDraftAttachmentLoadFailed() {
+ // no-op for now
+ }
+
+ @Override
+ public int getAttachmentsClearedFlags() {
+ return DraftMessageData.ATTACHMENTS_CHANGED;
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationInput.java b/src/com/android/messaging/ui/conversation/ConversationInput.java
new file mode 100644
index 0000000..bf60aa8
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationInput.java
@@ -0,0 +1,103 @@
+/*
+ * 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.conversation;
+
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+
+/**
+ * The base class for a method of user input, e.g. media picker.
+ */
+public abstract class ConversationInput {
+ /**
+ * The host component where all input components are contained. This is typically the
+ * conversation fragment but may be mocked in test code.
+ */
+ public interface ConversationInputBase {
+ boolean showHideInternal(final ConversationInput target, final boolean show,
+ final boolean animate);
+ String getInputStateKey(final ConversationInput input);
+ void beginUpdate();
+ void handleOnShow(final ConversationInput target);
+ void endUpdate();
+ }
+
+ protected boolean mShowing;
+ protected ConversationInputBase mConversationInputBase;
+
+ public abstract boolean show(boolean animate);
+ public abstract boolean hide(boolean animate);
+
+ public ConversationInput(ConversationInputBase baseHost, final boolean isShowing) {
+ mConversationInputBase = baseHost;
+ mShowing = isShowing;
+ }
+
+ public boolean onBackPressed() {
+ if (mShowing) {
+ mConversationInputBase.showHideInternal(this, false /* show */, true /* animate */);
+ return true;
+ }
+ return false;
+ }
+
+ public boolean onNavigationUpPressed() {
+ return false;
+ }
+
+ /**
+ * Toggle the visibility of this view.
+ * @param animate
+ * @return true if the view is now shown, false if it now hidden
+ */
+ public boolean toggle(final boolean animate) {
+ mConversationInputBase.showHideInternal(this, !mShowing /* show */, true /* animate */);
+ return mShowing;
+ }
+
+ public void saveState(final Bundle savedState) {
+ savedState.putBoolean(mConversationInputBase.getInputStateKey(this), mShowing);
+ }
+
+ public void restoreState(final Bundle savedState) {
+ // Things are hidden by default, so only handle show.
+ if (savedState.getBoolean(mConversationInputBase.getInputStateKey(this))) {
+ mConversationInputBase.showHideInternal(this, true /* show */, false /* animate */);
+ }
+ }
+
+ public boolean updateActionBar(final ActionBar actionBar) {
+ return false;
+ }
+
+ /**
+ * Update our visibility flag in response to visibility change, both for actions
+ * initiated by this class (through the show/hide methods), and for external changes
+ * tracked by event listeners (e.g. ImeStateObserver, MediaPickerListener). As part of
+ * handling an input showing, we will hide all other inputs to ensure they are mutually
+ * exclusive.
+ */
+ protected void onVisibilityChanged(final boolean visible) {
+ if (mShowing != visible) {
+ mConversationInputBase.beginUpdate();
+ mShowing = visible;
+ if (visible) {
+ mConversationInputBase.handleOnShow(this);
+ }
+ mConversationInputBase.endUpdate();
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationInputManager.java b/src/com/android/messaging/ui/conversation/ConversationInputManager.java
new file mode 100644
index 0000000..e10abe7
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationInputManager.java
@@ -0,0 +1,550 @@
+/*
+ * 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.conversation;
+
+import android.app.FragmentManager;
+import android.content.Context;
+import android.os.Bundle;
+import android.support.v7.app.ActionBar;
+import android.widget.EditText;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.binding.ImmutableBindingRef;
+import com.android.messaging.datamodel.data.ConversationData;
+import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener;
+import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener;
+import com.android.messaging.datamodel.data.DraftMessageData;
+import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.PendingAttachmentData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.ui.mediapicker.MediaPicker;
+import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.ImeUtil;
+import com.android.messaging.util.ImeUtil.ImeStateHost;
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.Collection;
+
+/**
+ * Manages showing/hiding/persisting different mutually exclusive UI components nested in
+ * ConversationFragment that take user inputs, i.e. media picker, SIM selector and
+ * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way
+ * as the other components).
+ */
+public class ConversationInputManager implements ConversationInput.ConversationInputBase {
+ /**
+ * The host component where all input components are contained. This is typically the
+ * conversation fragment but may be mocked in test code.
+ */
+ public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider {
+ void invalidateActionBar();
+ void setOptionsMenuVisibility(boolean visible);
+ void dismissActionMode();
+ void selectSim(SubscriptionListEntry subscriptionData);
+ void onStartComposeMessage();
+ SimSelectorView getSimSelectorView();
+ MediaPicker createMediaPicker();
+ void showHideSimSelector(boolean show);
+ int getSimSelectorItemLayoutId();
+ }
+
+ /**
+ * The "sink" component where all inputs components will direct the user inputs to. This is
+ * typically the ComposeMessageView but may be mocked in test code.
+ */
+ public interface ConversationInputSink {
+ void onMediaItemsSelected(Collection<MessagePartData> items);
+ void onMediaItemsUnselected(MessagePartData item);
+ void onPendingAttachmentAdded(PendingAttachmentData pendingItem);
+ void resumeComposeMessage();
+ EditText getComposeEditText();
+ void setAccessibility(boolean enabled);
+ }
+
+ private final ConversationInputHost mHost;
+ private final ConversationInputSink mSink;
+
+ /** Dependencies injected from the host during construction */
+ private final FragmentManager mFragmentManager;
+ private final Context mContext;
+ private final ImeStateHost mImeStateHost;
+ private final ImmutableBindingRef<ConversationData> mConversationDataModel;
+ private final ImmutableBindingRef<DraftMessageData> mDraftDataModel;
+
+ private final ConversationInput[] mInputs;
+ private final ConversationMediaPicker mMediaInput;
+ private final ConversationSimSelector mSimInput;
+ private final ConversationImeKeyboard mImeInput;
+ private int mUpdateCount;
+
+ private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() {
+ @Override
+ public void onImeStateChanged(final boolean imeOpen) {
+ mImeInput.onVisibilityChanged(imeOpen);
+ }
+ };
+
+ private final ConversationDataListener mDataListener = new SimpleConversationDataListener() {
+ @Override
+ public void onConversationParticipantDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ }
+
+ @Override
+ public void onSubscriptionListDataLoaded(ConversationData data) {
+ mConversationDataModel.ensureBound(data);
+ mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData());
+ }
+ };
+
+ public ConversationInputManager(
+ final Context context,
+ final ConversationInputHost host,
+ final ConversationInputSink sink,
+ final ImeStateHost imeStateHost,
+ final FragmentManager fm,
+ final BindingBase<ConversationData> conversationDataModel,
+ final BindingBase<DraftMessageData> draftDataModel,
+ final Bundle savedState) {
+ mHost = host;
+ mSink = sink;
+ mFragmentManager = fm;
+ mContext = context;
+ mImeStateHost = imeStateHost;
+ mConversationDataModel = BindingBase.createBindingReference(conversationDataModel);
+ mDraftDataModel = BindingBase.createBindingReference(draftDataModel);
+
+ // Register listeners on dependencies.
+ mImeStateHost.registerImeStateObserver(mImeStateObserver);
+ mConversationDataModel.getData().addConversationDataListener(mDataListener);
+
+ // Initialize the inputs
+ mMediaInput = new ConversationMediaPicker(this);
+ mSimInput = new SimSelector(this);
+ mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen());
+ mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput };
+
+ if (savedState != null) {
+ for (int i = 0; i < mInputs.length; i++) {
+ mInputs[i].restoreState(savedState);
+ }
+ }
+ updateHostOptionsMenu();
+ }
+
+ public void onDetach() {
+ mImeStateHost.unregisterImeStateObserver(mImeStateObserver);
+ // Don't need to explicitly unregister for data model events. It will unregister all
+ // listeners automagically on unbind.
+ }
+
+ public void onSaveInputState(final Bundle savedState) {
+ for (int i = 0; i < mInputs.length; i++) {
+ mInputs[i].saveState(savedState);
+ }
+ }
+
+ @Override
+ public String getInputStateKey(final ConversationInput input) {
+ return input.getClass().getCanonicalName() + "_savedstate_";
+ }
+
+ public boolean onBackPressed() {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i].onBackPressed()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public boolean onNavigationUpPressed() {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i].onNavigationUpPressed()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void resetMediaPickerState() {
+ mMediaInput.resetViewHolderState();
+ }
+
+ public void showHideMediaPicker(final boolean show, final boolean animate) {
+ showHideInternal(mMediaInput, show, animate);
+ }
+
+ /**
+ * Show or hide the sim selector
+ * @param show visibility
+ * @param animate whether to animate the change in visibility
+ * @return true if the state of the visibility was changed
+ */
+ public boolean showHideSimSelector(final boolean show, final boolean animate) {
+ return showHideInternal(mSimInput, show, animate);
+ }
+
+ public void showHideImeKeyboard(final boolean show, final boolean animate) {
+ showHideInternal(mImeInput, show, animate);
+ }
+
+ public void hideAllInputs(final boolean animate) {
+ beginUpdate();
+ for (int i = 0; i < mInputs.length; i++) {
+ showHideInternal(mInputs[i], false, animate);
+ }
+ endUpdate();
+ }
+
+ /**
+ * Toggle the visibility of the sim selector.
+ * @param animate
+ * @param subEntry
+ * @return true if the view is now shown, false if it now hidden
+ */
+ public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) {
+ mSimInput.setSelected(subEntry);
+ return mSimInput.toggle(animate);
+ }
+
+ public boolean updateActionBar(final ActionBar actionBar) {
+ for (int i = 0; i < mInputs.length; i++) {
+ if (mInputs[i].mShowing) {
+ return mInputs[i].updateActionBar(actionBar);
+ }
+ }
+ return false;
+ }
+
+ @VisibleForTesting
+ boolean isMediaPickerVisible() {
+ return mMediaInput.mShowing;
+ }
+
+ @VisibleForTesting
+ boolean isSimSelectorVisible() {
+ return mSimInput.mShowing;
+ }
+
+ @VisibleForTesting
+ boolean isImeKeyboardVisible() {
+ return mImeInput.mShowing;
+ }
+
+ @VisibleForTesting
+ void testNotifyImeStateChanged(final boolean imeOpen) {
+ mImeStateObserver.onImeStateChanged(imeOpen);
+ }
+
+ /**
+ * returns true if the state of the visibility was actually changed
+ */
+ @Override
+ public boolean showHideInternal(final ConversationInput target, final boolean show,
+ final boolean animate) {
+ if (!mConversationDataModel.isBound()) {
+ return false;
+ }
+
+ if (target.mShowing == show) {
+ return false;
+ }
+ beginUpdate();
+ boolean success;
+ if (!show) {
+ success = target.hide(animate);
+ } else {
+ success = target.show(animate);
+ }
+
+ if (success) {
+ target.onVisibilityChanged(show);
+ }
+ endUpdate();
+ return true;
+ }
+
+ @Override
+ public void handleOnShow(final ConversationInput target) {
+ if (!mConversationDataModel.isBound()) {
+ return;
+ }
+ beginUpdate();
+
+ // All inputs are mutually exclusive. Showing one will hide everything else.
+ // The one exception, is that the keyboard and location media chooser can be open at the
+ // time to enable searching within that chooser
+ for (int i = 0; i < mInputs.length; i++) {
+ final ConversationInput currInput = mInputs[i];
+ if (currInput != target) {
+ // TODO : If there's more exceptions we will want to make this more
+ // generic
+ if (currInput instanceof ConversationMediaPicker &&
+ target instanceof ConversationImeKeyboard &&
+ mMediaInput.getExistingOrCreateMediaPicker() != null &&
+ mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) {
+ // Allow the keyboard and location mediaPicker to be open at the same time,
+ // but ensure the media picker is full screen to allow enough room
+ mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true);
+ continue;
+ }
+ showHideInternal(currInput, false /* show */, false /* animate */);
+ }
+ }
+ // Always dismiss action mode on show.
+ mHost.dismissActionMode();
+ // Invoking any non-keyboard input UI is treated as starting message compose.
+ if (target != mImeInput) {
+ mHost.onStartComposeMessage();
+ }
+ endUpdate();
+ }
+
+ @Override
+ public void beginUpdate() {
+ mUpdateCount++;
+ }
+
+ @Override
+ public void endUpdate() {
+ Assert.isTrue(mUpdateCount > 0);
+ if (--mUpdateCount == 0) {
+ // Always try to update the host action bar after every update cycle.
+ mHost.invalidateActionBar();
+ }
+ }
+
+ private void updateHostOptionsMenu() {
+ mHost.setOptionsMenuVisibility(!mMediaInput.isOpen());
+ }
+
+ /**
+ * Manages showing/hiding the media picker in conversation.
+ */
+ private class ConversationMediaPicker extends ConversationInput {
+ public ConversationMediaPicker(ConversationInputBase baseHost) {
+ super(baseHost, false);
+ }
+
+ private MediaPicker mMediaPicker;
+
+ @Override
+ public boolean show(boolean animate) {
+ if (mMediaPicker == null) {
+ mMediaPicker = getExistingOrCreateMediaPicker();
+ setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor());
+ mMediaPicker.setSubscriptionDataProvider(mHost);
+ mMediaPicker.setDraftMessageDataModel(mDraftDataModel);
+ mMediaPicker.setListener(new MediaPickerListener() {
+ @Override
+ public void onOpened() {
+ handleStateChange();
+ }
+
+ @Override
+ public void onFullScreenChanged(boolean fullScreen) {
+ // When we're full screen, we want to disable accessibility on the
+ // ComposeMessageView controls (attach button, message input, sim chooser)
+ // that are hiding underneath the action bar.
+ mSink.setAccessibility(!fullScreen /*enabled*/);
+ handleStateChange();
+ }
+
+ @Override
+ public void onDismissed() {
+ // Re-enable accessibility on all controls now that the media picker is
+ // going away.
+ mSink.setAccessibility(true /*enabled*/);
+ handleStateChange();
+ }
+
+ private void handleStateChange() {
+ onVisibilityChanged(isOpen());
+ mHost.invalidateActionBar();
+ updateHostOptionsMenu();
+ }
+
+ @Override
+ public void onItemsSelected(final Collection<MessagePartData> items,
+ final boolean resumeCompose) {
+ mSink.onMediaItemsSelected(items);
+ mHost.invalidateActionBar();
+ if (resumeCompose) {
+ mSink.resumeComposeMessage();
+ }
+ }
+
+ @Override
+ public void onItemUnselected(final MessagePartData item) {
+ mSink.onMediaItemsUnselected(item);
+ mHost.invalidateActionBar();
+ }
+
+ @Override
+ public void onConfirmItemSelection() {
+ mSink.resumeComposeMessage();
+ }
+
+ @Override
+ public void onPendingItemAdded(final PendingAttachmentData pendingItem) {
+ mSink.onPendingAttachmentAdded(pendingItem);
+ }
+
+ @Override
+ public void onChooserSelected(final int chooserIndex) {
+ mHost.invalidateActionBar();
+ mHost.dismissActionMode();
+ }
+ });
+ }
+
+ mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
+
+ return isOpen();
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ if (mMediaPicker != null) {
+ mMediaPicker.dismiss(animate);
+ }
+ return !isOpen();
+ }
+
+ public void resetViewHolderState() {
+ if (mMediaPicker != null) {
+ mMediaPicker.resetViewHolderState();
+ }
+ }
+
+ public void setConversationThemeColor(final int themeColor) {
+ if (mMediaPicker != null) {
+ mMediaPicker.setConversationThemeColor(themeColor);
+ }
+ }
+
+ private boolean isOpen() {
+ return (mMediaPicker != null && mMediaPicker.isOpen());
+ }
+
+ private MediaPicker getExistingOrCreateMediaPicker() {
+ if (mMediaPicker != null) {
+ return mMediaPicker;
+ }
+ MediaPicker mediaPicker = (MediaPicker)
+ mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG);
+ if (mediaPicker == null) {
+ mediaPicker = mHost.createMediaPicker();
+ if (mediaPicker == null) {
+ return null; // this use of ComposeMessageView doesn't support media picking
+ }
+ mFragmentManager.beginTransaction().replace(
+ R.id.mediapicker_container,
+ mediaPicker,
+ MediaPicker.FRAGMENT_TAG).commit();
+ }
+ return mediaPicker;
+ }
+
+ @Override
+ public boolean updateActionBar(ActionBar actionBar) {
+ if (isOpen()) {
+ mMediaPicker.updateActionBar(actionBar);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onNavigationUpPressed() {
+ if (isOpen() && mMediaPicker.isFullScreen()) {
+ return onBackPressed();
+ }
+ return super.onNavigationUpPressed();
+ }
+
+ public boolean onBackPressed() {
+ if (mMediaPicker != null && mMediaPicker.onBackPressed()) {
+ return true;
+ }
+ return super.onBackPressed();
+ }
+ }
+
+ /**
+ * Manages showing/hiding the SIM selector in conversation.
+ */
+ private class SimSelector extends ConversationSimSelector {
+ public SimSelector(ConversationInputBase baseHost) {
+ super(baseHost);
+ }
+
+ @Override
+ protected SimSelectorView getSimSelectorView() {
+ return mHost.getSimSelectorView();
+ }
+
+ @Override
+ public int getSimSelectorItemLayoutId() {
+ return mHost.getSimSelectorItemLayoutId();
+ }
+
+ @Override
+ protected void selectSim(SubscriptionListEntry item) {
+ mHost.selectSim(item);
+ }
+
+ @Override
+ public boolean show(boolean animate) {
+ final boolean result = super.show(animate);
+ mHost.showHideSimSelector(true /*show*/);
+ return result;
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ final boolean result = super.hide(animate);
+ mHost.showHideSimSelector(false /*show*/);
+ return result;
+ }
+ }
+
+ /**
+ * Manages showing/hiding the IME keyboard in conversation.
+ */
+ private class ConversationImeKeyboard extends ConversationInput {
+ public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) {
+ super(baseHost, isShowing);
+ }
+
+ @Override
+ public boolean show(boolean animate) {
+ ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText());
+ return true;
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText());
+ return true;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java
new file mode 100644
index 0000000..2748fff
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java
@@ -0,0 +1,117 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.support.v7.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.android.messaging.R;
+import com.android.messaging.ui.AsyncImageView;
+import com.android.messaging.ui.CursorRecyclerAdapter;
+import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader;
+import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost;
+import com.android.messaging.util.Assert;
+
+import java.util.HashSet;
+import java.util.List;
+
+/**
+ * Provides an interface to expose Conversation Message Cursor data to a UI widget like a
+ * RecyclerView.
+ */
+public class ConversationMessageAdapter extends
+ CursorRecyclerAdapter<ConversationMessageAdapter.ConversationMessageViewHolder> {
+
+ private final ConversationMessageViewHost mHost;
+ private final AsyncImageViewDelayLoader mImageViewDelayLoader;
+ private final View.OnClickListener mViewClickListener;
+ private final View.OnLongClickListener mViewLongClickListener;
+ private boolean mOneOnOne;
+ private String mSelectedMessageId;
+
+ public ConversationMessageAdapter(final Context context, final Cursor cursor,
+ final ConversationMessageViewHost host,
+ final AsyncImageViewDelayLoader imageViewDelayLoader,
+ final View.OnClickListener viewClickListener,
+ final View.OnLongClickListener longClickListener) {
+ super(context, cursor, 0);
+ mHost = host;
+ mViewClickListener = viewClickListener;
+ mViewLongClickListener = longClickListener;
+ mImageViewDelayLoader = imageViewDelayLoader;
+ setHasStableIds(true);
+ }
+
+ @Override
+ public void bindViewHolder(final ConversationMessageViewHolder holder,
+ final Context context, final Cursor cursor) {
+ Assert.isTrue(holder.mView instanceof ConversationMessageView);
+ final ConversationMessageView conversationMessageView =
+ (ConversationMessageView) holder.mView;
+ conversationMessageView.bind(cursor, mOneOnOne, mSelectedMessageId);
+ }
+
+ @Override
+ public ConversationMessageViewHolder createViewHolder(final Context context,
+ final ViewGroup parent, final int viewType) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(context);
+ final ConversationMessageView conversationMessageView = (ConversationMessageView)
+ layoutInflater.inflate(R.layout.conversation_message_view, null);
+ conversationMessageView.setHost(mHost);
+ conversationMessageView.setImageViewDelayLoader(mImageViewDelayLoader);
+ return new ConversationMessageViewHolder(conversationMessageView,
+ mViewClickListener, mViewLongClickListener);
+ }
+
+ public void setSelectedMessage(final String messageId) {
+ mSelectedMessageId = messageId;
+ notifyDataSetChanged();
+ }
+
+ public void setOneOnOne(final boolean oneOnOne, final boolean invalidate) {
+ if (mOneOnOne != oneOnOne) {
+ mOneOnOne = oneOnOne;
+ if (invalidate) {
+ notifyDataSetChanged();
+ }
+ }
+ }
+
+ /**
+ * ViewHolder that holds a ConversationMessageView.
+ */
+ public static class ConversationMessageViewHolder extends RecyclerView.ViewHolder {
+ final View mView;
+
+ /**
+ * @param viewClickListener a View.OnClickListener that should define the interaction when
+ * an item in the RecyclerView is clicked.
+ */
+ public ConversationMessageViewHolder(final View itemView,
+ final View.OnClickListener viewClickListener,
+ final View.OnLongClickListener viewLongClickListener) {
+ super(itemView);
+ mView = itemView;
+
+ mView.setOnClickListener(viewClickListener);
+ mView.setOnLongClickListener(viewLongClickListener);
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java
new file mode 100644
index 0000000..ef6aeb4
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java
@@ -0,0 +1,132 @@
+/*
+ * 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.conversation;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.animation.ObjectAnimator;
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+import com.android.messaging.annotation.VisibleForAnimation;
+import com.android.messaging.datamodel.data.ConversationMessageBubbleData;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * Shows the message bubble for one conversation message. It is able to animate size changes
+ * by morphing when the message content changes size.
+ */
+// TODO: Move functionality from ConversationMessageView into this class as appropriate
+public class ConversationMessageBubbleView extends LinearLayout {
+ private int mIntrinsicWidth;
+ private int mMorphedWidth;
+ private ObjectAnimator mAnimator;
+ private boolean mShouldAnimateWidthChange;
+ private final ConversationMessageBubbleData mData;
+ private int mRunningStartWidth;
+ private ViewGroup mBubbleBackground;
+
+ public ConversationMessageBubbleView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ mData = new ConversationMessageBubbleData();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mBubbleBackground = (ViewGroup) findViewById(R.id.message_text_and_info);
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int newIntrinsicWidth = getMeasuredWidth();
+ if (mIntrinsicWidth == 0 && newIntrinsicWidth != mIntrinsicWidth) {
+ if (mShouldAnimateWidthChange) {
+ kickOffMorphAnimation(mIntrinsicWidth, newIntrinsicWidth);
+ }
+ mIntrinsicWidth = newIntrinsicWidth;
+ }
+
+ if (mMorphedWidth > 0) {
+ mBubbleBackground.getLayoutParams().width = mMorphedWidth;
+ } else {
+ mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT;
+ }
+ mBubbleBackground.requestLayout();
+ }
+
+ @VisibleForAnimation
+ public void setMorphWidth(final int width) {
+ mMorphedWidth = width;
+ requestLayout();
+ }
+
+ public void bind(final ConversationMessageData data) {
+ final boolean changed = mData.bind(data);
+ // Animate width change only when we are binding to the same message, so that we may
+ // animate view size changes on the same message bubble due to things like status text
+ // change.
+ // Don't animate width change when the bubble contains attachments. Width animation is
+ // only suitable for text-only messages (where the bubble size change due to status or
+ // time stamp changes).
+ mShouldAnimateWidthChange = !changed && !data.hasAttachments();
+ if (mAnimator == null) {
+ mMorphedWidth = 0;
+ }
+ }
+
+ public void kickOffMorphAnimation(final int oldWidth, final int newWidth) {
+ if (mAnimator != null) {
+ mAnimator.setIntValues(mRunningStartWidth, newWidth);
+ return;
+ }
+ mRunningStartWidth = oldWidth;
+ mAnimator = ObjectAnimator.ofInt(this, "morphWidth", oldWidth, newWidth);
+ mAnimator.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
+ mAnimator.addListener(new AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ mAnimator = null;
+ mMorphedWidth = 0;
+ // Allow the bubble to resize if, for example, the status text changed during
+ // the animation. This will snap to the bigger size if needed. This is intentional
+ // as animating immediately after looks really bad and switching layout params
+ // during the original animation does not achieve the desired effect.
+ mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT;
+ mBubbleBackground.requestLayout();
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animator) {
+ }
+
+ @Override
+ public void onAnimationRepeat(Animator animator) {
+ }
+ });
+ mAnimator.start();
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageView.java b/src/com/android/messaging/ui/conversation/ConversationMessageView.java
new file mode 100644
index 0000000..e22e2c7
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationMessageView.java
@@ -0,0 +1,1206 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.support.annotation.Nullable;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.format.DateUtils;
+import android.text.format.Formatter;
+import android.text.style.URLSpan;
+import android.text.util.Linkify;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.FrameLayout;
+import android.widget.ImageView.ScaleType;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.MessageData;
+import com.android.messaging.datamodel.data.MessagePartData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.datamodel.media.ImageRequestDescriptor;
+import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor;
+import com.android.messaging.datamodel.media.UriImageRequestDescriptor;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.ui.AsyncImageView;
+import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader;
+import com.android.messaging.ui.AudioAttachmentView;
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.ui.ConversationDrawables;
+import com.android.messaging.ui.MultiAttachmentLayout;
+import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener;
+import com.android.messaging.ui.PersonItemView;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.ui.VideoThumbnailView;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.ImageUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.YouTubeUtil;
+import com.google.common.base.Predicate;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * The view for a single entry in a conversation.
+ */
+public class ConversationMessageView extends FrameLayout implements View.OnClickListener,
+ View.OnLongClickListener, OnAttachmentClickListener {
+ public interface ConversationMessageViewHost {
+ boolean onAttachmentClick(ConversationMessageView view, MessagePartData attachment,
+ Rect imageBounds, boolean longPress);
+ SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId,
+ boolean excludeDefault);
+ }
+
+ private final ConversationMessageData mData;
+
+ private LinearLayout mMessageAttachmentsView;
+ private MultiAttachmentLayout mMultiAttachmentView;
+ private AsyncImageView mMessageImageView;
+ private TextView mMessageTextView;
+ private boolean mMessageTextHasLinks;
+ private boolean mMessageHasYouTubeLink;
+ private TextView mStatusTextView;
+ private TextView mTitleTextView;
+ private TextView mMmsInfoTextView;
+ private LinearLayout mMessageTitleLayout;
+ private TextView mSenderNameTextView;
+ private ContactIconView mContactIconView;
+ private ConversationMessageBubbleView mMessageBubble;
+ private View mSubjectView;
+ private TextView mSubjectLabel;
+ private TextView mSubjectText;
+ private View mDeliveredBadge;
+ private ViewGroup mMessageMetadataView;
+ private ViewGroup mMessageTextAndInfoView;
+ private TextView mSimNameView;
+
+ private boolean mOneOnOne;
+ private ConversationMessageViewHost mHost;
+
+ public ConversationMessageView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ // TODO: we should switch to using Binding and DataModel factory methods.
+ mData = new ConversationMessageData();
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon);
+ mContactIconView.setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(final View view) {
+ ConversationMessageView.this.performLongClick();
+ return true;
+ }
+ });
+
+ mMessageAttachmentsView = (LinearLayout) findViewById(R.id.message_attachments);
+ mMultiAttachmentView = (MultiAttachmentLayout) findViewById(R.id.multiple_attachments);
+ mMultiAttachmentView.setOnAttachmentClickListener(this);
+
+ mMessageImageView = (AsyncImageView) findViewById(R.id.message_image);
+ mMessageImageView.setOnClickListener(this);
+ mMessageImageView.setOnLongClickListener(this);
+
+ mMessageTextView = (TextView) findViewById(R.id.message_text);
+ mMessageTextView.setOnClickListener(this);
+ IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this);
+
+ mStatusTextView = (TextView) findViewById(R.id.message_status);
+ mTitleTextView = (TextView) findViewById(R.id.message_title);
+ mMmsInfoTextView = (TextView) findViewById(R.id.mms_info);
+ mMessageTitleLayout = (LinearLayout) findViewById(R.id.message_title_layout);
+ mSenderNameTextView = (TextView) findViewById(R.id.message_sender_name);
+ mMessageBubble = (ConversationMessageBubbleView) findViewById(R.id.message_content);
+ mSubjectView = findViewById(R.id.subject_container);
+ mSubjectLabel = (TextView) mSubjectView.findViewById(R.id.subject_label);
+ mSubjectText = (TextView) mSubjectView.findViewById(R.id.subject_text);
+ mDeliveredBadge = findViewById(R.id.smsDeliveredBadge);
+ mMessageMetadataView = (ViewGroup) findViewById(R.id.message_metadata);
+ mMessageTextAndInfoView = (ViewGroup) findViewById(R.id.message_text_and_info);
+ mSimNameView = (TextView) findViewById(R.id.sim_name);
+ }
+
+ @Override
+ protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
+ final int horizontalSpace = MeasureSpec.getSize(widthMeasureSpec);
+ final int iconSize = getResources()
+ .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size);
+
+ final int unspecifiedMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
+ final int iconMeasureSpec = MeasureSpec.makeMeasureSpec(iconSize, MeasureSpec.EXACTLY);
+
+ mContactIconView.measure(iconMeasureSpec, iconMeasureSpec);
+
+ final int arrowWidth =
+ getResources().getDimensionPixelSize(R.dimen.message_bubble_arrow_width);
+
+ // We need to subtract contact icon width twice from the horizontal space to get
+ // the max leftover space because we want the message bubble to extend no further than the
+ // starting position of the message bubble in the opposite direction.
+ final int maxLeftoverSpace = horizontalSpace - mContactIconView.getMeasuredWidth() * 2
+ - arrowWidth - getPaddingLeft() - getPaddingRight();
+ final int messageContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(maxLeftoverSpace,
+ MeasureSpec.AT_MOST);
+
+ mMessageBubble.measure(messageContentWidthMeasureSpec, unspecifiedMeasureSpec);
+
+ final int maxHeight = Math.max(mContactIconView.getMeasuredHeight(),
+ mMessageBubble.getMeasuredHeight());
+ setMeasuredDimension(horizontalSpace, maxHeight + getPaddingBottom() + getPaddingTop());
+ }
+
+ @Override
+ protected void onLayout(final boolean changed, final int left, final int top, final int right,
+ final int bottom) {
+ final boolean isRtl = AccessibilityUtil.isLayoutRtl(this);
+
+ final int iconWidth = mContactIconView.getMeasuredWidth();
+ final int iconHeight = mContactIconView.getMeasuredHeight();
+ final int iconTop = getPaddingTop();
+ final int contentWidth = (right -left) - iconWidth - getPaddingLeft() - getPaddingRight();
+ final int contentHeight = mMessageBubble.getMeasuredHeight();
+ final int contentTop = iconTop;
+
+ final int iconLeft;
+ final int contentLeft;
+ if (mData.getIsIncoming()) {
+ if (isRtl) {
+ iconLeft = (right - left) - getPaddingRight() - iconWidth;
+ contentLeft = iconLeft - contentWidth;
+ } else {
+ iconLeft = getPaddingLeft();
+ contentLeft = iconLeft + iconWidth;
+ }
+ } else {
+ if (isRtl) {
+ iconLeft = getPaddingLeft();
+ contentLeft = iconLeft + iconWidth;
+ } else {
+ iconLeft = (right - left) - getPaddingRight() - iconWidth;
+ contentLeft = iconLeft - contentWidth;
+ }
+ }
+
+ mContactIconView.layout(iconLeft, iconTop, iconLeft + iconWidth, iconTop + iconHeight);
+
+ mMessageBubble.layout(contentLeft, contentTop, contentLeft + contentWidth,
+ contentTop + contentHeight);
+ }
+
+ /**
+ * Fills in the data associated with this view.
+ *
+ * @param cursor The cursor from a MessageList that this view is in, pointing to its entry.
+ */
+ public void bind(final Cursor cursor) {
+ bind(cursor, true, null);
+ }
+
+ /**
+ * Fills in the data associated with this view.
+ *
+ * @param cursor The cursor from a MessageList that this view is in, pointing to its entry.
+ * @param oneOnOne Whether this is a 1:1 conversation
+ */
+ public void bind(final Cursor cursor,
+ final boolean oneOnOne, final String selectedMessageId) {
+ mOneOnOne = oneOnOne;
+
+ // Update our UI model
+ mData.bind(cursor);
+ setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId));
+
+ // Update text and image content for the view.
+ updateViewContent();
+
+ // Update colors and layout parameters for the view.
+ updateViewAppearance();
+
+ updateContentDescription();
+ }
+
+ public void setHost(final ConversationMessageViewHost host) {
+ mHost = host;
+ }
+
+ /**
+ * Sets a delay loader instance to manage loading / resuming of image attachments.
+ */
+ public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) {
+ Assert.notNull(mMessageImageView);
+ mMessageImageView.setDelayLoader(delayLoader);
+ mMultiAttachmentView.setImageViewDelayLoader(delayLoader);
+ }
+
+ public ConversationMessageData getData() {
+ return mData;
+ }
+
+ /**
+ * Returns whether we should show simplified visual style for the message view (i.e. hide the
+ * avatar and bubble arrow, reduce padding).
+ */
+ private boolean shouldShowSimplifiedVisualStyle() {
+ return mData.getCanClusterWithPreviousMessage();
+ }
+
+ /**
+ * Returns whether we need to show message bubble arrow. We don't show arrow if the message
+ * contains media attachments or if shouldShowSimplifiedVisualStyle() is true.
+ */
+ private boolean shouldShowMessageBubbleArrow() {
+ return !shouldShowSimplifiedVisualStyle()
+ && !(mData.hasAttachments() || mMessageHasYouTubeLink);
+ }
+
+ /**
+ * Returns whether we need to show a message bubble for text content.
+ */
+ private boolean shouldShowMessageTextBubble() {
+ if (mData.hasText()) {
+ return true;
+ }
+ final String subjectText = MmsUtils.cleanseMmsSubject(getResources(),
+ mData.getMmsSubject());
+ if (!TextUtils.isEmpty(subjectText)) {
+ return true;
+ }
+ return false;
+ }
+
+ private void updateViewContent() {
+ updateMessageContent();
+ int titleResId = -1;
+ int statusResId = -1;
+ String statusText = null;
+ switch(mData.getStatus()) {
+ case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ titleResId = R.string.message_title_downloading;
+ statusResId = R.string.message_status_downloading;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_manual_download;
+ if (isSelected()) {
+ statusResId = R.string.message_status_download_action;
+ } else {
+ statusResId = R.string.message_status_download;
+ }
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_download_failed;
+ statusResId = R.string.message_status_download_error;
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
+ if (!OsUtil.isSecondaryUser()) {
+ titleResId = R.string.message_title_download_failed;
+ if (isSelected()) {
+ statusResId = R.string.message_status_download_action;
+ } else {
+ statusResId = R.string.message_status_download;
+ }
+ }
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
+ statusResId = R.string.message_status_sending;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ statusResId = R.string.message_status_send_retrying;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ statusResId = R.string.message_status_send_failed_emergency_number;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
+ // don't show the error state unless we're the default sms app
+ if (PhoneUtils.getDefault().isDefaultSmsApp()) {
+ if (isSelected()) {
+ statusResId = R.string.message_status_resend;
+ } else {
+ statusResId = MmsUtils.mapRawStatusToErrorResourceId(
+ mData.getStatus(), mData.getRawTelephonyStatus());
+ }
+ break;
+ }
+ // FALL THROUGH HERE
+
+ case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
+ case MessageData.BUGLE_STATUS_INCOMING_COMPLETE:
+ default:
+ if (!mData.getCanClusterWithNextMessage()) {
+ statusText = mData.getFormattedReceivedTimeStamp();
+ }
+ break;
+ }
+
+ final boolean titleVisible = (titleResId >= 0);
+ if (titleVisible) {
+ final String titleText = getResources().getString(titleResId);
+ mTitleTextView.setText(titleText);
+
+ final String mmsInfoText = getResources().getString(
+ R.string.mms_info,
+ Formatter.formatFileSize(getContext(), mData.getSmsMessageSize()),
+ DateUtils.formatDateTime(
+ getContext(),
+ mData.getMmsExpiry(),
+ DateUtils.FORMAT_SHOW_DATE |
+ DateUtils.FORMAT_SHOW_TIME |
+ DateUtils.FORMAT_NUMERIC_DATE |
+ DateUtils.FORMAT_NO_YEAR));
+ mMmsInfoTextView.setText(mmsInfoText);
+ mMessageTitleLayout.setVisibility(View.VISIBLE);
+ } else {
+ mMessageTitleLayout.setVisibility(View.GONE);
+ }
+
+ final String subjectText = MmsUtils.cleanseMmsSubject(getResources(),
+ mData.getMmsSubject());
+ final boolean subjectVisible = !TextUtils.isEmpty(subjectText);
+
+ final boolean senderNameVisible = !mOneOnOne && !mData.getCanClusterWithNextMessage()
+ && mData.getIsIncoming();
+ if (senderNameVisible) {
+ mSenderNameTextView.setText(mData.getSenderDisplayName());
+ mSenderNameTextView.setVisibility(View.VISIBLE);
+ } else {
+ mSenderNameTextView.setVisibility(View.GONE);
+ }
+
+ if (statusResId >= 0) {
+ statusText = getResources().getString(statusResId);
+ }
+
+ // We set the text even if the view will be GONE for accessibility
+ mStatusTextView.setText(statusText);
+ final boolean statusVisible = !TextUtils.isEmpty(statusText);
+ if (statusVisible) {
+ mStatusTextView.setVisibility(View.VISIBLE);
+ } else {
+ mStatusTextView.setVisibility(View.GONE);
+ }
+
+ final boolean deliveredBadgeVisible =
+ mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED;
+ mDeliveredBadge.setVisibility(deliveredBadgeVisible ? View.VISIBLE : View.GONE);
+
+ // Update the sim indicator.
+ final boolean showSimIconAsIncoming = mData.getIsIncoming() &&
+ (!mData.hasAttachments() || shouldShowMessageTextBubble());
+ final SubscriptionListEntry subscriptionEntry =
+ mHost.getSubscriptionEntryForSelfParticipant(mData.getSelfParticipantId(),
+ true /* excludeDefault */);
+ final boolean simNameVisible = subscriptionEntry != null &&
+ !TextUtils.isEmpty(subscriptionEntry.displayName) &&
+ !mData.getCanClusterWithNextMessage();
+ if (simNameVisible) {
+ final String simNameText = mData.getIsIncoming() ? getResources().getString(
+ R.string.incoming_sim_name_text, subscriptionEntry.displayName) :
+ subscriptionEntry.displayName;
+ mSimNameView.setText(simNameText);
+ mSimNameView.setTextColor(showSimIconAsIncoming ? getResources().getColor(
+ R.color.timestamp_text_incoming) : subscriptionEntry.displayColor);
+ mSimNameView.setVisibility(VISIBLE);
+ } else {
+ mSimNameView.setText(null);
+ mSimNameView.setVisibility(GONE);
+ }
+
+ final boolean metadataVisible = senderNameVisible || statusVisible
+ || deliveredBadgeVisible || simNameVisible;
+ mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE);
+
+ final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible
+ || mData.hasText() || metadataVisible;
+ mMessageTextAndInfoView.setVisibility(
+ messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE);
+
+ if (shouldShowSimplifiedVisualStyle()) {
+ mContactIconView.setVisibility(View.GONE);
+ mContactIconView.setImageResourceUri(null);
+ } else {
+ mContactIconView.setVisibility(View.VISIBLE);
+ final Uri avatarUri = AvatarUriUtil.createAvatarUri(
+ mData.getSenderProfilePhotoUri(),
+ mData.getSenderFullName(),
+ mData.getSenderNormalizedDestination(),
+ mData.getSenderContactLookupKey());
+ mContactIconView.setImageResourceUri(avatarUri, mData.getSenderContactId(),
+ mData.getSenderContactLookupKey(), mData.getSenderNormalizedDestination());
+ }
+ }
+
+ private void updateMessageContent() {
+ // We must update the text before the attachments since we search the text to see if we
+ // should make a preview youtube image in the attachments
+ updateMessageText();
+ updateMessageAttachments();
+ updateMessageSubject();
+ mMessageBubble.bind(mData);
+ }
+
+ private void updateMessageAttachments() {
+ // Bind video, audio, and VCard attachments. If there are multiple, they stack vertically.
+ bindAttachmentsOfSameType(sVideoFilter,
+ R.layout.message_video_attachment, mVideoViewBinder, VideoThumbnailView.class);
+ bindAttachmentsOfSameType(sAudioFilter,
+ R.layout.message_audio_attachment, mAudioViewBinder, AudioAttachmentView.class);
+ bindAttachmentsOfSameType(sVCardFilter,
+ R.layout.message_vcard_attachment, mVCardViewBinder, PersonItemView.class);
+
+ // Bind image attachments. If there are multiple, they are shown in a collage view.
+ final List<MessagePartData> imageParts = mData.getAttachments(sImageFilter);
+ if (imageParts.size() > 1) {
+ Collections.sort(imageParts, sImageComparator);
+ mMultiAttachmentView.bindAttachments(imageParts, null, imageParts.size());
+ mMultiAttachmentView.setVisibility(View.VISIBLE);
+ } else {
+ mMultiAttachmentView.setVisibility(View.GONE);
+ }
+
+ // In the case that we have no image attachments and exactly one youtube link in a message
+ // then we will show a preview.
+ String youtubeThumbnailUrl = null;
+ String originalYoutubeLink = null;
+ if (mMessageTextHasLinks && imageParts.size() == 0) {
+ CharSequence messageTextWithSpans = mMessageTextView.getText();
+ final URLSpan[] spans = ((Spanned) messageTextWithSpans).getSpans(0,
+ messageTextWithSpans.length(), URLSpan.class);
+ for (URLSpan span : spans) {
+ String url = span.getURL();
+ String youtubeLinkForUrl = YouTubeUtil.getYoutubePreviewImageLink(url);
+ if (!TextUtils.isEmpty(youtubeLinkForUrl)) {
+ if (TextUtils.isEmpty(youtubeThumbnailUrl)) {
+ // Save the youtube link if we don't already have one
+ youtubeThumbnailUrl = youtubeLinkForUrl;
+ originalYoutubeLink = url;
+ } else {
+ // We already have a youtube link. This means we have two youtube links so
+ // we shall show none.
+ youtubeThumbnailUrl = null;
+ originalYoutubeLink = null;
+ break;
+ }
+ }
+ }
+ }
+ // We need to keep track if we have a youtube link in the message so that we will not show
+ // the arrow
+ mMessageHasYouTubeLink = !TextUtils.isEmpty(youtubeThumbnailUrl);
+
+ // We will show the message image view if there is one attachment or one youtube link
+ if (imageParts.size() == 1 || mMessageHasYouTubeLink) {
+ // Get the display metrics for a hint for how large to pull the image data into
+ final WindowManager windowManager = (WindowManager) getContext().
+ getSystemService(Context.WINDOW_SERVICE);
+ final DisplayMetrics displayMetrics = new DisplayMetrics();
+ windowManager.getDefaultDisplay().getMetrics(displayMetrics);
+
+ final int iconSize = getResources()
+ .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size);
+ final int desiredWidth = displayMetrics.widthPixels - iconSize - iconSize;
+
+ if (imageParts.size() == 1) {
+ final MessagePartData imagePart = imageParts.get(0);
+ // If the image is big, we want to scale it down to save memory since we're going to
+ // scale it down to fit into the bubble width. We don't constrain the height.
+ final ImageRequestDescriptor imageRequest =
+ new MessagePartImageRequestDescriptor(imagePart,
+ desiredWidth,
+ MessagePartData.UNSPECIFIED_SIZE,
+ false);
+ adjustImageViewBounds(imagePart);
+ mMessageImageView.setImageResourceId(imageRequest);
+ mMessageImageView.setTag(imagePart);
+ } else {
+ // Youtube Thumbnail image
+ final ImageRequestDescriptor imageRequest =
+ new UriImageRequestDescriptor(Uri.parse(youtubeThumbnailUrl), desiredWidth,
+ MessagePartData.UNSPECIFIED_SIZE, true /* allowCompression */,
+ true /* isStatic */, false /* cropToCircle */,
+ ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */,
+ ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */);
+ mMessageImageView.setImageResourceId(imageRequest);
+ mMessageImageView.setTag(originalYoutubeLink);
+ }
+ mMessageImageView.setVisibility(View.VISIBLE);
+ } else {
+ mMessageImageView.setImageResourceId(null);
+ mMessageImageView.setVisibility(View.GONE);
+ }
+
+ // Show the message attachments container if any of its children are visible
+ boolean attachmentsVisible = false;
+ for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(i);
+ if (attachmentView.getVisibility() == View.VISIBLE) {
+ attachmentsVisible = true;
+ break;
+ }
+ }
+ mMessageAttachmentsView.setVisibility(attachmentsVisible ? View.VISIBLE : View.GONE);
+ }
+
+ private void bindAttachmentsOfSameType(final Predicate<MessagePartData> attachmentTypeFilter,
+ final int attachmentViewLayoutRes, final AttachmentViewBinder viewBinder,
+ final Class<?> attachmentViewClass) {
+ final LayoutInflater layoutInflater = LayoutInflater.from(getContext());
+
+ // Iterate through all attachments of a particular type (video, audio, etc).
+ // Find the first attachment index that matches the given type if possible.
+ int attachmentViewIndex = -1;
+ View existingAttachmentView;
+ do {
+ existingAttachmentView = mMessageAttachmentsView.getChildAt(++attachmentViewIndex);
+ } while (existingAttachmentView != null &&
+ !(attachmentViewClass.isInstance(existingAttachmentView)));
+
+ for (final MessagePartData attachment : mData.getAttachments(attachmentTypeFilter)) {
+ View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex);
+ if (!attachmentViewClass.isInstance(attachmentView)) {
+ attachmentView = layoutInflater.inflate(attachmentViewLayoutRes,
+ mMessageAttachmentsView, false /* attachToRoot */);
+ attachmentView.setOnClickListener(this);
+ attachmentView.setOnLongClickListener(this);
+ mMessageAttachmentsView.addView(attachmentView, attachmentViewIndex);
+ }
+ viewBinder.bindView(attachmentView, attachment);
+ attachmentView.setTag(attachment);
+ attachmentView.setVisibility(View.VISIBLE);
+ attachmentViewIndex++;
+ }
+ // If there are unused views left over, unbind or remove them.
+ while (attachmentViewIndex < mMessageAttachmentsView.getChildCount()) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex);
+ if (attachmentViewClass.isInstance(attachmentView)) {
+ mMessageAttachmentsView.removeViewAt(attachmentViewIndex);
+ } else {
+ // No more views of this type; we're done.
+ break;
+ }
+ }
+ }
+
+ private void updateMessageSubject() {
+ final String subjectText = MmsUtils.cleanseMmsSubject(getResources(),
+ mData.getMmsSubject());
+ final boolean subjectVisible = !TextUtils.isEmpty(subjectText);
+
+ if (subjectVisible) {
+ mSubjectText.setText(subjectText);
+ mSubjectView.setVisibility(View.VISIBLE);
+ } else {
+ mSubjectView.setVisibility(View.GONE);
+ }
+ }
+
+ private void updateMessageText() {
+ final String text = mData.getText();
+ if (!TextUtils.isEmpty(text)) {
+ mMessageTextView.setText(text);
+ // Linkify phone numbers, web urls, emails, and map addresses to allow users to
+ // click on them and take the default intent.
+ mMessageTextHasLinks = Linkify.addLinks(mMessageTextView, Linkify.ALL);
+ mMessageTextView.setVisibility(View.VISIBLE);
+ } else {
+ mMessageTextView.setVisibility(View.GONE);
+ mMessageTextHasLinks = false;
+ }
+ }
+
+ private void updateViewAppearance() {
+ final Resources res = getResources();
+ final ConversationDrawables drawableProvider = ConversationDrawables.get();
+ final boolean incoming = mData.getIsIncoming();
+ final boolean outgoing = !incoming;
+ final boolean showArrow = shouldShowMessageBubbleArrow();
+
+ final int messageTopPaddingClustered =
+ res.getDimensionPixelSize(R.dimen.message_padding_same_author);
+ final int messageTopPaddingDefault =
+ res.getDimensionPixelSize(R.dimen.message_padding_default);
+ final int arrowWidth = res.getDimensionPixelOffset(R.dimen.message_bubble_arrow_width);
+ final int messageTextMinHeightDefault = res.getDimensionPixelSize(
+ R.dimen.conversation_message_contact_icon_size);
+ final int messageTextLeftRightPadding = res.getDimensionPixelOffset(
+ R.dimen.message_text_left_right_padding);
+ final int textTopPaddingDefault = res.getDimensionPixelOffset(
+ R.dimen.message_text_top_padding);
+ final int textBottomPaddingDefault = res.getDimensionPixelOffset(
+ R.dimen.message_text_bottom_padding);
+
+ // These values depend on whether the message has text, attachments, or both.
+ // We intentionally don't set defaults, so the compiler will tell us if we forget
+ // to set one of them, or if we set one more than once.
+ final int contentLeftPadding, contentRightPadding;
+ final Drawable textBackground;
+ final int textMinHeight;
+ final int textTopMargin;
+ final int textTopPadding, textBottomPadding;
+ final int textLeftPadding, textRightPadding;
+
+ if (mData.hasAttachments()) {
+ if (shouldShowMessageTextBubble()) {
+ // Text and attachment(s)
+ contentLeftPadding = incoming ? arrowWidth : 0;
+ contentRightPadding = outgoing ? arrowWidth : 0;
+ textBackground = drawableProvider.getBubbleDrawable(
+ isSelected(),
+ incoming,
+ false /* needArrow */,
+ mData.hasIncomingErrorStatus());
+ textMinHeight = messageTextMinHeightDefault;
+ textTopMargin = messageTopPaddingClustered;
+ textTopPadding = textTopPaddingDefault;
+ textBottomPadding = textBottomPaddingDefault;
+ textLeftPadding = messageTextLeftRightPadding;
+ textRightPadding = messageTextLeftRightPadding;
+ } else {
+ // Attachment(s) only
+ contentLeftPadding = incoming ? arrowWidth : 0;
+ contentRightPadding = outgoing ? arrowWidth : 0;
+ textBackground = null;
+ textMinHeight = 0;
+ textTopMargin = 0;
+ textTopPadding = 0;
+ textBottomPadding = 0;
+ textLeftPadding = 0;
+ textRightPadding = 0;
+ }
+ } else {
+ // Text only
+ contentLeftPadding = (!showArrow && incoming) ? arrowWidth : 0;
+ contentRightPadding = (!showArrow && outgoing) ? arrowWidth : 0;
+ textBackground = drawableProvider.getBubbleDrawable(
+ isSelected(),
+ incoming,
+ shouldShowMessageBubbleArrow(),
+ mData.hasIncomingErrorStatus());
+ textMinHeight = messageTextMinHeightDefault;
+ textTopMargin = 0;
+ textTopPadding = textTopPaddingDefault;
+ textBottomPadding = textBottomPaddingDefault;
+ if (showArrow && incoming) {
+ textLeftPadding = messageTextLeftRightPadding + arrowWidth;
+ } else {
+ textLeftPadding = messageTextLeftRightPadding;
+ }
+ if (showArrow && outgoing) {
+ textRightPadding = messageTextLeftRightPadding + arrowWidth;
+ } else {
+ textRightPadding = messageTextLeftRightPadding;
+ }
+ }
+
+ // These values do not depend on whether the message includes attachments
+ final int gravity = incoming ? (Gravity.START | Gravity.CENTER_VERTICAL) :
+ (Gravity.END | Gravity.CENTER_VERTICAL);
+ final int messageTopPadding = shouldShowSimplifiedVisualStyle() ?
+ messageTopPaddingClustered : messageTopPaddingDefault;
+ final int metadataTopPadding = res.getDimensionPixelOffset(
+ R.dimen.message_metadata_top_padding);
+
+ // Update the message text/info views
+ ImageUtils.setBackgroundDrawableOnView(mMessageTextAndInfoView, textBackground);
+ mMessageTextAndInfoView.setMinimumHeight(textMinHeight);
+ final LinearLayout.LayoutParams textAndInfoLayoutParams =
+ (LinearLayout.LayoutParams) mMessageTextAndInfoView.getLayoutParams();
+ textAndInfoLayoutParams.topMargin = textTopMargin;
+
+ if (UiUtils.isRtlMode()) {
+ // Need to switch right and left padding in RtL mode
+ mMessageTextAndInfoView.setPadding(textRightPadding, textTopPadding, textLeftPadding,
+ textBottomPadding);
+ mMessageBubble.setPadding(contentRightPadding, 0, contentLeftPadding, 0);
+ } else {
+ mMessageTextAndInfoView.setPadding(textLeftPadding, textTopPadding, textRightPadding,
+ textBottomPadding);
+ mMessageBubble.setPadding(contentLeftPadding, 0, contentRightPadding, 0);
+ }
+
+ // Update the message row and message bubble views
+ setPadding(getPaddingLeft(), messageTopPadding, getPaddingRight(), 0);
+ mMessageBubble.setGravity(gravity);
+ updateMessageAttachmentsAppearance(gravity);
+
+ mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0);
+
+ updateTextAppearance();
+
+ requestLayout();
+ }
+
+ private void updateContentDescription() {
+ StringBuilder description = new StringBuilder();
+
+ Resources res = getResources();
+ String separator = res.getString(R.string.enumeration_comma);
+
+ // Sender information
+ boolean hasPlainTextMessage = !(TextUtils.isEmpty(mData.getText()) ||
+ mMessageTextHasLinks);
+ if (mData.getIsIncoming()) {
+ int senderResId = hasPlainTextMessage
+ ? R.string.incoming_text_sender_content_description
+ : R.string.incoming_sender_content_description;
+ description.append(res.getString(senderResId, mData.getSenderDisplayName()));
+ } else {
+ int senderResId = hasPlainTextMessage
+ ? R.string.outgoing_text_sender_content_description
+ : R.string.outgoing_sender_content_description;
+ description.append(res.getString(senderResId));
+ }
+
+ if (mSubjectView.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mSubjectText.getText());
+ }
+
+ if (mMessageTextView.getVisibility() == View.VISIBLE) {
+ // If the message has hyperlinks, we will let the user navigate to the text message so
+ // that the hyperlink can be clicked. Otherwise, the text message does not need to
+ // be reachable.
+ if (mMessageTextHasLinks) {
+ mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ } else {
+ mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
+ description.append(separator);
+ description.append(mMessageTextView.getText());
+ }
+ }
+
+ if (mMessageTitleLayout.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mTitleTextView.getText());
+
+ description.append(separator);
+ description.append(mMmsInfoTextView.getText());
+ }
+
+ if (mStatusTextView.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mStatusTextView.getText());
+ }
+
+ if (mSimNameView.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(mSimNameView.getText());
+ }
+
+ if (mDeliveredBadge.getVisibility() == View.VISIBLE) {
+ description.append(separator);
+ description.append(res.getString(R.string.delivered_status_content_description));
+ }
+
+ setContentDescription(description);
+ }
+
+ private void updateMessageAttachmentsAppearance(final int gravity) {
+ mMessageAttachmentsView.setGravity(gravity);
+
+ // Tint image/video attachments when selected
+ final int selectedImageTint = getResources().getColor(R.color.message_image_selected_tint);
+ if (mMessageImageView.getVisibility() == View.VISIBLE) {
+ if (isSelected()) {
+ mMessageImageView.setColorFilter(selectedImageTint);
+ } else {
+ mMessageImageView.clearColorFilter();
+ }
+ }
+ if (mMultiAttachmentView.getVisibility() == View.VISIBLE) {
+ if (isSelected()) {
+ mMultiAttachmentView.setColorFilter(selectedImageTint);
+ } else {
+ mMultiAttachmentView.clearColorFilter();
+ }
+ }
+ for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(i);
+ if (attachmentView instanceof VideoThumbnailView
+ && attachmentView.getVisibility() == View.VISIBLE) {
+ final VideoThumbnailView videoView = (VideoThumbnailView) attachmentView;
+ if (isSelected()) {
+ videoView.setColorFilter(selectedImageTint);
+ } else {
+ videoView.clearColorFilter();
+ }
+ }
+ }
+
+ // If there are multiple attachment bubbles in a single message, add some separation.
+ final int multipleAttachmentPadding =
+ getResources().getDimensionPixelSize(R.dimen.message_padding_same_author);
+
+ boolean previousVisibleView = false;
+ for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) {
+ final View attachmentView = mMessageAttachmentsView.getChildAt(i);
+ if (attachmentView.getVisibility() == View.VISIBLE) {
+ final int margin = previousVisibleView ? multipleAttachmentPadding : 0;
+ ((LinearLayout.LayoutParams) attachmentView.getLayoutParams()).topMargin = margin;
+ // updateViewAppearance calls requestLayout() at the end, so we don't need to here
+ previousVisibleView = true;
+ }
+ }
+ }
+
+ private void updateTextAppearance() {
+ int messageColorResId;
+ int statusColorResId = -1;
+ int infoColorResId = -1;
+ int timestampColorResId;
+ int subjectLabelColorResId;
+ if (isSelected()) {
+ messageColorResId = R.color.message_text_color_incoming;
+ statusColorResId = R.color.message_action_status_text;
+ infoColorResId = R.color.message_action_info_text;
+ if (shouldShowMessageTextBubble()) {
+ timestampColorResId = R.color.message_action_timestamp_text;
+ subjectLabelColorResId = R.color.message_action_timestamp_text;
+ } else {
+ // If there's no text, the timestamp will be shown below the attachments,
+ // against the conversation view background.
+ timestampColorResId = R.color.timestamp_text_outgoing;
+ subjectLabelColorResId = R.color.timestamp_text_outgoing;
+ }
+ } else {
+ messageColorResId = (mData.getIsIncoming() ?
+ R.color.message_text_color_incoming : R.color.message_text_color_outgoing);
+ statusColorResId = messageColorResId;
+ infoColorResId = R.color.timestamp_text_incoming;
+ switch(mData.getStatus()) {
+
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED:
+ case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER:
+ timestampColorResId = R.color.message_failed_timestamp_text;
+ subjectLabelColorResId = R.color.timestamp_text_outgoing;
+ break;
+
+ case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND:
+ case MessageData.BUGLE_STATUS_OUTGOING_SENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_RESENDING:
+ case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY:
+ case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE:
+ case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED:
+ timestampColorResId = R.color.timestamp_text_outgoing;
+ subjectLabelColorResId = R.color.timestamp_text_outgoing;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE:
+ case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED:
+ messageColorResId = R.color.message_text_color_incoming_download_failed;
+ timestampColorResId = R.color.message_download_failed_timestamp_text;
+ subjectLabelColorResId = R.color.message_text_color_incoming_download_failed;
+ statusColorResId = R.color.message_download_failed_status_text;
+ infoColorResId = R.color.message_info_text_incoming_download_failed;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD:
+ case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD:
+ timestampColorResId = R.color.message_text_color_incoming;
+ subjectLabelColorResId = R.color.message_text_color_incoming;
+ infoColorResId = R.color.timestamp_text_incoming;
+ break;
+
+ case MessageData.BUGLE_STATUS_INCOMING_COMPLETE:
+ default:
+ timestampColorResId = R.color.timestamp_text_incoming;
+ subjectLabelColorResId = R.color.timestamp_text_incoming;
+ infoColorResId = -1; // Not used
+ break;
+ }
+ }
+ final int messageColor = getResources().getColor(messageColorResId);
+ mMessageTextView.setTextColor(messageColor);
+ mMessageTextView.setLinkTextColor(messageColor);
+ mSubjectText.setTextColor(messageColor);
+ if (statusColorResId >= 0) {
+ mTitleTextView.setTextColor(getResources().getColor(statusColorResId));
+ }
+ if (infoColorResId >= 0) {
+ mMmsInfoTextView.setTextColor(getResources().getColor(infoColorResId));
+ }
+ if (timestampColorResId == R.color.timestamp_text_incoming &&
+ mData.hasAttachments() && !shouldShowMessageTextBubble()) {
+ timestampColorResId = R.color.timestamp_text_outgoing;
+ }
+ mStatusTextView.setTextColor(getResources().getColor(timestampColorResId));
+
+ mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId));
+ mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId));
+ }
+
+ /**
+ * If we don't know the size of the image, we want to show it in a fixed-sized frame to
+ * avoid janks when the image is loaded and resized. Otherwise, we can set the imageview to
+ * take on normal layout params.
+ */
+ private void adjustImageViewBounds(final MessagePartData imageAttachment) {
+ Assert.isTrue(ContentType.isImageType(imageAttachment.getContentType()));
+ final ViewGroup.LayoutParams layoutParams = mMessageImageView.getLayoutParams();
+ if (imageAttachment.getWidth() == MessagePartData.UNSPECIFIED_SIZE ||
+ imageAttachment.getHeight() == MessagePartData.UNSPECIFIED_SIZE) {
+ // We don't know the size of the image attachment, enable letterboxing on the image
+ // and show a fixed sized attachment. This should happen at most once per image since
+ // after the image is loaded we then save the image dimensions to the db so that the
+ // next time we can display the full size.
+ layoutParams.width = getResources()
+ .getDimensionPixelSize(R.dimen.image_attachment_fallback_width);
+ layoutParams.height = getResources()
+ .getDimensionPixelSize(R.dimen.image_attachment_fallback_height);
+ mMessageImageView.setScaleType(ScaleType.CENTER_CROP);
+ } else {
+ layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT;
+ layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT;
+ // ScaleType.CENTER_INSIDE and FIT_CENTER behave similarly for most images. However,
+ // FIT_CENTER works better for small images as it enlarges the image such that the
+ // minimum size ("android:minWidth" etc) is honored.
+ mMessageImageView.setScaleType(ScaleType.FIT_CENTER);
+ }
+ }
+
+ @Override
+ public void onClick(final View view) {
+ final Object tag = view.getTag();
+ if (tag instanceof MessagePartData) {
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view);
+ onAttachmentClick((MessagePartData) tag, bounds, false /* longPress */);
+ } else if (tag instanceof String) {
+ // Currently the only object that would make a tag of a string is a youtube preview
+ // image
+ UIIntents.get().launchBrowserForUrl(getContext(), (String) tag);
+ }
+ }
+
+ @Override
+ public boolean onLongClick(final View view) {
+ if (view == mMessageTextView) {
+ // Preemptively handle the long click event on message text so it's not handled by
+ // the link spans.
+ return performLongClick();
+ }
+
+ final Object tag = view.getTag();
+ if (tag instanceof MessagePartData) {
+ final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view);
+ return onAttachmentClick((MessagePartData) tag, bounds, true /* longPress */);
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onAttachmentClick(final MessagePartData attachment,
+ final Rect viewBoundsOnScreen, final boolean longPress) {
+ return mHost.onAttachmentClick(this, attachment, viewBoundsOnScreen, longPress);
+ }
+
+ public ContactIconView getContactIconView() {
+ return mContactIconView;
+ }
+
+ // Sort photos in MultiAttachLayout in the same order as the ConversationImagePartsView
+ static final Comparator<MessagePartData> sImageComparator = new Comparator<MessagePartData>(){
+ @Override
+ public int compare(final MessagePartData x, final MessagePartData y) {
+ return x.getPartId().compareTo(y.getPartId());
+ }
+ };
+
+ static final Predicate<MessagePartData> sVideoFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isVideo();
+ }
+ };
+
+ static final Predicate<MessagePartData> sAudioFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isAudio();
+ }
+ };
+
+ static final Predicate<MessagePartData> sVCardFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isVCard();
+ }
+ };
+
+ static final Predicate<MessagePartData> sImageFilter = new Predicate<MessagePartData>() {
+ @Override
+ public boolean apply(final MessagePartData part) {
+ return part.isImage();
+ }
+ };
+
+ interface AttachmentViewBinder {
+ void bindView(View view, MessagePartData attachment);
+ void unbind(View view);
+ }
+
+ final AttachmentViewBinder mVideoViewBinder = new AttachmentViewBinder() {
+ @Override
+ public void bindView(final View view, final MessagePartData attachment) {
+ ((VideoThumbnailView) view).setSource(attachment, mData.getIsIncoming());
+ }
+
+ @Override
+ public void unbind(final View view) {
+ ((VideoThumbnailView) view).setSource((Uri) null, mData.getIsIncoming());
+ }
+ };
+
+ final AttachmentViewBinder mAudioViewBinder = new AttachmentViewBinder() {
+ @Override
+ public void bindView(final View view, final MessagePartData attachment) {
+ final AudioAttachmentView audioView = (AudioAttachmentView) view;
+ audioView.bindMessagePartData(attachment, isSelected() || mData.getIsIncoming());
+ audioView.setBackground(ConversationDrawables.get().getBubbleDrawable(
+ isSelected(), mData.getIsIncoming(), false /* needArrow */,
+ mData.hasIncomingErrorStatus()));
+ }
+
+ @Override
+ public void unbind(final View view) {
+ ((AudioAttachmentView) view).bindMessagePartData(null, mData.getIsIncoming());
+ }
+ };
+
+ final AttachmentViewBinder mVCardViewBinder = new AttachmentViewBinder() {
+ @Override
+ public void bindView(final View view, final MessagePartData attachment) {
+ final PersonItemView personView = (PersonItemView) view;
+ personView.bind(DataModel.get().createVCardContactItemData(getContext(),
+ attachment));
+ personView.setBackground(ConversationDrawables.get().getBubbleDrawable(
+ isSelected(), mData.getIsIncoming(), false /* needArrow */,
+ mData.hasIncomingErrorStatus()));
+ final int nameTextColorRes;
+ final int detailsTextColorRes;
+ if (isSelected()) {
+ nameTextColorRes = R.color.message_text_color_incoming;
+ detailsTextColorRes = R.color.message_text_color_incoming;
+ } else {
+ nameTextColorRes = mData.getIsIncoming() ? R.color.message_text_color_incoming
+ : R.color.message_text_color_outgoing;
+ detailsTextColorRes = mData.getIsIncoming() ? R.color.timestamp_text_incoming
+ : R.color.timestamp_text_outgoing;
+ }
+ personView.setNameTextColor(getResources().getColor(nameTextColorRes));
+ personView.setDetailsTextColor(getResources().getColor(detailsTextColorRes));
+ }
+
+ @Override
+ public void unbind(final View view) {
+ ((PersonItemView) view).bind(null);
+ }
+ };
+
+ /**
+ * A helper class that allows us to handle long clicks on linkified message text view (i.e. to
+ * select the message) so it's not handled by the link spans to launch apps for the links.
+ */
+ private static class IgnoreLinkLongClickHelper implements OnLongClickListener, OnTouchListener {
+ private boolean mIsLongClick;
+ private final OnLongClickListener mDelegateLongClickListener;
+
+ /**
+ * Ignore long clicks on linkified texts for a given text view.
+ * @param textView the TextView to ignore long clicks on
+ * @param longClickListener a delegate OnLongClickListener to be called when the view is
+ * long clicked.
+ */
+ public static void ignoreLinkLongClick(final TextView textView,
+ @Nullable final OnLongClickListener longClickListener) {
+ final IgnoreLinkLongClickHelper helper =
+ new IgnoreLinkLongClickHelper(longClickListener);
+ textView.setOnLongClickListener(helper);
+ textView.setOnTouchListener(helper);
+ }
+
+ private IgnoreLinkLongClickHelper(@Nullable final OnLongClickListener longClickListener) {
+ mDelegateLongClickListener = longClickListener;
+ }
+
+ @Override
+ public boolean onLongClick(final View v) {
+ // Record that this click is a long click.
+ mIsLongClick = true;
+ if (mDelegateLongClickListener != null) {
+ return mDelegateLongClickListener.onLongClick(v);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouch(final View v, final MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsLongClick) {
+ // This touch event is a long click, preemptively handle this touch event so that
+ // the link span won't get a onClicked() callback.
+ mIsLongClick = false;
+ return true;
+ }
+
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ mIsLongClick = false;
+ }
+ return false;
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java
new file mode 100644
index 0000000..fc43a46
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java
@@ -0,0 +1,128 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.support.v4.util.Pair;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.SubscriptionListData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.ui.conversation.SimSelectorView.SimSelectorViewListener;
+import com.android.messaging.util.AccessibilityUtil;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.ThreadUtil;
+
+/**
+ * Manages showing/hiding the SIM selector in conversation.
+ */
+abstract class ConversationSimSelector extends ConversationInput {
+ private SimSelectorView mSimSelectorView;
+ private Pair<Boolean /* show */, Boolean /* animate */> mPendingShow;
+ private boolean mDataReady;
+ private String mSelectedSimText;
+
+ public ConversationSimSelector(ConversationInputBase baseHost) {
+ super(baseHost, false);
+ }
+
+ public void onSubscriptionListDataLoaded(final SubscriptionListData subscriptionListData) {
+ ensureSimSelectorView();
+ mSimSelectorView.bind(subscriptionListData);
+ mDataReady = subscriptionListData != null && subscriptionListData.hasData();
+ if (mPendingShow != null && mDataReady) {
+ Assert.isTrue(OsUtil.isAtLeastL_MR1());
+ final boolean show = mPendingShow.first;
+ final boolean animate = mPendingShow.second;
+ ThreadUtil.getMainThreadHandler().post(new Runnable() {
+ @Override
+ public void run() {
+ // This will No-Op if we are no longer attached to the host.
+ mConversationInputBase.showHideInternal(ConversationSimSelector.this,
+ show, animate);
+ }
+ });
+ mPendingShow = null;
+ }
+ }
+
+ private void announcedSelectedSim() {
+ final Context context = Factory.get().getApplicationContext();
+ if (AccessibilityUtil.isTouchExplorationEnabled(context) &&
+ !TextUtils.isEmpty(mSelectedSimText)) {
+ AccessibilityUtil.announceForAccessibilityCompat(
+ mSimSelectorView, null,
+ context.getString(R.string.selected_sim_content_message, mSelectedSimText));
+ }
+ }
+
+ public void setSelected(final SubscriptionListEntry subEntry) {
+ mSelectedSimText = subEntry == null ? null : subEntry.displayName;
+ }
+
+ @Override
+ public boolean show(boolean animate) {
+ announcedSelectedSim();
+ return showHide(true, animate);
+ }
+
+ @Override
+ public boolean hide(boolean animate) {
+ return showHide(false, animate);
+ }
+
+ private boolean showHide(final boolean show, final boolean animate) {
+ if (!OsUtil.isAtLeastL_MR1()) {
+ return false;
+ }
+
+ if (mDataReady) {
+ mSimSelectorView.showOrHide(show, animate);
+ return mSimSelectorView.isOpen() == show;
+ } else {
+ mPendingShow = Pair.create(show, animate);
+ return false;
+ }
+ }
+
+ private void ensureSimSelectorView() {
+ if (mSimSelectorView == null) {
+ // Grab the SIM selector view from the host. This class assumes ownership of it.
+ mSimSelectorView = getSimSelectorView();
+ mSimSelectorView.setItemLayoutId(getSimSelectorItemLayoutId());
+ mSimSelectorView.setListener(new SimSelectorViewListener() {
+
+ @Override
+ public void onSimSelectorVisibilityChanged(boolean visible) {
+ onVisibilityChanged(visible);
+ }
+
+ @Override
+ public void onSimItemClicked(SubscriptionListEntry item) {
+ selectSim(item);
+ }
+ });
+ }
+ }
+
+ protected abstract SimSelectorView getSimSelectorView();
+ protected abstract void selectSim(final SubscriptionListEntry item);
+ protected abstract int getSimSelectorItemLayoutId();
+
+}
diff --git a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java
new file mode 100644
index 0000000..e3ad601
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java
@@ -0,0 +1,92 @@
+/*
+ * 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.conversation;
+
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.DialogFragment;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.widget.EditText;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.ParticipantRefresh;
+import com.android.messaging.util.BuglePrefs;
+import com.android.messaging.util.UiUtils;
+
+/**
+ * The dialog for the user to enter the phone number of their sim.
+ */
+public class EnterSelfPhoneNumberDialog extends DialogFragment {
+ private EditText mEditText;
+ private int mSubId;
+
+ public static EnterSelfPhoneNumberDialog newInstance(final int subId) {
+ final EnterSelfPhoneNumberDialog dialog = new EnterSelfPhoneNumberDialog();
+ dialog.mSubId = subId;
+ return dialog;
+ }
+
+ @Override
+ public Dialog onCreateDialog(final Bundle savedInstanceState) {
+ final Context context = getActivity();
+ final LayoutInflater inflater = LayoutInflater.from(context);
+ mEditText = (EditText) inflater.inflate(R.layout.enter_phone_number_view, null, false);
+
+ final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(R.string.enter_phone_number_title)
+ .setMessage(R.string.enter_phone_number_text)
+ .setView(mEditText)
+ .setNegativeButton(android.R.string.cancel,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ dismiss();
+ }
+ })
+ .setPositiveButton(android.R.string.ok,
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(final DialogInterface dialog,
+ final int button) {
+ final String newNumber = mEditText.getText().toString();
+ dismiss();
+ if (!TextUtils.isEmpty(newNumber)) {
+ savePhoneNumberInPrefs(newNumber);
+ // TODO: Remove this toast and just auto-send
+ // the message instead
+ UiUtils.showToast(
+ R.string
+ .toast_after_setting_default_sms_app_for_message_send);
+ }
+ }
+ });
+ return builder.create();
+ }
+
+ private void savePhoneNumberInPrefs(final String newPhoneNumber) {
+ final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId);
+ subPrefs.putString(getString(R.string.mms_phone_number_pref_key),
+ newPhoneNumber);
+ // Update the self participants so the new phone number will be reflected
+ // everywhere in the UI.
+ ParticipantRefresh.refreshSelfParticipants();
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java
new file mode 100644
index 0000000..8af9f75
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java
@@ -0,0 +1,134 @@
+/*
+ * 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.conversation;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.binding.Binding;
+import com.android.messaging.datamodel.binding.BindingBase;
+import com.android.messaging.datamodel.data.LaunchConversationData;
+import com.android.messaging.ui.UIIntents;
+import com.android.messaging.util.ContentType;
+import com.android.messaging.util.LogUtil;
+import com.android.messaging.util.UiUtils;
+import com.android.messaging.util.UriUtil;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+
+/**
+ * Launches ConversationActivity for sending a message to, or viewing messages from, a specific
+ * recipient.
+ * <p>
+ * (This activity should be marked noHistory="true" in AndroidManifest.xml)
+ */
+public class LaunchConversationActivity extends Activity implements
+ LaunchConversationData.LaunchConversationDataListener {
+ static final String SMS_BODY = "sms_body";
+ static final String ADDRESS = "address";
+ final Binding<LaunchConversationData> mBinding = BindingBase.createBinding(this);
+ String mSmsBody;
+
+ @Override
+ protected void onCreate(final Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ if (UiUtils.redirectToPermissionCheckIfNeeded(this)) {
+ return;
+ }
+
+ final Intent intent = getIntent();
+ final String action = intent.getAction();
+ if (Intent.ACTION_SENDTO.equals(action) || Intent.ACTION_VIEW.equals(action)) {
+ String[] recipients = UriUtil.parseRecipientsFromSmsMmsUri(intent.getData());
+ final boolean haveAddress = !TextUtils.isEmpty(intent.getStringExtra(ADDRESS));
+ final boolean haveEmail = !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL));
+ if (recipients == null && (haveAddress || haveEmail)) {
+ if (haveAddress) {
+ recipients = new String[] { intent.getStringExtra(ADDRESS) };
+ } else {
+ recipients = new String[] { intent.getStringExtra(Intent.EXTRA_EMAIL) };
+ }
+ }
+ mSmsBody = intent.getStringExtra(SMS_BODY);
+ if (TextUtils.isEmpty(mSmsBody)) {
+ // Used by intents sent from the web YouTube (and perhaps others).
+ mSmsBody = getBody(intent.getData());
+ if (TextUtils.isEmpty(mSmsBody)) {
+ // If that fails, try yet another method apps use to share text
+ if (ContentType.TEXT_PLAIN.equals(intent.getType())) {
+ mSmsBody = intent.getStringExtra(Intent.EXTRA_TEXT);
+ }
+ }
+ }
+ if (recipients != null) {
+ mBinding.bind(DataModel.get().createLaunchConversationData(this));
+ mBinding.getData().getOrCreateConversation(mBinding, recipients);
+ } else {
+ // No recipients were specified in the intent.
+ // Start a new conversation with contact picker. The new conversation will be
+ // primed with the (optional) message in mSmsBody.
+ onGetOrCreateNewConversation(null);
+ }
+ } else {
+ LogUtil.w(LogUtil.BUGLE_TAG, "Unsupported conversation intent action : " + action);
+ }
+ // As of M, activities without a visible window must finish before onResume completes.
+ finish();
+ }
+
+ private String getBody(final Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ String urlStr = uri.getSchemeSpecificPart();
+ if (!urlStr.contains("?")) {
+ return null;
+ }
+ urlStr = urlStr.substring(urlStr.indexOf('?') + 1);
+ final String[] params = urlStr.split("&");
+ for (final String p : params) {
+ if (p.startsWith("body=")) {
+ try {
+ return URLDecoder.decode(p.substring(5), "UTF-8");
+ } catch (final UnsupportedEncodingException e) {
+ // Invalid URL, ignore
+ }
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public void onGetOrCreateNewConversation(final String conversationId) {
+ final Context context = Factory.get().getApplicationContext();
+ UIIntents.get().launchConversationActivityWithParentStack(context, conversationId,
+ mSmsBody);
+ }
+
+ @Override
+ public void onGetOrCreateNewConversationFailed() {
+ UiUtils.showToastAtBottom(R.string.conversation_creation_failure);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java
new file mode 100644
index 0000000..4c22970
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java
@@ -0,0 +1,47 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.LinearLayout;
+
+import com.android.messaging.R;
+
+public class MessageBubbleBackground extends LinearLayout {
+ private final int mSnapWidthPixels;
+
+ public MessageBubbleBackground(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mSnapWidthPixels = context.getResources().getDimensionPixelSize(
+ R.dimen.conversation_bubble_width_snap);
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ final int widthPadding = getPaddingLeft() + getPaddingRight();
+ int bubbleWidth = getMeasuredWidth() - widthPadding;
+ final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) - widthPadding;
+ // Round up to next snapWidthPixels
+ bubbleWidth = Math.min(maxWidth,
+ (int) (Math.ceil(bubbleWidth / (float) mSnapWidthPixels) * mSnapWidthPixels));
+ super.onMeasure(
+ MeasureSpec.makeMeasureSpec(bubbleWidth + widthPadding, MeasureSpec.EXACTLY),
+ heightMeasureSpec);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java
new file mode 100644
index 0000000..89b9148
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java
@@ -0,0 +1,381 @@
+/*
+ * 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.conversation;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.res.Resources;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.text.format.Formatter;
+
+import com.android.messaging.Factory;
+import com.android.messaging.R;
+import com.android.messaging.datamodel.BugleDatabaseOperations;
+import com.android.messaging.datamodel.DataModel;
+import com.android.messaging.datamodel.data.ConversationMessageData;
+import com.android.messaging.datamodel.data.ConversationParticipantsData;
+import com.android.messaging.datamodel.data.ParticipantData;
+import com.android.messaging.mmslib.pdu.PduHeaders;
+import com.android.messaging.sms.DatabaseMessages.MmsMessage;
+import com.android.messaging.sms.MmsUtils;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.Assert.DoesNotRunOnMainThread;
+import com.android.messaging.util.Dates;
+import com.android.messaging.util.DebugUtils;
+import com.android.messaging.util.OsUtil;
+import com.android.messaging.util.PhoneUtils;
+import com.android.messaging.util.SafeAsyncTask;
+
+import java.util.List;
+
+public class MessageDetailsDialog {
+ private static final String RECIPIENT_SEPARATOR = ", ";
+
+ // All methods are static, no creating this class
+ private MessageDetailsDialog() {
+ }
+
+ public static void show(final Context context, final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ if (DebugUtils.isDebugEnabled()) {
+ new SafeAsyncTask<Void, Void, String>() {
+ @Override
+ protected String doInBackgroundTimed(Void... params) {
+ return getMessageDetails(context, data, participants, self);
+ }
+
+ @Override
+ protected void onPostExecute(String messageDetails) {
+ showDialog(context, messageDetails);
+ }
+ }.executeOnThreadPool(null, null, null);
+ } else {
+ String messageDetails = getMessageDetails(context, data, participants, self);
+ showDialog(context, messageDetails);
+ }
+ }
+
+ private static String getMessageDetails(final Context context,
+ final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ String messageDetails = null;
+ if (data.getIsSms()) {
+ messageDetails = getSmsMessageDetails(data, participants, self);
+ } else {
+ // TODO: Handle SMS_TYPE_MMS_PUSH_NOTIFICATION type differently?
+ messageDetails = getMmsMessageDetails(context, data, participants, self);
+ }
+
+ return messageDetails;
+ }
+
+ private static void showDialog(final Context context, String messageDetails) {
+ if (!TextUtils.isEmpty(messageDetails)) {
+ new AlertDialog.Builder(context)
+ .setTitle(R.string.message_details_title)
+ .setMessage(messageDetails)
+ .setCancelable(true)
+ .show();
+ }
+ }
+
+ /**
+ * Return a string, separated by newlines, that contains a number of labels and values
+ * for this sms message. The string will be displayed in a modal dialog.
+ * @return string list of various message properties
+ */
+ private static String getSmsMessageDetails(final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ final Resources res = Factory.get().getApplicationContext().getResources();
+ final StringBuilder details = new StringBuilder();
+
+ // Type: Text message
+ details.append(res.getString(R.string.message_type_label));
+ details.append(res.getString(R.string.text_message));
+
+ // From: +1425xxxxxxx
+ // or To: +1425xxxxxxx
+ final String rawSender = data.getSenderNormalizedDestination();
+ if (!TextUtils.isEmpty(rawSender)) {
+ details.append('\n');
+ details.append(res.getString(R.string.from_label));
+ details.append(rawSender);
+ }
+ final String rawRecipients = getRecipientParticipantString(participants,
+ data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId());
+ if (!TextUtils.isEmpty(rawRecipients)) {
+ details.append('\n');
+ details.append(res.getString(R.string.to_address_label));
+ details.append(rawRecipients);
+ }
+
+ // Sent: Mon 11:42AM
+ if (data.getIsIncoming()) {
+ if (data.getSentTimeStamp() != MmsUtils.INVALID_TIMESTAMP) {
+ details.append('\n');
+ details.append(res.getString(R.string.sent_label));
+ details.append(
+ Dates.getMessageDetailsTimeString(data.getSentTimeStamp()).toString());
+ }
+ }
+
+ // Sent: Mon 11:43AM
+ // or Received: Mon 11:43AM
+ appendSentOrReceivedTimestamp(res, details, data);
+
+ appendSimInfo(res, self, details);
+
+ if (DebugUtils.isDebugEnabled()) {
+ appendDebugInfo(details, data);
+ }
+
+ return details.toString();
+ }
+
+ /**
+ * Return a string, separated by newlines, that contains a number of labels and values
+ * for this mms message. The string will be displayed in a modal dialog.
+ * @return string list of various message properties
+ */
+ private static String getMmsMessageDetails(Context context, final ConversationMessageData data,
+ final ConversationParticipantsData participants, final ParticipantData self) {
+ final Resources res = Factory.get().getApplicationContext().getResources();
+ // TODO: when we support non-auto-download of mms messages, we'll have to handle
+ // the case when the message is a PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND and display
+ // something different. See the Messaging app's MessageUtils.getNotificationIndDetails()
+
+ final StringBuilder details = new StringBuilder();
+
+ // Type: Multimedia message.
+ details.append(res.getString(R.string.message_type_label));
+ details.append(res.getString(R.string.multimedia_message));
+
+ // From: +1425xxxxxxx
+ final String rawSender = data.getSenderNormalizedDestination();
+ details.append('\n');
+ details.append(res.getString(R.string.from_label));
+ details.append(!TextUtils.isEmpty(rawSender) ? rawSender :
+ res.getString(R.string.hidden_sender_address));
+
+ // To: +1425xxxxxxx
+ final String rawRecipients = getRecipientParticipantString(participants,
+ data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId());
+ if (!TextUtils.isEmpty(rawRecipients)) {
+ details.append('\n');
+ details.append(res.getString(R.string.to_address_label));
+ details.append(rawRecipients);
+ }
+
+ // Sent: Tue 3:05PM
+ // or Received: Tue 3:05PM
+ appendSentOrReceivedTimestamp(res, details, data);
+
+ // Subject: You're awesome
+ details.append('\n');
+ details.append(res.getString(R.string.subject_label));
+ if (!TextUtils.isEmpty(MmsUtils.cleanseMmsSubject(res, data.getMmsSubject()))) {
+ details.append(data.getMmsSubject());
+ }
+
+ // Priority: High/Normal/Low
+ details.append('\n');
+ details.append(res.getString(R.string.priority_label));
+ details.append(getPriorityDescription(res, data.getSmsPriority()));
+
+ // Message size: 30 KB
+ if (data.getSmsMessageSize() > 0) {
+ details.append('\n');
+ details.append(res.getString(R.string.message_size_label));
+ details.append(Formatter.formatFileSize(context, data.getSmsMessageSize()));
+ }
+
+ appendSimInfo(res, self, details);
+
+ if (DebugUtils.isDebugEnabled()) {
+ appendDebugInfo(details, data);
+ }
+
+ return details.toString();
+ }
+
+ private static void appendSentOrReceivedTimestamp(Resources res, StringBuilder details,
+ ConversationMessageData data) {
+ int labelId = -1;
+ if (data.getIsIncoming()) {
+ labelId = R.string.received_label;
+ } else if (data.getIsSendComplete()) {
+ labelId = R.string.sent_label;
+ }
+ if (labelId >= 0) {
+ details.append('\n');
+ details.append(res.getString(labelId));
+ details.append(
+ Dates.getMessageDetailsTimeString(data.getReceivedTimeStamp()).toString());
+ }
+ }
+
+ @DoesNotRunOnMainThread
+ private static void appendDebugInfo(StringBuilder details, ConversationMessageData data) {
+ // We grab the thread id from the database, so this needs to run in the background
+ Assert.isNotMainThread();
+ details.append("\n\n");
+ details.append("DEBUG");
+
+ details.append('\n');
+ details.append("Message id: ");
+ details.append(data.getMessageId());
+
+ final String telephonyUri = data.getSmsMessageUri();
+ details.append('\n');
+ details.append("Telephony uri: ");
+ details.append(telephonyUri);
+
+ final String conversationId = data.getConversationId();
+
+ if (conversationId == null) {
+ return;
+ }
+
+ details.append('\n');
+ details.append("Conversation id: ");
+ details.append(conversationId);
+
+ final long threadId = BugleDatabaseOperations.getThreadId(DataModel.get().getDatabase(),
+ conversationId);
+
+ details.append('\n');
+ details.append("Conversation telephony thread id: ");
+ details.append(threadId);
+
+ MmsMessage mms = null;
+
+ if (data.getIsMms()) {
+ if (telephonyUri == null) {
+ return;
+ }
+ mms = MmsUtils.loadMms(Uri.parse(telephonyUri));
+ if (mms == null) {
+ return;
+ }
+
+ // We log the thread id again to check that they are internally consistent
+ final long mmsThreadId = mms.mThreadId;
+ details.append('\n');
+ details.append("Telephony thread id: ");
+ details.append(mmsThreadId);
+
+ // Log the MMS content location
+ final String mmsContentLocation = mms.mContentLocation;
+ details.append('\n');
+ details.append("Content location URL: ");
+ details.append(mmsContentLocation);
+ }
+
+ final String recipientsString = MmsUtils.getRawRecipientIdsForThread(threadId);
+ if (recipientsString != null) {
+ details.append('\n');
+ details.append("Thread recipient ids: ");
+ details.append(recipientsString);
+ }
+
+ final List<String> recipients = MmsUtils.getRecipientsByThread(threadId);
+ if (recipients != null) {
+ details.append('\n');
+ details.append("Thread recipients: ");
+ details.append(recipients.toString());
+
+ if (mms != null) {
+ final String from = MmsUtils.getMmsSender(recipients, mms.getUri());
+ details.append('\n');
+ details.append("Sender: ");
+ details.append(from);
+ }
+ }
+ }
+
+ private static String getRecipientParticipantString(
+ final ConversationParticipantsData participants, final String senderId,
+ final boolean addSelf, final String selfId) {
+ final StringBuilder recipients = new StringBuilder();
+ for (final ParticipantData participant : participants) {
+ if (TextUtils.equals(participant.getId(), senderId)) {
+ // Don't add sender
+ continue;
+ }
+ if (participant.isSelf() &&
+ (!participant.getId().equals(selfId) || !addSelf)) {
+ // For self participants, don't add the one that's not relevant to this message
+ // or if we are asked not to add self
+ continue;
+ }
+ final String phoneNumber = participant.getNormalizedDestination();
+ // Don't add empty number. This should not happen. But if that happens
+ // we should not add it.
+ if (!TextUtils.isEmpty(phoneNumber)) {
+ if (recipients.length() > 0) {
+ recipients.append(RECIPIENT_SEPARATOR);
+ }
+ recipients.append(phoneNumber);
+ }
+ }
+ return recipients.toString();
+ }
+
+ /**
+ * Convert the numeric mms priority into a human-readable string
+ * @param res
+ * @param priorityValue coded PduHeader priority
+ * @return string representation of the priority
+ */
+ private static String getPriorityDescription(final Resources res, final int priorityValue) {
+ switch(priorityValue) {
+ case PduHeaders.PRIORITY_HIGH:
+ return res.getString(R.string.priority_high);
+ case PduHeaders.PRIORITY_LOW:
+ return res.getString(R.string.priority_low);
+ case PduHeaders.PRIORITY_NORMAL:
+ default:
+ return res.getString(R.string.priority_normal);
+ }
+ }
+
+ private static void appendSimInfo(final Resources res,
+ final ParticipantData self, final StringBuilder outString) {
+ if (!OsUtil.isAtLeastL_MR1()
+ || self == null
+ || PhoneUtils.getDefault().getActiveSubscriptionCount() < 2) {
+ return;
+ }
+ // The appended SIM info would look like:
+ // SIM: SUB 01
+ // or SIM: SIM 1
+ // or SIM: Unknown
+ Assert.isTrue(self.isSelf());
+ outString.append('\n');
+ outString.append(res.getString(R.string.sim_label));
+ if (self.isActiveSubscription() && !self.isDefaultSelf()) {
+ final String subscriptionName = self.getSubscriptionName();
+ if (TextUtils.isEmpty(subscriptionName)) {
+ outString.append(res.getString(R.string.sim_slot_identifier,
+ self.getDisplaySlotId()));
+ } else {
+ outString.append(subscriptionName);
+ }
+ }
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/SimIconView.java b/src/com/android/messaging/ui/conversation/SimIconView.java
new file mode 100644
index 0000000..e2e446c
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/SimIconView.java
@@ -0,0 +1,51 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.graphics.Outline;
+import android.net.Uri;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+
+import com.android.messaging.ui.ContactIconView;
+import com.android.messaging.util.Assert;
+import com.android.messaging.util.AvatarUriUtil;
+import com.android.messaging.util.OsUtil;
+
+/**
+ * Shows SIM avatar icon in the SIM switcher / Self-send button.
+ */
+public class SimIconView extends ContactIconView {
+ public SimIconView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ if (OsUtil.isAtLeastL()) {
+ setOutlineProvider(new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View v, Outline outline) {
+ outline.setOval(0, 0, v.getWidth(), v.getHeight());
+ }
+ });
+ }
+ }
+
+ @Override
+ protected void maybeInitializeOnClickListener() {
+ // TODO: SIM icon view shouldn't consume or handle clicks, but it should if
+ // this is the send button for the only SIM in the device or if MSIM is not supported.
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java
new file mode 100644
index 0000000..3058d31
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java
@@ -0,0 +1,90 @@
+/*
+ * 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.conversation;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.util.Assert;
+
+/**
+ * Shows a view for a SIM in the SIM selector.
+ */
+public class SimSelectorItemView extends LinearLayout {
+ public interface HostInterface {
+ void onSimItemClicked(SubscriptionListEntry item);
+ }
+
+ private SubscriptionListEntry mData;
+ private TextView mNameTextView;
+ private TextView mDetailsTextView;
+ private SimIconView mSimIconView;
+ private HostInterface mHost;
+
+ public SimSelectorItemView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ mNameTextView = (TextView) findViewById(R.id.name);
+ mDetailsTextView = (TextView) findViewById(R.id.details);
+ mSimIconView = (SimIconView) findViewById(R.id.sim_icon);
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ mHost.onSimItemClicked(mData);
+ }
+ });
+ }
+
+ public void bind(final SubscriptionListEntry simEntry) {
+ Assert.notNull(simEntry);
+ mData = simEntry;
+ updateViewAppearance();
+ }
+
+ public void setHostInterface(final HostInterface host) {
+ mHost = host;
+ }
+
+ private void updateViewAppearance() {
+ Assert.notNull(mData);
+ final String displayName = mData.displayName;
+ if (TextUtils.isEmpty(displayName)) {
+ mNameTextView.setVisibility(GONE);
+ } else {
+ mNameTextView.setVisibility(VISIBLE);
+ mNameTextView.setText(displayName);
+ }
+
+ final String details = mData.displayDestination;
+ if (TextUtils.isEmpty(details)) {
+ mDetailsTextView.setVisibility(GONE);
+ } else {
+ mDetailsTextView.setVisibility(VISIBLE);
+ mDetailsTextView.setText(details);
+ }
+
+ mSimIconView.setImageResourceUri(mData.iconUri);
+ }
+}
diff --git a/src/com/android/messaging/ui/conversation/SimSelectorView.java b/src/com/android/messaging/ui/conversation/SimSelectorView.java
new file mode 100644
index 0000000..b07ff19
--- /dev/null
+++ b/src/com/android/messaging/ui/conversation/SimSelectorView.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.conversation;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.TranslateAnimation;
+import android.widget.ArrayAdapter;
+import android.widget.FrameLayout;
+import android.widget.ListView;
+
+import com.android.messaging.R;
+import com.android.messaging.datamodel.data.SubscriptionListData;
+import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry;
+import com.android.messaging.util.UiUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Displays a SIM selector above the compose message view and overlays the message list.
+ */
+public class SimSelectorView extends FrameLayout implements SimSelectorItemView.HostInterface {
+ public interface SimSelectorViewListener {
+ void onSimItemClicked(SubscriptionListEntry item);
+ void onSimSelectorVisibilityChanged(boolean visible);
+ }
+
+ private ListView mSimListView;
+ private final SimSelectorAdapter mAdapter;
+ private boolean mShow;
+ private SimSelectorViewListener mListener;
+ private int mItemLayoutId;
+
+ public SimSelectorView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ mAdapter = new SimSelectorAdapter(getContext());
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ mSimListView = (ListView) findViewById(R.id.sim_list);
+ mSimListView.setAdapter(mAdapter);
+
+ // Clicking anywhere outside the switcher list should dismiss.
+ setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ showOrHide(false, true);
+ }
+ });
+ }
+
+ public void bind(final SubscriptionListData data) {
+ mAdapter.bindData(data.getActiveSubscriptionEntriesExcludingDefault());
+ }
+
+ public void setItemLayoutId(final int layoutId) {
+ mItemLayoutId = layoutId;
+ }
+
+ public void setListener(final SimSelectorViewListener listener) {
+ mListener = listener;
+ }
+
+ public void toggleVisibility() {
+ showOrHide(!mShow, true);
+ }
+
+ public void showOrHide(final boolean show, final boolean animate) {
+ final boolean oldShow = mShow;
+ mShow = show && mAdapter.getCount() > 1;
+ if (oldShow != mShow) {
+ if (mListener != null) {
+ mListener.onSimSelectorVisibilityChanged(mShow);
+ }
+
+ if (animate) {
+ // Fade in the background pane.
+ setVisibility(VISIBLE);
+ setAlpha(mShow ? 0.0f : 1.0f);
+ animate().alpha(mShow ? 1.0f : 0.0f)
+ .setDuration(UiUtils.REVEAL_ANIMATION_DURATION)
+ .withEndAction(new Runnable() {
+ @Override
+ public void run() {
+ setAlpha(1.0f);
+ setVisibility(mShow ? VISIBLE : GONE);
+ }
+ });
+ } else {
+ setVisibility(mShow ? VISIBLE : GONE);
+ }
+
+ // Slide in the SIM selector list via a translate animation.
+ mSimListView.setVisibility(mShow ? VISIBLE : GONE);
+ if (animate) {
+ mSimListView.clearAnimation();
+ final TranslateAnimation translateAnimation = new TranslateAnimation(
+ Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0,
+ Animation.RELATIVE_TO_SELF, mShow ? 1.0f : 0.0f,
+ Animation.RELATIVE_TO_SELF, mShow ? 0.0f : 1.0f);
+ translateAnimation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR);
+ translateAnimation.setDuration(UiUtils.REVEAL_ANIMATION_DURATION);
+ mSimListView.startAnimation(translateAnimation);
+ }
+ }
+ }
+
+ /**
+ * An adapter that takes a list of SubscriptionListEntry and displays them as a list of
+ * available SIMs in the SIM selector.
+ */
+ private class SimSelectorAdapter extends ArrayAdapter<SubscriptionListEntry> {
+ public SimSelectorAdapter(final Context context) {
+ super(context, R.layout.sim_selector_item_view, new ArrayList<SubscriptionListEntry>());
+ }
+
+ public void bindData(final List<SubscriptionListEntry> newList) {
+ clear();
+ addAll(newList);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(final int position, final View convertView, final ViewGroup parent) {
+ SimSelectorItemView itemView;
+ if (convertView != null && convertView instanceof SimSelectorItemView) {
+ itemView = (SimSelectorItemView) convertView;
+ } else {
+ final LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ itemView = (SimSelectorItemView) inflater.inflate(mItemLayoutId,
+ parent, false);
+ itemView.setHostInterface(SimSelectorView.this);
+ }
+ itemView.bind(getItem(position));
+ return itemView;
+ }
+ }
+
+ @Override
+ public void onSimItemClicked(SubscriptionListEntry item) {
+ mListener.onSimItemClicked(item);
+ showOrHide(false, true);
+ }
+
+ public boolean isOpen() {
+ return mShow;
+ }
+}