blob: 20c986d5800e831aed3e32c32045cd5ff2a3cca6 [file] [log] [blame]
/*
* 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 androidx.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, mData.getIsIncoming(), isSelected());
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(), false);
}
};
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;
}
}
}