blob: 18287d01cdbf6ce03ff616f65daa11137e4a6336 [file] [log] [blame]
/**
* Copyright (c) 2011, Google Inc.
*
* 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.mail.browse;
import android.annotation.SuppressLint;
import android.app.FragmentManager;
import android.content.AsyncQueryHandler;
import android.content.Context;
import android.content.res.Resources;
import android.database.DataSetObserver;
import android.graphics.Bitmap;
import androidx.core.text.BidiFormatter;
import android.text.Html;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.PopupMenu;
import android.widget.PopupMenu.OnMenuItemClickListener;
import android.widget.QuickContactBadge;
import android.widget.TextView;
import android.widget.Toast;
import com.android.emailcommon.mail.Address;
import com.android.mail.ContactInfo;
import com.android.mail.ContactInfoSource;
import com.android.mail.R;
import com.android.mail.analytics.Analytics;
import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
import com.android.mail.compose.ComposeActivity;
import com.android.mail.perf.Timer;
import com.android.mail.photomanager.LetterTileProvider;
import com.android.mail.print.PrintUtils;
import com.android.mail.providers.Account;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.Message;
import com.android.mail.providers.Settings;
import com.android.mail.providers.UIProvider;
import com.android.mail.text.EmailAddressSpan;
import com.android.mail.ui.AbstractConversationViewFragment;
import com.android.mail.ui.ImageCanvas;
import com.android.mail.utils.BitmapUtil;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.StyleUtils;
import com.android.mail.utils.Utils;
import com.android.mail.utils.VeiledAddressMatcher;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.io.StringReader;
import java.util.Map;
public class MessageHeaderView extends SnapHeader implements OnClickListener,
OnMenuItemClickListener, ConversationContainer.DetachListener {
/**
* Cap very long recipient lists during summary construction for efficiency.
*/
private static final int SUMMARY_MAX_RECIPIENTS = 50;
private static final int MAX_SNIPPET_LENGTH = 100;
private static final int SHOW_IMAGE_PROMPT_ONCE = 1;
private static final int SHOW_IMAGE_PROMPT_ALWAYS = 2;
private static final String HEADER_RENDER_TAG = "message header render";
private static final String LAYOUT_TAG = "message header layout";
private static final String MEASURE_TAG = "message header measure";
private static final String LOG_TAG = LogTag.getLogTag();
// This is a debug only feature
public static final boolean ENABLE_REPORT_RENDERING_PROBLEM = false;
private MessageHeaderViewCallbacks mCallbacks;
private View mBorderView;
private ViewGroup mUpperHeaderView;
private View mTitleContainer;
private View mSnapHeaderBottomBorder;
private TextView mSenderNameView;
private TextView mRecipientSummary;
private TextView mDateView;
private View mHideDetailsView;
private TextView mSnippetView;
private MessageHeaderContactBadge mPhotoView;
private ViewGroup mExtraContentView;
private ViewGroup mExpandedDetailsView;
private SpamWarningView mSpamWarningView;
private TextView mImagePromptView;
private MessageInviteView mInviteView;
private View mForwardButton;
private View mOverflowButton;
private View mDraftIcon;
private View mEditDraftButton;
private TextView mUpperDateView;
private View mReplyButton;
private View mReplyAllButton;
private View mAttachmentIcon;
private final EmailCopyContextMenu mEmailCopyMenu;
// temporary fields to reference raw data between initial render and details
// expansion
private String[] mFrom;
private String[] mTo;
private String[] mCc;
private String[] mBcc;
private String[] mReplyTo;
private boolean mIsDraft = false;
private int mSendingState;
private String mSnippet;
private Address mSender;
private ContactInfoSource mContactInfoSource;
private boolean mPreMeasuring;
private ConversationAccountController mAccountController;
private Map<String, Address> mAddressCache;
private boolean mShowImagePrompt;
private PopupMenu mPopup;
private MessageHeaderItem mMessageHeaderItem;
private ConversationMessage mMessage;
private boolean mRecipientSummaryValid;
private boolean mExpandedDetailsValid;
private final LayoutInflater mInflater;
private AsyncQueryHandler mQueryHandler;
private boolean mObservingContactInfo;
/**
* What I call myself? "me" in English, and internationalized correctly.
*/
private final String mMyName;
private final DataSetObserver mContactInfoObserver = new DataSetObserver() {
@Override
public void onChanged() {
updateContactInfo();
}
};
private boolean mExpandable = true;
private VeiledAddressMatcher mVeiledMatcher;
private boolean mIsViewOnlyMode = false;
private LetterTileProvider mLetterTileProvider;
private final int mContactPhotoWidth;
private final int mContactPhotoHeight;
private final int mTitleContainerMarginEnd;
/**
* The snappy header has special visibility rules (i.e. no details header,
* even though it has an expanded appearance)
*/
private boolean mIsSnappy;
private BidiFormatter mBidiFormatter;
public interface MessageHeaderViewCallbacks {
void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeight);
void setMessageExpanded(MessageHeaderItem item, int newSpacerHeight);
void setMessageDetailsExpanded(MessageHeaderItem messageHeaderItem, boolean expanded,
int previousMessageHeaderItemHeight);
void showExternalResources(Message msg);
void showExternalResources(String senderRawAddress);
boolean supportsMessageTransforms();
String getMessageTransforms(Message msg);
FragmentManager getFragmentManager();
/**
* @return <tt>true</tt> if this header is contained within a SecureConversationViewFragment
* and cannot assume the content is <strong>not</strong> malicious
*/
boolean isSecure();
}
public MessageHeaderView(Context context) {
this(context, null);
}
public MessageHeaderView(Context context, AttributeSet attrs) {
this(context, attrs, -1);
}
public MessageHeaderView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mIsSnappy = false;
mEmailCopyMenu = new EmailCopyContextMenu(getContext());
mInflater = LayoutInflater.from(context);
mMyName = context.getString(R.string.me_object_pronoun);
final Resources res = getResources();
mContactPhotoWidth = res.getDimensionPixelSize(R.dimen.contact_image_width);
mContactPhotoHeight = res.getDimensionPixelSize(R.dimen.contact_image_height);
mTitleContainerMarginEnd = res.getDimensionPixelSize(R.dimen.conversation_view_margin_side);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mBorderView = findViewById(R.id.message_header_border);
mUpperHeaderView = (ViewGroup) findViewById(R.id.upper_header);
mTitleContainer = findViewById(R.id.title_container);
mSnapHeaderBottomBorder = findViewById(R.id.snap_header_bottom_border);
mSenderNameView = (TextView) findViewById(R.id.sender_name);
mRecipientSummary = (TextView) findViewById(R.id.recipient_summary);
mDateView = (TextView) findViewById(R.id.send_date);
mHideDetailsView = findViewById(R.id.hide_details);
mSnippetView = (TextView) findViewById(R.id.email_snippet);
mPhotoView = (MessageHeaderContactBadge) findViewById(R.id.photo);
mPhotoView.setQuickContactBadge(
(QuickContactBadge) findViewById(R.id.invisible_quick_contact));
mReplyButton = findViewById(R.id.reply);
mReplyAllButton = findViewById(R.id.reply_all);
mForwardButton = findViewById(R.id.forward);
mOverflowButton = findViewById(R.id.overflow);
mDraftIcon = findViewById(R.id.draft);
mEditDraftButton = findViewById(R.id.edit_draft);
mUpperDateView = (TextView) findViewById(R.id.upper_date);
mAttachmentIcon = findViewById(R.id.attachment);
mExtraContentView = (ViewGroup) findViewById(R.id.header_extra_content);
setExpanded(true);
registerMessageClickTargets(mReplyButton, mReplyAllButton, mForwardButton,
mEditDraftButton, mOverflowButton, mUpperHeaderView, mDateView, mHideDetailsView);
mUpperHeaderView.setOnCreateContextMenuListener(mEmailCopyMenu);
}
private void registerMessageClickTargets(View... views) {
for (View v : views) {
if (v != null) {
v.setOnClickListener(this);
}
}
}
@Override
public void initialize(ConversationAccountController accountController,
Map<String, Address> addressCache, MessageHeaderViewCallbacks callbacks,
ContactInfoSource contactInfoSource, VeiledAddressMatcher veiledAddressMatcher) {
initialize(accountController, addressCache);
setCallbacks(callbacks);
setContactInfoSource(contactInfoSource);
setVeiledMatcher(veiledAddressMatcher);
}
/**
* Associate the header with a contact info source for later contact
* presence/photo lookup.
*/
public void setContactInfoSource(ContactInfoSource contactInfoSource) {
mContactInfoSource = contactInfoSource;
}
public void setCallbacks(MessageHeaderViewCallbacks callbacks) {
mCallbacks = callbacks;
}
public void setVeiledMatcher(VeiledAddressMatcher matcher) {
mVeiledMatcher = matcher;
}
public boolean isExpanded() {
// (let's just arbitrarily say that unbound views are expanded by default)
return mMessageHeaderItem == null || mMessageHeaderItem.isExpanded();
}
@Override
public void onDetachedFromParent() {
unbind();
}
/**
* Headers that are unbound will not match any rendered header (matches()
* will return false). Unbinding is not guaranteed to *hide* the view's old
* data, though. To re-bind this header to message data, call render() or
* renderUpperHeaderFrom().
*/
@Override
public void unbind() {
mMessageHeaderItem = null;
mMessage = null;
if (mObservingContactInfo) {
mContactInfoSource.unregisterObserver(mContactInfoObserver);
mObservingContactInfo = false;
}
}
public void initialize(ConversationAccountController accountController,
Map<String, Address> addressCache) {
mAccountController = accountController;
mAddressCache = addressCache;
}
private Account getAccount() {
return mAccountController != null ? mAccountController.getAccount() : null;
}
public void bind(MessageHeaderItem headerItem, boolean measureOnly) {
if (mMessageHeaderItem != null && mMessageHeaderItem == headerItem) {
return;
}
mMessageHeaderItem = headerItem;
render(measureOnly);
}
/**
* Rebinds the view to its data. This will only update the view
* if the {@link MessageHeaderItem} sent as a parameter is the
* same as the view's current {@link MessageHeaderItem} and the
* view's expanded state differs from the item's expanded state.
*/
public void rebind(MessageHeaderItem headerItem) {
if (mMessageHeaderItem == null || mMessageHeaderItem != headerItem ||
isActivated() == isExpanded()) {
return;
}
render(false /* measureOnly */);
}
@Override
public void refresh() {
render(false);
}
private BidiFormatter getBidiFormatter() {
if (mBidiFormatter == null) {
final ConversationViewAdapter adapter = mMessageHeaderItem != null
? mMessageHeaderItem.getAdapter() : null;
if (adapter == null) {
mBidiFormatter = BidiFormatter.getInstance();
} else {
mBidiFormatter = adapter.getBidiFormatter();
}
}
return mBidiFormatter;
}
private void render(boolean measureOnly) {
if (mMessageHeaderItem == null) {
return;
}
Timer t = new Timer();
t.start(HEADER_RENDER_TAG);
mRecipientSummaryValid = false;
mExpandedDetailsValid = false;
mMessage = mMessageHeaderItem.getMessage();
final Account account = getAccount();
final boolean alwaysShowImagesForAccount = (account != null) &&
(account.settings.showImages == Settings.ShowImages.ALWAYS);
final boolean alwaysShowImagesForMessage = mMessage.shouldShowImagePrompt();
if (!alwaysShowImagesForMessage) {
// we don't need the "Show picture" prompt if the user allows images for this message
mShowImagePrompt = false;
} else if (mCallbacks.isSecure()) {
// in a secure view we always display the "Show picture" prompt
mShowImagePrompt = true;
} else {
// otherwise honor the account setting for automatically showing pictures
mShowImagePrompt = !alwaysShowImagesForAccount;
}
setExpanded(mMessageHeaderItem.isExpanded());
mFrom = mMessage.getFromAddresses();
mTo = mMessage.getToAddresses();
mCc = mMessage.getCcAddresses();
mBcc = mMessage.getBccAddresses();
mReplyTo = mMessage.getReplyToAddresses();
/**
* Turns draft mode on or off. Draft mode hides message operations other
* than "edit", hides contact photo, hides presence, and changes the
* sender name to "Draft".
*/
mIsDraft = mMessage.draftType != UIProvider.DraftType.NOT_A_DRAFT;
mSendingState = mMessage.sendingState;
// If this was a sent message AND:
// 1. the account has a custom from, the cursor will populate the
// selected custom from as the fromAddress when a message is sent but
// not yet synced.
// 2. the account has no custom froms, fromAddress will be empty, and we
// can safely fall back and show the account name as sender since it's
// the only possible fromAddress.
String from = mMessage.getFrom();
if (TextUtils.isEmpty(from)) {
from = (account != null) ? account.getEmailAddress() : "";
}
mSender = getAddress(from);
updateChildVisibility();
final String snippet;
if (mIsDraft || mSendingState != UIProvider.ConversationSendingState.OTHER) {
snippet = makeSnippet(mMessage.snippet);
} else {
snippet = mMessage.snippet;
}
mSnippet = snippet == null ? null : getBidiFormatter().unicodeWrap(snippet);
mSenderNameView.setText(getHeaderTitle());
setRecipientSummary();
setDateText();
mSnippetView.setText(mSnippet);
setAddressOnContextMenu();
if (mUpperDateView != null) {
mUpperDateView.setText(mMessageHeaderItem.getTimestampShort());
}
if (measureOnly) {
// avoid leaving any state around that would interfere with future regular bind() calls
unbind();
} else {
updateContactInfo();
if (!mObservingContactInfo) {
mContactInfoSource.registerObserver(mContactInfoObserver);
mObservingContactInfo = true;
}
}
t.pause(HEADER_RENDER_TAG);
}
/**
* Update context menu's address field for when the user long presses
* on the message header and attempts to copy/send email.
*/
private void setAddressOnContextMenu() {
if (mSender != null) {
mEmailCopyMenu.setAddress(mSender.getAddress());
}
}
@Override
public boolean isBoundTo(ConversationOverlayItem item) {
return item == mMessageHeaderItem;
}
public Address getAddress(String emailStr) {
return Utils.getAddress(mAddressCache, emailStr);
}
private void updateSpacerHeight() {
final int h = measureHeight();
mMessageHeaderItem.setHeight(h);
if (mCallbacks != null) {
mCallbacks.setMessageSpacerHeight(mMessageHeaderItem, h);
}
}
private int measureHeight() {
ViewGroup parent = (ViewGroup) getParent();
if (parent == null) {
LogUtils.e(LOG_TAG, new Error(), "Unable to measure height of detached header");
return getHeight();
}
mPreMeasuring = true;
final int h = Utils.measureViewHeight(this, parent);
mPreMeasuring = false;
return h;
}
private CharSequence getHeaderTitle() {
CharSequence title;
switch (mSendingState) {
case UIProvider.ConversationSendingState.QUEUED:
case UIProvider.ConversationSendingState.SENDING:
case UIProvider.ConversationSendingState.RETRYING:
title = getResources().getString(R.string.sending);
break;
case UIProvider.ConversationSendingState.SEND_ERROR:
title = getResources().getString(R.string.message_failed);
break;
default:
if (mIsDraft) {
title = SendersView.getSingularDraftString(getContext());
} else {
title = getBidiFormatter().unicodeWrap(
getSenderName(mSender));
}
}
return title;
}
private void setRecipientSummary() {
if (!mRecipientSummaryValid) {
if (mMessageHeaderItem.recipientSummaryText == null) {
final Account account = getAccount();
final String meEmailAddress = (account != null) ? account.getEmailAddress() : "";
mMessageHeaderItem.recipientSummaryText = getRecipientSummaryText(getContext(),
meEmailAddress, mMyName, mTo, mCc, mBcc, mAddressCache, mVeiledMatcher,
getBidiFormatter());
}
mRecipientSummary.setText(mMessageHeaderItem.recipientSummaryText);
mRecipientSummaryValid = true;
}
}
private void setDateText() {
if (mIsSnappy) {
mDateView.setText(mMessageHeaderItem.getTimestampLong());
mDateView.setOnClickListener(null);
} else {
mDateView.setMovementMethod(LinkMovementMethod.getInstance());
mDateView.setText(Html.fromHtml(getResources().getString(
R.string.date_and_view_details, mMessageHeaderItem.getTimestampLong())));
StyleUtils.stripUnderlinesAndUrl(mDateView);
}
}
/**
* Return the name, if known, or just the address.
*/
private static String getSenderName(Address sender) {
if (sender == null) {
return "";
}
final String displayName = sender.getPersonal();
return TextUtils.isEmpty(displayName) ? sender.getAddress() : displayName;
}
private static void setChildVisibility(int visibility, View... children) {
for (View v : children) {
if (v != null) {
v.setVisibility(visibility);
}
}
}
private void setExpanded(final boolean expanded) {
// use View's 'activated' flag to store expanded state
// child view state lists can use this to toggle drawables
setActivated(expanded);
if (mMessageHeaderItem != null) {
mMessageHeaderItem.setExpanded(expanded);
}
}
/**
* Update the visibility of the many child views based on expanded/collapsed
* and draft/normal state.
*/
private void updateChildVisibility() {
// Too bad this can't be done with an XML state list...
if (mIsViewOnlyMode) {
setMessageDetailsVisibility(VISIBLE);
setChildVisibility(GONE, mSnapHeaderBottomBorder);
setChildVisibility(GONE, mReplyButton, mReplyAllButton, mForwardButton,
mOverflowButton, mDraftIcon, mEditDraftButton,
mAttachmentIcon, mUpperDateView, mSnippetView);
setChildVisibility(VISIBLE, mPhotoView, mRecipientSummary);
setChildMarginEnd(mTitleContainer, 0);
} else if (isExpanded()) {
int normalVis, draftVis;
final boolean isSnappy = isSnappy();
setMessageDetailsVisibility((isSnappy) ? GONE : VISIBLE);
setChildVisibility(isSnappy ? VISIBLE : GONE, mSnapHeaderBottomBorder);
if (mIsDraft) {
normalVis = GONE;
draftVis = VISIBLE;
} else {
normalVis = VISIBLE;
draftVis = GONE;
}
setReplyOrReplyAllVisible();
setChildVisibility(normalVis, mPhotoView, mForwardButton, mOverflowButton);
setChildVisibility(draftVis, mDraftIcon, mEditDraftButton);
setChildVisibility(VISIBLE, mRecipientSummary);
setChildVisibility(GONE, mAttachmentIcon, mUpperDateView, mSnippetView);
setChildMarginEnd(mTitleContainer, 0);
} else {
setMessageDetailsVisibility(GONE);
setChildVisibility(GONE, mSnapHeaderBottomBorder);
setChildVisibility(VISIBLE, mSnippetView, mUpperDateView);
setChildVisibility(GONE, mEditDraftButton, mReplyButton, mReplyAllButton,
mForwardButton, mOverflowButton, mRecipientSummary,
mDateView, mHideDetailsView);
setChildVisibility(mMessage.hasAttachments ? VISIBLE : GONE,
mAttachmentIcon);
if (mIsDraft) {
setChildVisibility(VISIBLE, mDraftIcon);
setChildVisibility(GONE, mPhotoView);
} else {
setChildVisibility(GONE, mDraftIcon);
setChildVisibility(VISIBLE, mPhotoView);
}
setChildMarginEnd(mTitleContainer, mTitleContainerMarginEnd);
}
final ConversationViewAdapter adapter = mMessageHeaderItem.getAdapter();
if (adapter != null) {
mBorderView.setVisibility(
adapter.isPreviousItemSuperCollapsed(mMessageHeaderItem) ? GONE : VISIBLE);
} else {
mBorderView.setVisibility(VISIBLE);
}
}
/**
* If an overflow menu is present in this header's layout, set the
* visibility of "Reply" and "Reply All" actions based on a user preference.
* Only one of those actions will be visible when an overflow is present. If
* no overflow is present (e.g. big phone or tablet), it's assumed we have
* plenty of screen real estate and can show both.
*/
private void setReplyOrReplyAllVisible() {
if (mIsDraft) {
setChildVisibility(GONE, mReplyButton, mReplyAllButton);
return;
} else if (mOverflowButton == null) {
setChildVisibility(VISIBLE, mReplyButton, mReplyAllButton);
return;
}
final Account account = getAccount();
final boolean defaultReplyAll = (account != null) ? account.settings.replyBehavior
== UIProvider.DefaultReplyBehavior.REPLY_ALL : false;
setChildVisibility(defaultReplyAll ? GONE : VISIBLE, mReplyButton);
setChildVisibility(defaultReplyAll ? VISIBLE : GONE, mReplyAllButton);
}
@SuppressLint("NewApi")
private static void setChildMarginEnd(View childView, int marginEnd) {
MarginLayoutParams mlp = (MarginLayoutParams) childView.getLayoutParams();
if (Utils.isRunningJBMR1OrLater()) {
mlp.setMarginEnd(marginEnd);
} else {
mlp.rightMargin = marginEnd;
}
childView.setLayoutParams(mlp);
}
@VisibleForTesting
static CharSequence getRecipientSummaryText(Context context, String meEmailAddress,
String myName, String[] to, String[] cc, String[] bcc,
Map<String, Address> addressCache, VeiledAddressMatcher matcher,
BidiFormatter bidiFormatter) {
final RecipientListsBuilder builder = new RecipientListsBuilder(
context, meEmailAddress, myName, addressCache, matcher, bidiFormatter);
builder.append(to);
builder.append(cc);
builder.appendBcc(bcc);
return builder.build();
}
/**
* Utility class to build a list of recipient lists.
*/
private static class RecipientListsBuilder {
private final Context mContext;
private final String mMeEmailAddress;
private final String mMyName;
private final StringBuilder mBuilder = new StringBuilder();
private final CharSequence mComma;
private final Map<String, Address> mAddressCache;
private final VeiledAddressMatcher mMatcher;
private final BidiFormatter mBidiFormatter;
int mRecipientCount = 0;
boolean mSkipComma = true;
public RecipientListsBuilder(Context context, String meEmailAddress, String myName,
Map<String, Address> addressCache, VeiledAddressMatcher matcher,
BidiFormatter bidiFormatter) {
mContext = context;
mMeEmailAddress = meEmailAddress;
mMyName = myName;
mComma = mContext.getText(R.string.enumeration_comma);
mAddressCache = addressCache;
mMatcher = matcher;
mBidiFormatter = bidiFormatter;
}
public void append(String[] recipients) {
final int addLimit = SUMMARY_MAX_RECIPIENTS - mRecipientCount;
final boolean hasRecipients = appendRecipients(recipients, addLimit);
if (hasRecipients) {
mRecipientCount += Math.min(addLimit, recipients.length);
}
}
public void appendBcc(String[] recipients) {
final int addLimit = SUMMARY_MAX_RECIPIENTS - mRecipientCount;
if (shouldAppendRecipients(recipients, addLimit)) {
// add the comma before the bcc header
// and then reset mSkipComma so we don't add a comma after "bcc: "
if (!mSkipComma) {
mBuilder.append(mComma);
mSkipComma = true;
}
mBuilder.append(mContext.getString(R.string.bcc_header_for_recipient_summary));
}
append(recipients);
}
/**
* Appends formatted recipients of the message to the recipient list,
* as long as there are recipients left to append and the maximum number
* of addresses limit has not been reached.
* @param rawAddrs The addresses to append.
* @param maxToCopy The maximum number of addresses to append.
* @return {@code true} if a recipient has been appended. {@code false}, otherwise.
*/
private boolean appendRecipients(String[] rawAddrs, int maxToCopy) {
if (!shouldAppendRecipients(rawAddrs, maxToCopy)) {
return false;
}
final int len = Math.min(maxToCopy, rawAddrs.length);
for (int i = 0; i < len; i++) {
final Address email = Utils.getAddress(mAddressCache, rawAddrs[i]);
final String emailAddress = email.getAddress();
final String name;
if (mMatcher != null && mMatcher.isVeiledAddress(emailAddress)) {
if (TextUtils.isEmpty(email.getPersonal())) {
// Let's write something more readable.
name = mContext.getString(VeiledAddressMatcher.VEILED_SUMMARY_UNKNOWN);
} else {
name = email.getSimplifiedName();
}
} else {
// Not a veiled address, show first part of email, or "me".
name = mMeEmailAddress.equals(emailAddress) ?
mMyName : email.getSimplifiedName();
}
// duplicate TextUtils.join() logic to minimize temporary allocations
if (mSkipComma) {
mSkipComma = false;
} else {
mBuilder.append(mComma);
}
mBuilder.append(mBidiFormatter.unicodeWrap(name));
}
return true;
}
/**
* @param rawAddrs The addresses to append.
* @param maxToCopy The maximum number of addresses to append.
* @return {@code true} if a recipient should be appended. {@code false}, otherwise.
*/
private boolean shouldAppendRecipients(String[] rawAddrs, int maxToCopy) {
return rawAddrs != null && rawAddrs.length != 0 && maxToCopy != 0;
}
public CharSequence build() {
return mContext.getString(R.string.to_message_header, mBuilder);
}
}
private void updateContactInfo() {
if (mContactInfoSource == null || mSender == null) {
mPhotoView.setImageToDefault();
mPhotoView.setContentDescription(getResources().getString(
R.string.contact_info_string_default));
return;
}
// Set the photo to either a found Bitmap or the default
// and ensure either the contact URI or email is set so the click
// handling works
String contentDesc = getResources().getString(R.string.contact_info_string,
!TextUtils.isEmpty(mSender.getPersonal())
? mSender.getPersonal()
: mSender.getAddress());
mPhotoView.setContentDescription(contentDesc);
boolean photoSet = false;
final String email = mSender.getAddress();
final ContactInfo info = mContactInfoSource.getContactInfo(email);
if (info != null) {
if (info.contactUri != null) {
mPhotoView.assignContactUri(info.contactUri);
} else {
mPhotoView.assignContactFromEmail(email, true /* lazyLookup */);
}
if (info.photo != null) {
mPhotoView.setImageBitmap(BitmapUtil.frameBitmapInCircle(info.photo));
photoSet = true;
}
} else {
mPhotoView.assignContactFromEmail(email, true /* lazyLookup */);
}
if (!photoSet) {
mPhotoView.setImageBitmap(
BitmapUtil.frameBitmapInCircle(makeLetterTile(mSender.getPersonal(), email)));
}
}
private Bitmap makeLetterTile(
String displayName, String senderAddress) {
if (mLetterTileProvider == null) {
mLetterTileProvider = new LetterTileProvider(getContext().getResources());
}
final ImageCanvas.Dimensions dimensions = new ImageCanvas.Dimensions(
mContactPhotoWidth, mContactPhotoHeight, ImageCanvas.Dimensions.SCALE_ONE);
return mLetterTileProvider.getLetterTile(dimensions, displayName, senderAddress);
}
@Override
public boolean onMenuItemClick(MenuItem item) {
mPopup.dismiss();
return onClick(null, item.getItemId());
}
@Override
public void onClick(View v) {
onClick(v, v.getId());
}
/**
* Handles clicks on either views or menu items. View parameter can be null
* for menu item clicks.
*/
public boolean onClick(final View v, final int id) {
if (mMessage == null) {
LogUtils.i(LOG_TAG, "ignoring message header tap on unbound view");
return false;
}
boolean handled = true;
if (id == R.id.reply) {
ComposeActivity.reply(getContext(), getAccount(), mMessage);
} else if (id == R.id.reply_all) {
ComposeActivity.replyAll(getContext(), getAccount(), mMessage);
} else if (id == R.id.forward) {
ComposeActivity.forward(getContext(), getAccount(), mMessage);
} else if (id == R.id.add_star) {
mMessage.star(true);
} else if (id == R.id.remove_star) {
mMessage.star(false);
} else if (id == R.id.print_message) {
printMessage();
} else if (id == R.id.report_rendering_problem) {
final String text = getContext().getString(R.string.report_rendering_problem_desc);
ComposeActivity.reportRenderingFeedback(getContext(), getAccount(), mMessage,
text + "\n\n" + mCallbacks.getMessageTransforms(mMessage));
} else if (id == R.id.report_rendering_improvement) {
final String text = getContext().getString(R.string.report_rendering_improvement_desc);
ComposeActivity.reportRenderingFeedback(getContext(), getAccount(), mMessage,
text + "\n\n" + mCallbacks.getMessageTransforms(mMessage));
} else if (id == R.id.edit_draft) {
ComposeActivity.editDraft(getContext(), getAccount(), mMessage);
} else if (id == R.id.overflow) {
if (mPopup == null) {
mPopup = new PopupMenu(getContext(), v);
mPopup.getMenuInflater().inflate(R.menu.message_header_overflow_menu,
mPopup.getMenu());
mPopup.setOnMenuItemClickListener(this);
}
final boolean defaultReplyAll = getAccount().settings.replyBehavior
== UIProvider.DefaultReplyBehavior.REPLY_ALL;
final Menu m = mPopup.getMenu();
m.findItem(R.id.reply).setVisible(defaultReplyAll);
m.findItem(R.id.reply_all).setVisible(!defaultReplyAll);
m.findItem(R.id.print_message).setVisible(Utils.isRunningKitkatOrLater());
final boolean isStarred = mMessage.starred;
boolean showStar = true;
final Conversation conversation = mMessage.getConversation();
if (conversation != null) {
showStar = !conversation.isInTrash();
}
m.findItem(R.id.add_star).setVisible(showStar && !isStarred);
m.findItem(R.id.remove_star).setVisible(showStar && isStarred);
final boolean reportRendering = ENABLE_REPORT_RENDERING_PROBLEM
&& mCallbacks.supportsMessageTransforms();
m.findItem(R.id.report_rendering_improvement).setVisible(reportRendering);
m.findItem(R.id.report_rendering_problem).setVisible(reportRendering);
mPopup.show();
} else if (id == R.id.send_date || id == R.id.hide_details ||
id == R.id.details_expanded_content) {
toggleMessageDetails();
} else if (id == R.id.upper_header) {
toggleExpanded();
} else if (id == R.id.show_pictures_text) {
handleShowImagePromptClick(v);
} else {
LogUtils.i(LOG_TAG, "unrecognized header tap: %d", id);
handled = false;
}
if (handled && id != R.id.overflow) {
Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id,
"message_header", 0);
}
return handled;
}
private void printMessage() {
// Secure conversation view does not use a conversation view adapter
// so it's safe to test for existence as a signal to use javascript or not.
final boolean useJavascript = mMessageHeaderItem.getAdapter() != null;
final Account account = getAccount();
final Conversation conversation = mMessage.getConversation();
final String baseUri =
AbstractConversationViewFragment.buildBaseUri(getContext(), account, conversation);
PrintUtils.printMessage(getContext(), mMessage, conversation.subject,
mAddressCache, conversation.getBaseUri(baseUri), useJavascript);
}
/**
* Set to true if the user should not be able to perform message actions
* on the message such as reply/reply all/forward/star/etc.
*
* Default is false.
*/
public void setViewOnlyMode(boolean isViewOnlyMode) {
mIsViewOnlyMode = isViewOnlyMode;
}
public void setExpandable(boolean expandable) {
mExpandable = expandable;
}
public void toggleExpanded() {
if (!mExpandable) {
return;
}
setExpanded(!isExpanded());
// The snappy header will disappear; no reason to update text.
if (!isSnappy()) {
mSenderNameView.setText(getHeaderTitle());
setRecipientSummary();
setDateText();
mSnippetView.setText(mSnippet);
}
updateChildVisibility();
// Force-measure the new header height so we can set the spacer size and
// reveal the message div in one pass. Force-measuring makes it unnecessary to set
// mSizeChanged.
int h = measureHeight();
mMessageHeaderItem.setHeight(h);
if (mCallbacks != null) {
mCallbacks.setMessageExpanded(mMessageHeaderItem, h);
}
}
private static boolean isValidPosition(int position, int size) {
return position >= 0 && position < size;
}
@Override
public void setSnappy() {
mIsSnappy = true;
hideMessageDetails();
}
private boolean isSnappy() {
return mIsSnappy;
}
private void toggleMessageDetails() {
int heightBefore = measureHeight();
final boolean expand =
(mExpandedDetailsView == null || mExpandedDetailsView.getVisibility() == GONE);
setMessageDetailsExpanded(expand);
updateSpacerHeight();
if (mCallbacks != null) {
mCallbacks.setMessageDetailsExpanded(mMessageHeaderItem, expand, heightBefore);
}
}
private void setMessageDetailsExpanded(boolean expand) {
if (expand) {
showExpandedDetails();
} else {
hideExpandedDetails();
}
if (mMessageHeaderItem != null) {
mMessageHeaderItem.detailsExpanded = expand;
}
}
public void setMessageDetailsVisibility(int vis) {
if (vis == GONE) {
hideExpandedDetails();
hideSpamWarning();
hideShowImagePrompt();
hideInvite();
mUpperHeaderView.setOnCreateContextMenuListener(null);
} else {
setMessageDetailsExpanded(mMessageHeaderItem.detailsExpanded);
if (mMessage.spamWarningString == null) {
hideSpamWarning();
} else {
showSpamWarning();
}
if (mShowImagePrompt) {
if (mMessageHeaderItem.getShowImages()) {
showImagePromptAlways(true);
} else {
showImagePromptOnce();
}
} else {
hideShowImagePrompt();
}
if (mMessage.isFlaggedCalendarInvite()) {
showInvite();
} else {
hideInvite();
}
mUpperHeaderView.setOnCreateContextMenuListener(mEmailCopyMenu);
}
}
private void hideMessageDetails() {
setMessageDetailsVisibility(GONE);
}
private void hideExpandedDetails() {
if (mExpandedDetailsView != null) {
mExpandedDetailsView.setVisibility(GONE);
}
mDateView.setVisibility(VISIBLE);
mHideDetailsView.setVisibility(GONE);
}
private void hideInvite() {
if (mInviteView != null) {
mInviteView.setVisibility(GONE);
}
}
private void showInvite() {
if (mInviteView == null) {
mInviteView = (MessageInviteView) mInflater.inflate(
R.layout.conversation_message_invite, this, false);
mExtraContentView.addView(mInviteView);
}
mInviteView.bind(mMessage);
mInviteView.setVisibility(VISIBLE);
}
private void hideShowImagePrompt() {
if (mImagePromptView != null) {
mImagePromptView.setVisibility(GONE);
}
}
private void showImagePromptOnce() {
if (mImagePromptView == null) {
mImagePromptView = (TextView) mInflater.inflate(
R.layout.conversation_message_show_pics, this, false);
mExtraContentView.addView(mImagePromptView);
mImagePromptView.setOnClickListener(this);
}
mImagePromptView.setVisibility(VISIBLE);
mImagePromptView.setText(R.string.show_images);
mImagePromptView.setTag(SHOW_IMAGE_PROMPT_ONCE);
}
/**
* Shows the "Always show pictures" message
*
* @param initialShowing <code>true</code> if this is the first time we are showing the prompt
* for "show images", <code>false</code> if we are transitioning from "Show pictures"
*/
private void showImagePromptAlways(final boolean initialShowing) {
if (initialShowing) {
// Initialize the view
showImagePromptOnce();
}
mImagePromptView.setText(R.string.always_show_images);
mImagePromptView.setTag(SHOW_IMAGE_PROMPT_ALWAYS);
if (!initialShowing) {
// the new text's line count may differ, so update the spacer height
updateSpacerHeight();
}
}
private void hideSpamWarning() {
if (mSpamWarningView != null) {
mSpamWarningView.setVisibility(GONE);
}
}
private void showSpamWarning() {
if (mSpamWarningView == null) {
mSpamWarningView = (SpamWarningView)
mInflater.inflate(R.layout.conversation_message_spam_warning, this, false);
mExtraContentView.addView(mSpamWarningView);
}
mSpamWarningView.showSpamWarning(mMessage, mSender);
}
private void handleShowImagePromptClick(View v) {
Integer state = (Integer) v.getTag();
if (state == null) {
return;
}
switch (state) {
case SHOW_IMAGE_PROMPT_ONCE:
if (mCallbacks != null) {
mCallbacks.showExternalResources(mMessage);
}
if (mMessageHeaderItem != null) {
mMessageHeaderItem.setShowImages(true);
}
if (mIsViewOnlyMode) {
hideShowImagePrompt();
} else {
showImagePromptAlways(false);
}
break;
case SHOW_IMAGE_PROMPT_ALWAYS:
mMessage.markAlwaysShowImages(getQueryHandler(), 0 /* token */, null /* cookie */);
if (mCallbacks != null) {
mCallbacks.showExternalResources(mMessage.getFrom());
}
mShowImagePrompt = false;
v.setTag(null);
v.setVisibility(GONE);
updateSpacerHeight();
Toast.makeText(getContext(), R.string.always_show_images_toast, Toast.LENGTH_SHORT)
.show();
break;
}
}
private AsyncQueryHandler getQueryHandler() {
if (mQueryHandler == null) {
mQueryHandler = new AsyncQueryHandler(getContext().getContentResolver()) {};
}
return mQueryHandler;
}
/**
* Makes expanded details visible. If necessary, will inflate expanded
* details layout and render using saved-off state (senders, timestamp,
* etc).
*/
private void showExpandedDetails() {
// lazily create expanded details view
final boolean expandedViewCreated = ensureExpandedDetailsView();
if (expandedViewCreated) {
mExtraContentView.addView(mExpandedDetailsView, 0);
}
mExpandedDetailsView.setVisibility(VISIBLE);
mDateView.setVisibility(GONE);
mHideDetailsView.setVisibility(VISIBLE);
}
private boolean ensureExpandedDetailsView() {
boolean viewCreated = false;
if (mExpandedDetailsView == null) {
View v = inflateExpandedDetails(mInflater);
v.setOnClickListener(this);
mExpandedDetailsView = (ViewGroup) v;
viewCreated = true;
}
if (!mExpandedDetailsValid) {
renderExpandedDetails(getResources(), mExpandedDetailsView, mMessage.viaDomain,
mAddressCache, getAccount(), mVeiledMatcher, mFrom, mReplyTo, mTo, mCc, mBcc,
mMessageHeaderItem.getTimestampFull(),
getBidiFormatter());
mExpandedDetailsValid = true;
}
return viewCreated;
}
public static View inflateExpandedDetails(LayoutInflater inflater) {
return inflater.inflate(R.layout.conversation_message_header_details, null, false);
}
public static void renderExpandedDetails(Resources res, View detailsView,
String viaDomain, Map<String, Address> addressCache, Account account,
VeiledAddressMatcher veiledMatcher, String[] from, String[] replyTo,
String[] to, String[] cc, String[] bcc, CharSequence receivedTimestamp,
BidiFormatter bidiFormatter) {
renderEmailList(res, R.id.from_heading, R.id.from_details, from, viaDomain,
detailsView, addressCache, account, veiledMatcher, bidiFormatter);
renderEmailList(res, R.id.replyto_heading, R.id.replyto_details, replyTo, viaDomain,
detailsView, addressCache, account, veiledMatcher, bidiFormatter);
renderEmailList(res, R.id.to_heading, R.id.to_details, to, viaDomain,
detailsView, addressCache, account, veiledMatcher, bidiFormatter);
renderEmailList(res, R.id.cc_heading, R.id.cc_details, cc, viaDomain,
detailsView, addressCache, account, veiledMatcher, bidiFormatter);
renderEmailList(res, R.id.bcc_heading, R.id.bcc_details, bcc, viaDomain,
detailsView, addressCache, account, veiledMatcher, bidiFormatter);
// Render date
detailsView.findViewById(R.id.date_heading).setVisibility(VISIBLE);
final TextView date = (TextView) detailsView.findViewById(R.id.date_details);
date.setText(receivedTimestamp);
date.setVisibility(VISIBLE);
}
/**
* Render an email list for the expanded message details view.
*/
private static void renderEmailList(Resources res, int headerId, int detailsId,
String[] emails, String viaDomain, View rootView,
Map<String, Address> addressCache, Account account,
VeiledAddressMatcher veiledMatcher, BidiFormatter bidiFormatter) {
if (emails == null || emails.length == 0) {
return;
}
final String[] formattedEmails = new String[emails.length];
for (int i = 0; i < emails.length; i++) {
final Address email = Utils.getAddress(addressCache, emails[i]);
String name = email.getPersonal();
final String address = email.getAddress();
// Check if the address here is a veiled address. If it is, we need to display an
// alternate layout
final boolean isVeiledAddress = veiledMatcher != null &&
veiledMatcher.isVeiledAddress(address);
final String addressShown;
if (isVeiledAddress) {
// Add the warning at the end of the name, and remove the address. The alternate
// text cannot be put in the address part, because the address is made into a link,
// and the alternate human-readable text is not a link.
addressShown = "";
if (TextUtils.isEmpty(name)) {
// Empty name and we will block out the address. Let's write something more
// readable.
name = res.getString(VeiledAddressMatcher.VEILED_ALTERNATE_TEXT_UNKNOWN_PERSON);
} else {
name = name + res.getString(VeiledAddressMatcher.VEILED_ALTERNATE_TEXT);
}
} else {
addressShown = address;
}
if (name == null || name.length() == 0 || name.equalsIgnoreCase(addressShown)) {
formattedEmails[i] = bidiFormatter.unicodeWrap(addressShown);
} else {
// The one downside to having the showViaDomain here is that
// if the sender does not have a name, it will not show the via info
if (viaDomain != null) {
formattedEmails[i] = res.getString(
R.string.address_display_format_with_via_domain,
bidiFormatter.unicodeWrap(name),
bidiFormatter.unicodeWrap(addressShown),
bidiFormatter.unicodeWrap(viaDomain));
} else {
formattedEmails[i] = res.getString(R.string.address_display_format,
bidiFormatter.unicodeWrap(name),
bidiFormatter.unicodeWrap(addressShown));
}
}
}
rootView.findViewById(headerId).setVisibility(VISIBLE);
final TextView detailsText = (TextView) rootView.findViewById(detailsId);
detailsText.setText(TextUtils.join("\n", formattedEmails));
stripUnderlines(detailsText, account);
detailsText.setVisibility(VISIBLE);
}
private static void stripUnderlines(TextView textView, Account account) {
final Spannable spannable = (Spannable) textView.getText();
final URLSpan[] urls = textView.getUrls();
for (URLSpan span : urls) {
final int start = spannable.getSpanStart(span);
final int end = spannable.getSpanEnd(span);
spannable.removeSpan(span);
span = new EmailAddressSpan(account, span.getURL().substring(7));
spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
/**
* Returns a short plaintext snippet generated from the given HTML message
* body. Collapses whitespace, ignores '&lt;' and '&gt;' characters and
* everything in between, and truncates the snippet to no more than 100
* characters.
*
* @return Short plaintext snippet
*/
@VisibleForTesting
static String makeSnippet(final String messageBody) {
if (TextUtils.isEmpty(messageBody)) {
return null;
}
final StringBuilder snippet = new StringBuilder(MAX_SNIPPET_LENGTH);
final StringReader reader = new StringReader(messageBody);
try {
int c;
while ((c = reader.read()) != -1 && snippet.length() < MAX_SNIPPET_LENGTH) {
// Collapse whitespace.
if (Character.isWhitespace(c)) {
snippet.append(' ');
do {
c = reader.read();
} while (Character.isWhitespace(c));
if (c == -1) {
break;
}
}
if (c == '<') {
// Ignore everything up to and including the next '>'
// character.
while ((c = reader.read()) != -1) {
if (c == '>') {
break;
}
}
// If we reached the end of the message body, exit.
if (c == -1) {
break;
}
} else if (c == '&') {
// Read HTML entity.
StringBuilder sb = new StringBuilder();
while ((c = reader.read()) != -1) {
if (c == ';') {
break;
}
sb.append((char) c);
}
String entity = sb.toString();
if ("nbsp".equals(entity)) {
snippet.append(' ');
} else if ("lt".equals(entity)) {
snippet.append('<');
} else if ("gt".equals(entity)) {
snippet.append('>');
} else if ("amp".equals(entity)) {
snippet.append('&');
} else if ("quot".equals(entity)) {
snippet.append('"');
} else if ("apos".equals(entity) || "#39".equals(entity)) {
snippet.append('\'');
} else {
// Unknown entity; just append the literal string.
snippet.append('&').append(entity);
if (c == ';') {
snippet.append(';');
}
}
// If we reached the end of the message body, exit.
if (c == -1) {
break;
}
} else {
// The current character is a non-whitespace character that
// isn't inside some
// HTML tag and is not part of an HTML entity.
snippet.append((char) c);
}
}
} catch (IOException e) {
LogUtils.wtf(LOG_TAG, e, "Really? IOException while reading a freaking string?!? ");
}
return snippet.toString();
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
Timer perf = new Timer();
perf.start(LAYOUT_TAG);
super.onLayout(changed, l, t, r, b);
perf.pause(LAYOUT_TAG);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
Timer t = new Timer();
if (Timer.ENABLE_TIMER && !mPreMeasuring) {
t.count("header measure id=" + mMessage.id);
t.start(MEASURE_TAG);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (!mPreMeasuring) {
t.pause(MEASURE_TAG);
}
}
}