diff options
Diffstat (limited to 'src/com/android/messaging/ui/conversation')
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; + } +} |