blob: c4e13410764f4307c28d06d61e45dc78ad3b2f3c [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.app.FragmentManager;
import android.content.AsyncQueryHandler;
import android.content.Context;
import android.content.res.Resources;
import android.database.DataSetObserver;
import android.graphics.Bitmap;
import android.graphics.Typeface;
import android.os.Build;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.StyleSpan;
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.ImageView;
import android.widget.PopupMenu;
import android.widget.PopupMenu.OnMenuItemClickListener;
import android.widget.QuickContactBadge;
import android.widget.TextView;
import android.widget.Toast;
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.BorderItem;
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.Address;
import com.android.mail.providers.Conversation;
import com.android.mail.providers.Folder;
import com.android.mail.providers.Message;
import com.android.mail.providers.Settings;
import com.android.mail.providers.UIProvider;
import com.android.mail.ui.AbstractConversationViewFragment;
import com.android.mail.ui.ImageCanvas;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
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_INFLATE_TAG = "message header inflate";
private static final String HEADER_ADDVIEW_TAG = "message header addView";
private static final String HEADER_RENDER_TAG = "message header render";
private static final String PREMEASURE_TAG = "message header pre-measure";
private static final String LAYOUT_TAG = "message header layout";
private static final String MEASURE_TAG = "message header measure";
private static final String RECIPIENT_HEADING_DELIMITER = " ";
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 ViewGroup mUpperHeaderView;
private View mSnapHeaderBottomBorder;
private TextView mSenderNameView;
private TextView mSenderEmailView;
private TextView mDateView;
private TextView mSnippetView;
private QuickContactBadge mPhotoView;
private ImageView mStarView;
private ViewGroup mTitleContainerView;
private ViewGroup mExtraContentView;
private ViewGroup mCollapsedDetailsView;
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 boolean mIsSending;
private String mSnippet;
private Address mSender;
private ContactInfoSource mContactInfoSource;
private boolean mPreMeasuring;
private ConversationAccountController mAccountController;
private Map<String, Address> mAddressCache;
private boolean mShowImagePrompt;
/**
* Take the initial visibility of the star view to mean its collapsed
* visibility. Star is always visible when expanded, but sometimes, like on
* phones, there isn't enough room to warrant showing star when collapsed.
*/
private boolean mCollapsedStarVisible;
private boolean mStarShown;
/**
* End margin of the text when collapsed. When expanded, the margin is 0.
*/
private int mTitleContainerCollapsedMarginEnd;
private PopupMenu mPopup;
private MessageHeaderItem mMessageHeaderItem;
private ConversationMessage mMessage;
private boolean mCollapsedDetailsValid;
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;
/**
* The snappy header has special visibility rules (i.e. no details header,
* even though it has an expanded appearance)
*/
private boolean mIsSnappy;
public interface MessageHeaderViewCallbacks {
void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeight);
void setMessageExpanded(MessageHeaderItem item, int newSpacerHeight,
int topBorderHeight, int bottomBorderHeight);
void setMessageDetailsExpanded(MessageHeaderItem messageHeaderItem, boolean expanded,
int previousMessageHeaderItemHeight);
void showExternalResources(Message msg);
void showExternalResources(String senderRawAddress);
boolean supportsMessageTransforms();
String getMessageTransforms(Message msg);
FragmentManager getFragmentManager();
}
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_pronun);
final Resources resources = getResources();
mContactPhotoWidth = resources.getDimensionPixelSize(
R.dimen.message_header_contact_photo_width);
mContactPhotoHeight = resources.getDimensionPixelSize(
R.dimen.message_header_contact_photo_height);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mUpperHeaderView = (ViewGroup) findViewById(R.id.upper_header);
mSnapHeaderBottomBorder = findViewById(R.id.snap_header_bottom_border);
mSenderNameView = (TextView) findViewById(R.id.sender_name);
mSenderEmailView = (TextView) findViewById(R.id.sender_email);
mDateView = (TextView) findViewById(R.id.send_date);
mSnippetView = (TextView) findViewById(R.id.email_snippet);
mPhotoView = (QuickContactBadge) findViewById(R.id.photo);
mReplyButton = findViewById(R.id.reply);
mReplyAllButton = findViewById(R.id.reply_all);
mForwardButton = findViewById(R.id.forward);
mStarView = (ImageView) findViewById(R.id.star);
mTitleContainerView = (ViewGroup) findViewById(R.id.title_container);
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);
mCollapsedStarVisible = mStarView.getVisibility() == VISIBLE;
final Resources resources = getResources();
mTitleContainerCollapsedMarginEnd = resources.getDimensionPixelSize(
R.dimen.message_header_title_container_margin_end_collapsed);
setExpanded(true);
registerMessageClickTargets(R.id.reply, R.id.reply_all, R.id.forward, R.id.star,
R.id.edit_draft, R.id.overflow, R.id.upper_header);
mUpperHeaderView.setOnCreateContextMenuListener(mEmailCopyMenu);
}
private void registerMessageClickTargets(int... ids) {
for (int id : ids) {
View v = findViewById(id);
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 void render(boolean measureOnly) {
if (mMessageHeaderItem == null) {
return;
}
Timer t = new Timer();
t.start(HEADER_RENDER_TAG);
mCollapsedDetailsValid = false;
mExpandedDetailsValid = false;
mMessage = mMessageHeaderItem.getMessage();
final boolean alwaysShowImages = (getAccount() != null) &&
(getAccount().settings.showImages == Settings.ShowImages.ALWAYS);
mShowImagePrompt = mMessage.shouldShowImagePrompt() && !alwaysShowImages;
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;
mIsSending = mMessage.isSending;
// 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 = getAccount().name;
}
mSender = getAddress(from);
mStarView.setSelected(mMessage.starred);
mStarView.setContentDescription(getResources().getString(
mStarView.isSelected() ? R.string.remove_star : R.string.add_star));
mStarShown = true;
final Conversation conversation = mMessage.getConversation();
if (conversation != null) {
for (Folder folder : conversation.getRawFolders()) {
if (folder.isTrash()) {
mStarShown = false;
break;
}
}
}
updateChildVisibility();
if (mIsDraft || mIsSending) {
mSnippet = makeSnippet(mMessage.snippet);
} else {
mSnippet = mMessage.snippet;
}
mSenderNameView.setText(getHeaderTitle());
mSenderEmailView.setText(getHeaderSubtitle());
mDateView.setText(mMessageHeaderItem.getTimestampLong());
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() {
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;
if (mIsDraft) {
title = getResources().getQuantityText(R.plurals.draft, 1);
} else if (mIsSending) {
title = getResources().getString(R.string.sending);
} else {
title = getSenderName(mSender);
}
return title;
}
private CharSequence getHeaderSubtitle() {
CharSequence sub;
if (mIsSending) {
sub = null;
} else {
if (isExpanded()) {
if (mMessage.viaDomain != null) {
sub = getResources().getString(
R.string.via_domain, mMessage.viaDomain);
} else {
sub = getSenderAddress(mSender);
}
} else {
sub = mSnippet;
}
}
return sub;
}
/**
* Return the name, if known, or just the address.
*/
private static CharSequence getSenderName(Address sender) {
final String displayName = sender.getName();
return TextUtils.isEmpty(displayName) ? sender.getAddress() : displayName;
}
/**
* Return the address, if a name is present, or null if not.
*/
private static CharSequence getSenderAddress(Address sender) {
return sender.getAddress();
}
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, mStarView,
mAttachmentIcon, mUpperDateView, mSnippetView);
setChildVisibility(VISIBLE, mPhotoView, mSenderEmailView, mDateView);
setChildMarginEnd(mTitleContainerView, 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, mSenderEmailView, mDateView);
setChildVisibility(GONE, mAttachmentIcon, mUpperDateView, mSnippetView);
setChildVisibility(mStarShown ? VISIBLE : GONE, mStarView);
setChildMarginEnd(mTitleContainerView, 0);
} else {
setMessageDetailsVisibility(GONE);
setChildVisibility(GONE, mSnapHeaderBottomBorder);
setChildVisibility(VISIBLE, mSnippetView, mUpperDateView);
setChildVisibility(GONE, mEditDraftButton, mReplyButton, mReplyAllButton,
mForwardButton, mOverflowButton, mSenderEmailView, mDateView);
setChildVisibility(mMessage.hasAttachments ? VISIBLE : GONE,
mAttachmentIcon);
setChildVisibility(mCollapsedStarVisible && mStarShown ? VISIBLE : GONE, mStarView);
setChildMarginEnd(mTitleContainerView, mTitleContainerCollapsedMarginEnd);
if (mIsDraft) {
setChildVisibility(VISIBLE, mDraftIcon);
setChildVisibility(GONE, mPhotoView);
} else {
setChildVisibility(GONE, mDraftIcon);
setChildVisibility(VISIBLE, mPhotoView);
}
}
}
/**
* 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);
}
private static void setChildMarginEnd(View childView, int marginEnd) {
MarginLayoutParams mlp = (MarginLayoutParams) childView.getLayoutParams();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
mlp.setMarginEnd(marginEnd);
} else {
mlp.rightMargin = marginEnd;
}
childView.setLayoutParams(mlp);
}
/**
* Utility class to build a list of recipient lists.
*/
private static class RecipientListsBuilder {
private final Context mContext;
private final String mMe;
private final String mMyName;
private final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
private final CharSequence mComma;
private final Map<String, Address> mAddressCache;
private final VeiledAddressMatcher mMatcher;
int mRecipientCount = 0;
boolean mFirst = true;
public RecipientListsBuilder(Context context, String me, String myName,
Map<String, Address> addressCache, VeiledAddressMatcher matcher) {
mContext = context;
mMe = me;
mMyName = myName;
mComma = mContext.getText(R.string.enumeration_comma);
mAddressCache = addressCache;
mMatcher = matcher;
}
public void append(String[] recipients, int headingRes) {
int addLimit = SUMMARY_MAX_RECIPIENTS - mRecipientCount;
CharSequence recipientList = getSummaryTextForHeading(headingRes, recipients, addLimit);
if (recipientList != null) {
// duplicate TextUtils.join() logic to minimize temporary
// allocations, and because we need to support spans
if (mFirst) {
mFirst = false;
} else {
mBuilder.append(RECIPIENT_HEADING_DELIMITER);
}
mBuilder.append(recipientList);
mRecipientCount += Math.min(addLimit, recipients.length);
}
}
private CharSequence getSummaryTextForHeading(int headingStrRes, String[] rawAddrs,
int maxToCopy) {
if (rawAddrs == null || rawAddrs.length == 0 || maxToCopy == 0) {
return null;
}
SpannableStringBuilder ssb = new SpannableStringBuilder(
mContext.getString(headingStrRes));
ssb.setSpan(new StyleSpan(Typeface.NORMAL), 0, ssb.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
final int len = Math.min(maxToCopy, rawAddrs.length);
boolean first = true;
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.getName())) {
// 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 = mMe.equals(emailAddress) ? mMyName : email.getSimplifiedName();
}
// duplicate TextUtils.join() logic to minimize temporary
// allocations, and because we need to support spans
if (first) {
first = false;
} else {
ssb.append(mComma);
}
ssb.append(name);
}
return ssb;
}
public CharSequence build() {
return mBuilder;
}
}
@VisibleForTesting
static CharSequence getRecipientSummaryText(Context context, String me, String myName,
String[] to, String[] cc, String[] bcc, Map<String, Address> addressCache,
VeiledAddressMatcher matcher) {
final RecipientListsBuilder builder =
new RecipientListsBuilder(context, me, myName, addressCache, matcher);
builder.append(to, R.string.to_heading);
builder.append(cc, R.string.cc_heading);
builder.append(bcc, R.string.bcc_heading);
return builder.build();
}
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.getName()) ? mSender.getName() : mSender.getAddress());
mPhotoView.setContentDescription(contentDesc);
boolean photoSet = false;
final String email = mSender.getAddress();
final ContactInfo info = mContactInfoSource.getContactInfo(email);
if (info != null) {
mPhotoView.assignContactUri(info.contactUri);
if (info.photo != null) {
mPhotoView.setImageBitmap(info.photo);
photoSet = true;
}
} else {
mPhotoView.assignContactFromEmail(email, true /* lazyLookup */);
}
if (!photoSet) {
mPhotoView.setImageBitmap(makeLetterTile(mSender.getName(), email));
}
}
private Bitmap makeLetterTile(
String displayName, String senderAddress) {
if (mLetterTileProvider == null) {
mLetterTileProvider = new LetterTileProvider(getContext());
}
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.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.star) {
final boolean newValue = !v.isSelected();
v.setSelected(newValue);
mMessage.star(newValue);
} 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 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.details_collapsed_content
|| id == R.id.details_expanded_content) {
toggleMessageDetails(v);
} 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 = mAccountController.getAccount();
final Conversation conversation = mMessage.getConversation();
final String baseUri =
AbstractConversationViewFragment.buildBaseUri(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());
mSenderEmailView.setText(getHeaderSubtitle());
mDateView.setText(mMessageHeaderItem.getTimestampLong());
mSnippetView.setText(mSnippet);
}
updateChildVisibility();
final BorderHeights borderHeights = updateBorderExpandedState();
// 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,
borderHeights.topHeight, borderHeights.bottomHeight);
}
}
/**
* Checks the neighboring messages to this message and
* updates the {@link BorderItem}s of the borders of this message
* in case they should be collapsed or expanded.
* @return a {@link BorderHeights} object containing
* the new heights of the top and bottom borders.
*/
private BorderHeights updateBorderExpandedState() {
final int position = mMessageHeaderItem.getPosition();
final boolean isExpanded = mMessageHeaderItem.isExpanded();
final int abovePosition = position - 2; // position of MessageFooterItem above header
final int belowPosition = position + 3; // position of next MessageHeaderItem
final ConversationViewAdapter adapter = mMessageHeaderItem.getAdapter();
final int size = adapter.getCount();
final BorderHeights borderHeights = new BorderHeights();
// if an above message exists, update the border above this message
if (isValidPosition(abovePosition, size)) {
final ConversationOverlayItem item = adapter.getItem(abovePosition);
final int type = item.getType();
if (type == ConversationViewAdapter.VIEW_TYPE_MESSAGE_FOOTER ||
type == ConversationViewAdapter.VIEW_TYPE_SUPER_COLLAPSED_BLOCK) {
final BorderItem borderItem = (BorderItem) adapter.getItem(abovePosition + 1);
final boolean borderIsExpanded = isExpanded || item.isExpanded();
borderItem.setExpanded(borderIsExpanded);
borderHeights.topHeight = borderIsExpanded ?
BorderView.getExpandedHeight() : BorderView.getCollapsedHeight();
borderItem.setHeight(borderHeights.topHeight);
}
}
// if a below message exists, update the border below this message
if (isValidPosition(belowPosition, size)) {
final ConversationOverlayItem item = adapter.getItem(belowPosition);
if (item.getType() == ConversationViewAdapter.VIEW_TYPE_MESSAGE_HEADER) {
final BorderItem borderItem = (BorderItem) adapter.getItem(belowPosition - 1);
final boolean borderIsExpanded = isExpanded || item.isExpanded();
borderItem.setExpanded(borderIsExpanded);
borderHeights.bottomHeight = borderIsExpanded ?
BorderView.getExpandedHeight() : BorderView.getCollapsedHeight();
borderItem.setHeight(borderHeights.bottomHeight);
}
}
return borderHeights;
}
/**
* A plain-old-data class used to return the new heights of the top and bottom borders
* in {@link #updateBorderExpandedState()}.
* If {@link #topHeight} or {@link #bottomHeight} are -1 after returning,
* do not update the heights of the spacer for their respective borders
* as their state has not changed.
*/
private class BorderHeights {
public int topHeight = -1;
public int bottomHeight = -1;
}
private 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(View visibleDetailsView) {
int heightBefore = measureHeight();
final boolean detailsExpanded = (visibleDetailsView == mCollapsedDetailsView);
setMessageDetailsExpanded(detailsExpanded);
updateSpacerHeight();
if (mCallbacks != null) {
mCallbacks.setMessageDetailsExpanded(mMessageHeaderItem, detailsExpanded, heightBefore);
}
}
private void setMessageDetailsExpanded(boolean expand) {
if (expand) {
showExpandedDetails();
hideCollapsedDetails();
} else {
hideExpandedDetails();
showCollapsedDetails();
}
if (mMessageHeaderItem != null) {
mMessageHeaderItem.detailsExpanded = expand;
}
}
public void setMessageDetailsVisibility(int vis) {
if (vis == GONE) {
hideCollapsedDetails();
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 hideCollapsedDetails() {
if (mCollapsedDetailsView != null) {
mCollapsedDetailsView.setVisibility(GONE);
}
}
private void hideExpandedDetails() {
if (mExpandedDetailsView != null) {
mExpandedDetailsView.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 collapsed details visible. If necessary, will inflate details
* layout and render using saved-off state (senders, timestamp, etc).
*/
private void showCollapsedDetails() {
if (mCollapsedDetailsView == null) {
mCollapsedDetailsView = (ViewGroup) mInflater.inflate(
R.layout.conversation_message_details_header, this, false);
mExtraContentView.addView(mCollapsedDetailsView, 0);
mCollapsedDetailsView.setOnClickListener(this);
}
if (!mCollapsedDetailsValid) {
if (mMessageHeaderItem.recipientSummaryText == null) {
final Account account = getAccount();
final String name = (account != null) ? account.name : "";
mMessageHeaderItem.recipientSummaryText = getRecipientSummaryText(getContext(),
name, mMyName, mTo, mCc, mBcc, mAddressCache, mVeiledMatcher);
}
((TextView) findViewById(R.id.recipients_summary))
.setText(mMessageHeaderItem.recipientSummaryText);
mCollapsedDetailsValid = true;
}
mCollapsedDetailsView.setVisibility(VISIBLE);
}
/**
* 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);
}
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.getTimestampLong());
mExpandedDetailsValid = true;
}
return viewCreated;
}
public static View inflateExpandedDetails(LayoutInflater inflater) {
return inflater.inflate(R.layout.conversation_message_details_header_expanded, 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) {
renderEmailList(res, R.id.from_heading, R.id.from_details, from, viaDomain,
detailsView, addressCache, account, veiledMatcher);
renderEmailList(res, R.id.replyto_heading, R.id.replyto_details, replyTo, viaDomain,
detailsView, addressCache, account, veiledMatcher);
renderEmailList(res, R.id.to_heading, R.id.to_details, to, viaDomain,
detailsView, addressCache, account, veiledMatcher);
renderEmailList(res, R.id.cc_heading, R.id.cc_details, cc, viaDomain,
detailsView, addressCache, account, veiledMatcher);
renderEmailList(res, R.id.bcc_heading, R.id.bcc_details, bcc, viaDomain,
detailsView, addressCache, account, veiledMatcher);
// 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) {
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.getName();
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) {
formattedEmails[i] = 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,
name, addressShown, viaDomain);
} else {
formattedEmails[i] = res.getString(R.string.address_display_format,
name, 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);
}
}
}