1/**
2 * Copyright (c) 2011, Google Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.mail.browse;
18
19import android.annotation.SuppressLint;
20import android.app.FragmentManager;
21import android.content.AsyncQueryHandler;
22import android.content.Context;
23import android.content.res.Resources;
24import android.database.DataSetObserver;
25import android.graphics.Bitmap;
26import android.graphics.Canvas;
27import android.graphics.Color;
28import android.graphics.Paint;
29import android.graphics.PorterDuff;
30import android.graphics.PorterDuffXfermode;
31import android.support.v4.text.BidiFormatter;
32import android.text.Html;
33import android.text.Spannable;
34import android.text.Spanned;
35import android.text.TextUtils;
36import android.text.method.LinkMovementMethod;
37import android.text.style.URLSpan;
38import android.util.AttributeSet;
39import android.view.LayoutInflater;
40import android.view.Menu;
41import android.view.MenuItem;
42import android.view.View;
43import android.view.View.OnClickListener;
44import android.view.ViewGroup;
45import android.widget.PopupMenu;
46import android.widget.PopupMenu.OnMenuItemClickListener;
47import android.widget.QuickContactBadge;
48import android.widget.TextView;
49import android.widget.Toast;
50
51import com.android.emailcommon.mail.Address;
52import com.android.mail.ContactInfo;
53import com.android.mail.ContactInfoSource;
54import com.android.mail.R;
55import com.android.mail.analytics.Analytics;
56import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
57import com.android.mail.compose.ComposeActivity;
58import com.android.mail.perf.Timer;
59import com.android.mail.photomanager.LetterTileProvider;
60import com.android.mail.print.PrintUtils;
61import com.android.mail.providers.Account;
62import com.android.mail.providers.Conversation;
63import com.android.mail.providers.Message;
64import com.android.mail.providers.Settings;
65import com.android.mail.providers.UIProvider;
66import com.android.mail.text.EmailAddressSpan;
67import com.android.mail.ui.AbstractConversationViewFragment;
68import com.android.mail.ui.ImageCanvas;
69import com.android.mail.utils.LogTag;
70import com.android.mail.utils.LogUtils;
71import com.android.mail.utils.StyleUtils;
72import com.android.mail.utils.Utils;
73import com.android.mail.utils.VeiledAddressMatcher;
74import com.google.common.annotations.VisibleForTesting;
75
76import java.io.IOException;
77import java.io.StringReader;
78import java.util.Map;
79
80public class MessageHeaderView extends SnapHeader implements OnClickListener,
81        OnMenuItemClickListener, ConversationContainer.DetachListener {
82
83    /**
84     * Cap very long recipient lists during summary construction for efficiency.
85     */
86    private static final int SUMMARY_MAX_RECIPIENTS = 50;
87
88    private static final int MAX_SNIPPET_LENGTH = 100;
89
90    private static final int SHOW_IMAGE_PROMPT_ONCE = 1;
91    private static final int SHOW_IMAGE_PROMPT_ALWAYS = 2;
92
93    private static final String HEADER_RENDER_TAG = "message header render";
94    private static final String LAYOUT_TAG = "message header layout";
95    private static final String MEASURE_TAG = "message header measure";
96
97    private static final String LOG_TAG = LogTag.getLogTag();
98
99    // This is a debug only feature
100    public static final boolean ENABLE_REPORT_RENDERING_PROBLEM = false;
101
102    private MessageHeaderViewCallbacks mCallbacks;
103
104    private View mBorderView;
105    private ViewGroup mUpperHeaderView;
106    private View mTitleContainer;
107    private View mSnapHeaderBottomBorder;
108    private TextView mSenderNameView;
109    private TextView mRecipientSummary;
110    private TextView mDateView;
111    private View mHideDetailsView;
112    private TextView mSnippetView;
113    private MessageHeaderContactBadge mPhotoView;
114    private ViewGroup mExtraContentView;
115    private ViewGroup mExpandedDetailsView;
116    private SpamWarningView mSpamWarningView;
117    private TextView mImagePromptView;
118    private MessageInviteView mInviteView;
119    private View mForwardButton;
120    private View mOverflowButton;
121    private View mDraftIcon;
122    private View mEditDraftButton;
123    private TextView mUpperDateView;
124    private View mReplyButton;
125    private View mReplyAllButton;
126    private View mAttachmentIcon;
127    private final EmailCopyContextMenu mEmailCopyMenu;
128
129    // temporary fields to reference raw data between initial render and details
130    // expansion
131    private String[] mFrom;
132    private String[] mTo;
133    private String[] mCc;
134    private String[] mBcc;
135    private String[] mReplyTo;
136
137    private boolean mIsDraft = false;
138
139    private int mSendingState;
140
141    private String mSnippet;
142
143    private Address mSender;
144
145    private ContactInfoSource mContactInfoSource;
146
147    private boolean mPreMeasuring;
148
149    private ConversationAccountController mAccountController;
150
151    private Map<String, Address> mAddressCache;
152
153    private boolean mShowImagePrompt;
154
155    private PopupMenu mPopup;
156
157    private MessageHeaderItem mMessageHeaderItem;
158    private ConversationMessage mMessage;
159
160    private boolean mRecipientSummaryValid;
161    private boolean mExpandedDetailsValid;
162
163    private final LayoutInflater mInflater;
164
165    private AsyncQueryHandler mQueryHandler;
166
167    private boolean mObservingContactInfo;
168
169    /**
170     * What I call myself? "me" in English, and internationalized correctly.
171     */
172    private final String mMyName;
173
174    private final DataSetObserver mContactInfoObserver = new DataSetObserver() {
175        @Override
176        public void onChanged() {
177            updateContactInfo();
178        }
179    };
180
181    private boolean mExpandable = true;
182
183    private VeiledAddressMatcher mVeiledMatcher;
184
185    private boolean mIsViewOnlyMode = false;
186
187    private LetterTileProvider mLetterTileProvider;
188    private final int mContactPhotoWidth;
189    private final int mContactPhotoHeight;
190    private final int mTitleContainerMarginEnd;
191
192    /**
193     * The snappy header has special visibility rules (i.e. no details header,
194     * even though it has an expanded appearance)
195     */
196    private boolean mIsSnappy;
197
198    private BidiFormatter mBidiFormatter;
199
200
201    public interface MessageHeaderViewCallbacks {
202        void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeight);
203
204        void setMessageExpanded(MessageHeaderItem item, int newSpacerHeight);
205
206        void setMessageDetailsExpanded(MessageHeaderItem messageHeaderItem, boolean expanded,
207                int previousMessageHeaderItemHeight);
208
209        void showExternalResources(Message msg);
210
211        void showExternalResources(String senderRawAddress);
212
213        boolean supportsMessageTransforms();
214
215        String getMessageTransforms(Message msg);
216
217        FragmentManager getFragmentManager();
218
219        /**
220         * @return <tt>true</tt> if this header is contained within a SecureConversationViewFragment
221         * and cannot assume the content is <strong>not</strong> malicious
222         */
223        boolean isSecure();
224    }
225
226    public MessageHeaderView(Context context) {
227        this(context, null);
228    }
229
230    public MessageHeaderView(Context context, AttributeSet attrs) {
231        this(context, attrs, -1);
232    }
233
234    public MessageHeaderView(Context context, AttributeSet attrs, int defStyle) {
235        super(context, attrs, defStyle);
236
237        mIsSnappy = false;
238        mEmailCopyMenu = new EmailCopyContextMenu(getContext());
239        mInflater = LayoutInflater.from(context);
240        mMyName = context.getString(R.string.me_object_pronoun);
241
242        final Resources res = getResources();
243        mContactPhotoWidth = res.getDimensionPixelSize(R.dimen.contact_image_width);
244        mContactPhotoHeight = res.getDimensionPixelSize(R.dimen.contact_image_height);
245        mTitleContainerMarginEnd = res.getDimensionPixelSize(R.dimen.conversation_view_margin_side);
246    }
247
248    @Override
249    protected void onFinishInflate() {
250        super.onFinishInflate();
251        mBorderView = findViewById(R.id.message_header_border);
252        mUpperHeaderView = (ViewGroup) findViewById(R.id.upper_header);
253        mTitleContainer = findViewById(R.id.title_container);
254        mSnapHeaderBottomBorder = findViewById(R.id.snap_header_bottom_border);
255        mSenderNameView = (TextView) findViewById(R.id.sender_name);
256        mRecipientSummary = (TextView) findViewById(R.id.recipient_summary);
257        mDateView = (TextView) findViewById(R.id.send_date);
258        mHideDetailsView = findViewById(R.id.hide_details);
259        mSnippetView = (TextView) findViewById(R.id.email_snippet);
260        mPhotoView = (MessageHeaderContactBadge) findViewById(R.id.photo);
261        mPhotoView.setQuickContactBadge(
262                (QuickContactBadge) findViewById(R.id.invisible_quick_contact));
263        mReplyButton = findViewById(R.id.reply);
264        mReplyAllButton = findViewById(R.id.reply_all);
265        mForwardButton = findViewById(R.id.forward);
266        mOverflowButton = findViewById(R.id.overflow);
267        mDraftIcon = findViewById(R.id.draft);
268        mEditDraftButton = findViewById(R.id.edit_draft);
269        mUpperDateView = (TextView) findViewById(R.id.upper_date);
270        mAttachmentIcon = findViewById(R.id.attachment);
271        mExtraContentView = (ViewGroup) findViewById(R.id.header_extra_content);
272
273        setExpanded(true);
274
275        registerMessageClickTargets(mReplyButton, mReplyAllButton, mForwardButton,
276                mEditDraftButton, mOverflowButton, mUpperHeaderView, mDateView, mHideDetailsView);
277
278        mUpperHeaderView.setOnCreateContextMenuListener(mEmailCopyMenu);
279    }
280
281    private void registerMessageClickTargets(View... views) {
282        for (View v : views) {
283            if (v != null) {
284                v.setOnClickListener(this);
285            }
286        }
287    }
288
289    @Override
290    public void initialize(ConversationAccountController accountController,
291            Map<String, Address> addressCache, MessageHeaderViewCallbacks callbacks,
292            ContactInfoSource contactInfoSource, VeiledAddressMatcher veiledAddressMatcher) {
293        initialize(accountController, addressCache);
294        setCallbacks(callbacks);
295        setContactInfoSource(contactInfoSource);
296        setVeiledMatcher(veiledAddressMatcher);
297    }
298
299    /**
300     * Associate the header with a contact info source for later contact
301     * presence/photo lookup.
302     */
303    public void setContactInfoSource(ContactInfoSource contactInfoSource) {
304        mContactInfoSource = contactInfoSource;
305    }
306
307    public void setCallbacks(MessageHeaderViewCallbacks callbacks) {
308        mCallbacks = callbacks;
309    }
310
311    public void setVeiledMatcher(VeiledAddressMatcher matcher) {
312        mVeiledMatcher = matcher;
313    }
314
315    public boolean isExpanded() {
316        // (let's just arbitrarily say that unbound views are expanded by default)
317        return mMessageHeaderItem == null || mMessageHeaderItem.isExpanded();
318    }
319
320    @Override
321    public void onDetachedFromParent() {
322        unbind();
323    }
324
325    /**
326     * Headers that are unbound will not match any rendered header (matches()
327     * will return false). Unbinding is not guaranteed to *hide* the view's old
328     * data, though. To re-bind this header to message data, call render() or
329     * renderUpperHeaderFrom().
330     */
331    @Override
332    public void unbind() {
333        mMessageHeaderItem = null;
334        mMessage = null;
335
336        if (mObservingContactInfo) {
337            mContactInfoSource.unregisterObserver(mContactInfoObserver);
338            mObservingContactInfo = false;
339        }
340    }
341
342    public void initialize(ConversationAccountController accountController,
343            Map<String, Address> addressCache) {
344        mAccountController = accountController;
345        mAddressCache = addressCache;
346    }
347
348    private Account getAccount() {
349        return mAccountController != null ? mAccountController.getAccount() : null;
350    }
351
352    public void bind(MessageHeaderItem headerItem, boolean measureOnly) {
353        if (mMessageHeaderItem != null && mMessageHeaderItem == headerItem) {
354            return;
355        }
356
357        mMessageHeaderItem = headerItem;
358        render(measureOnly);
359    }
360
361    /**
362     * Rebinds the view to its data. This will only update the view
363     * if the {@link MessageHeaderItem} sent as a parameter is the
364     * same as the view's current {@link MessageHeaderItem} and the
365     * view's expanded state differs from the item's expanded state.
366     */
367    public void rebind(MessageHeaderItem headerItem) {
368        if (mMessageHeaderItem == null || mMessageHeaderItem != headerItem ||
369                isActivated() == isExpanded()) {
370            return;
371        }
372
373        render(false /* measureOnly */);
374    }
375
376    @Override
377    public void refresh() {
378        render(false);
379    }
380
381    private BidiFormatter getBidiFormatter() {
382        if (mBidiFormatter == null) {
383            final ConversationViewAdapter adapter = mMessageHeaderItem != null
384                    ? mMessageHeaderItem.getAdapter() : null;
385            if (adapter == null) {
386                mBidiFormatter = BidiFormatter.getInstance();
387            } else {
388                mBidiFormatter = adapter.getBidiFormatter();
389            }
390        }
391        return mBidiFormatter;
392    }
393
394    private void render(boolean measureOnly) {
395        if (mMessageHeaderItem == null) {
396            return;
397        }
398
399        Timer t = new Timer();
400        t.start(HEADER_RENDER_TAG);
401
402        mRecipientSummaryValid = false;
403        mExpandedDetailsValid = false;
404
405        mMessage = mMessageHeaderItem.getMessage();
406
407        final Account account = getAccount();
408        final boolean alwaysShowImagesForAccount = (account != null) &&
409                (account.settings.showImages == Settings.ShowImages.ALWAYS);
410
411        final boolean alwaysShowImagesForMessage = mMessage.shouldShowImagePrompt();
412
413        if (!alwaysShowImagesForMessage) {
414            // we don't need the "Show picture" prompt if the user allows images for this message
415            mShowImagePrompt = false;
416        } else if (mCallbacks.isSecure()) {
417            // in a secure view we always display the "Show picture" prompt
418            mShowImagePrompt = true;
419        } else {
420            // otherwise honor the account setting for automatically showing pictures
421            mShowImagePrompt = !alwaysShowImagesForAccount;
422        }
423
424        setExpanded(mMessageHeaderItem.isExpanded());
425
426        mFrom = mMessage.getFromAddresses();
427        mTo = mMessage.getToAddresses();
428        mCc = mMessage.getCcAddresses();
429        mBcc = mMessage.getBccAddresses();
430        mReplyTo = mMessage.getReplyToAddresses();
431
432        /**
433         * Turns draft mode on or off. Draft mode hides message operations other
434         * than "edit", hides contact photo, hides presence, and changes the
435         * sender name to "Draft".
436         */
437        mIsDraft = mMessage.draftType != UIProvider.DraftType.NOT_A_DRAFT;
438        mSendingState = mMessage.sendingState;
439
440        // If this was a sent message AND:
441        // 1. the account has a custom from, the cursor will populate the
442        // selected custom from as the fromAddress when a message is sent but
443        // not yet synced.
444        // 2. the account has no custom froms, fromAddress will be empty, and we
445        // can safely fall back and show the account name as sender since it's
446        // the only possible fromAddress.
447        String from = mMessage.getFrom();
448        if (TextUtils.isEmpty(from)) {
449            from = (account != null) ? account.getEmailAddress() : "";
450        }
451        mSender = getAddress(from);
452
453        updateChildVisibility();
454
455        final String snippet;
456        if (mIsDraft || mSendingState != UIProvider.ConversationSendingState.OTHER) {
457            snippet = makeSnippet(mMessage.snippet);
458        } else {
459            snippet = mMessage.snippet;
460        }
461        mSnippet = snippet == null ? null : getBidiFormatter().unicodeWrap(snippet);
462
463        mSenderNameView.setText(getHeaderTitle());
464        setRecipientSummary();
465        setDateText();
466        mSnippetView.setText(mSnippet);
467        setAddressOnContextMenu();
468
469        if (mUpperDateView != null) {
470            mUpperDateView.setText(mMessageHeaderItem.getTimestampShort());
471        }
472
473        if (measureOnly) {
474            // avoid leaving any state around that would interfere with future regular bind() calls
475            unbind();
476        } else {
477            updateContactInfo();
478            if (!mObservingContactInfo) {
479                mContactInfoSource.registerObserver(mContactInfoObserver);
480                mObservingContactInfo = true;
481            }
482        }
483
484        t.pause(HEADER_RENDER_TAG);
485    }
486
487    /**
488     * Update context menu's address field for when the user long presses
489     * on the message header and attempts to copy/send email.
490     */
491    private void setAddressOnContextMenu() {
492        if (mSender != null) {
493            mEmailCopyMenu.setAddress(mSender.getAddress());
494        }
495    }
496
497    @Override
498    public boolean isBoundTo(ConversationOverlayItem item) {
499        return item == mMessageHeaderItem;
500    }
501
502    public Address getAddress(String emailStr) {
503        return Utils.getAddress(mAddressCache, emailStr);
504    }
505
506    private void updateSpacerHeight() {
507        final int h = measureHeight();
508
509        mMessageHeaderItem.setHeight(h);
510        if (mCallbacks != null) {
511            mCallbacks.setMessageSpacerHeight(mMessageHeaderItem, h);
512        }
513    }
514
515    private int measureHeight() {
516        ViewGroup parent = (ViewGroup) getParent();
517        if (parent == null) {
518            LogUtils.e(LOG_TAG, new Error(), "Unable to measure height of detached header");
519            return getHeight();
520        }
521        mPreMeasuring = true;
522        final int h = Utils.measureViewHeight(this, parent);
523        mPreMeasuring = false;
524        return h;
525    }
526
527    private CharSequence getHeaderTitle() {
528        CharSequence title;
529        switch (mSendingState) {
530            case UIProvider.ConversationSendingState.QUEUED:
531            case UIProvider.ConversationSendingState.SENDING:
532                title = getResources().getString(R.string.sending);
533                break;
534            case UIProvider.ConversationSendingState.RETRYING:
535                title = getResources().getString(R.string.message_retrying);
536                break;
537            case UIProvider.ConversationSendingState.SEND_ERROR:
538                title = getResources().getString(R.string.message_failed);
539                break;
540            default:
541                if (mIsDraft) {
542                    title = SendersView.getSingularDraftString(getContext());
543                } else {
544                    title = getBidiFormatter().unicodeWrap(
545                            getSenderName(mSender));
546                }
547        }
548
549        return title;
550    }
551
552    private void setRecipientSummary() {
553        if (!mRecipientSummaryValid) {
554            if (mMessageHeaderItem.recipientSummaryText == null) {
555                final Account account = getAccount();
556                final String meEmailAddress = (account != null) ? account.getEmailAddress() : "";
557                mMessageHeaderItem.recipientSummaryText = getRecipientSummaryText(getContext(),
558                        meEmailAddress, mMyName, mTo, mCc, mBcc, mAddressCache, mVeiledMatcher,
559                        getBidiFormatter());
560            }
561            mRecipientSummary.setText(mMessageHeaderItem.recipientSummaryText);
562            mRecipientSummaryValid = true;
563        }
564    }
565
566    private void setDateText() {
567        if (mIsSnappy) {
568            mDateView.setText(mMessageHeaderItem.getTimestampLong());
569            mDateView.setOnClickListener(null);
570        } else {
571            mDateView.setMovementMethod(LinkMovementMethod.getInstance());
572            mDateView.setText(Html.fromHtml(getResources().getString(
573                    R.string.date_and_view_details, mMessageHeaderItem.getTimestampLong())));
574            StyleUtils.stripUnderlinesAndUrl(mDateView);
575        }
576    }
577
578    /**
579     * Return the name, if known, or just the address.
580     */
581    private static String getSenderName(Address sender) {
582        if (sender == null) {
583            return "";
584        }
585        final String displayName = sender.getPersonal();
586        return TextUtils.isEmpty(displayName) ? sender.getAddress() : displayName;
587    }
588
589    private static void setChildVisibility(int visibility, View... children) {
590        for (View v : children) {
591            if (v != null) {
592                v.setVisibility(visibility);
593            }
594        }
595    }
596
597    private void setExpanded(final boolean expanded) {
598        // use View's 'activated' flag to store expanded state
599        // child view state lists can use this to toggle drawables
600        setActivated(expanded);
601        if (mMessageHeaderItem != null) {
602            mMessageHeaderItem.setExpanded(expanded);
603        }
604    }
605
606    /**
607     * Update the visibility of the many child views based on expanded/collapsed
608     * and draft/normal state.
609     */
610    private void updateChildVisibility() {
611        // Too bad this can't be done with an XML state list...
612
613        if (mIsViewOnlyMode) {
614            setMessageDetailsVisibility(VISIBLE);
615            setChildVisibility(GONE, mSnapHeaderBottomBorder);
616
617            setChildVisibility(GONE, mReplyButton, mReplyAllButton, mForwardButton,
618                    mOverflowButton, mDraftIcon, mEditDraftButton,
619                    mAttachmentIcon, mUpperDateView, mSnippetView);
620            setChildVisibility(VISIBLE, mPhotoView, mRecipientSummary);
621
622            setChildMarginEnd(mTitleContainer, 0);
623        } else if (isExpanded()) {
624            int normalVis, draftVis;
625
626            final boolean isSnappy = isSnappy();
627            setMessageDetailsVisibility((isSnappy) ? GONE : VISIBLE);
628            setChildVisibility(isSnappy ? VISIBLE : GONE, mSnapHeaderBottomBorder);
629
630            if (mIsDraft) {
631                normalVis = GONE;
632                draftVis = VISIBLE;
633            } else {
634                normalVis = VISIBLE;
635                draftVis = GONE;
636            }
637
638            setReplyOrReplyAllVisible();
639            setChildVisibility(normalVis, mPhotoView, mForwardButton, mOverflowButton);
640            setChildVisibility(draftVis, mDraftIcon, mEditDraftButton);
641            setChildVisibility(VISIBLE, mRecipientSummary);
642            setChildVisibility(GONE, mAttachmentIcon, mUpperDateView, mSnippetView);
643
644            setChildMarginEnd(mTitleContainer, 0);
645        } else {
646            setMessageDetailsVisibility(GONE);
647            setChildVisibility(GONE, mSnapHeaderBottomBorder);
648            setChildVisibility(VISIBLE, mSnippetView, mUpperDateView);
649
650            setChildVisibility(GONE, mEditDraftButton, mReplyButton, mReplyAllButton,
651                    mForwardButton, mOverflowButton, mRecipientSummary,
652                    mDateView, mHideDetailsView);
653
654            setChildVisibility(mMessage.hasAttachments ? VISIBLE : GONE,
655                    mAttachmentIcon);
656
657            if (mIsDraft) {
658                setChildVisibility(VISIBLE, mDraftIcon);
659                setChildVisibility(GONE, mPhotoView);
660            } else {
661                setChildVisibility(GONE, mDraftIcon);
662                setChildVisibility(VISIBLE, mPhotoView);
663            }
664
665            setChildMarginEnd(mTitleContainer, mTitleContainerMarginEnd);
666        }
667
668        final ConversationViewAdapter adapter = mMessageHeaderItem.getAdapter();
669        if (adapter != null) {
670            mBorderView.setVisibility(
671                    adapter.isPreviousItemSuperCollapsed(mMessageHeaderItem) ? GONE : VISIBLE);
672        } else {
673            mBorderView.setVisibility(VISIBLE);
674        }
675    }
676
677    /**
678     * If an overflow menu is present in this header's layout, set the
679     * visibility of "Reply" and "Reply All" actions based on a user preference.
680     * Only one of those actions will be visible when an overflow is present. If
681     * no overflow is present (e.g. big phone or tablet), it's assumed we have
682     * plenty of screen real estate and can show both.
683     */
684    private void setReplyOrReplyAllVisible() {
685        if (mIsDraft) {
686            setChildVisibility(GONE, mReplyButton, mReplyAllButton);
687            return;
688        } else if (mOverflowButton == null) {
689            setChildVisibility(VISIBLE, mReplyButton, mReplyAllButton);
690            return;
691        }
692
693        final Account account = getAccount();
694        final boolean defaultReplyAll = (account != null) ? account.settings.replyBehavior
695                == UIProvider.DefaultReplyBehavior.REPLY_ALL : false;
696        setChildVisibility(defaultReplyAll ? GONE : VISIBLE, mReplyButton);
697        setChildVisibility(defaultReplyAll ? VISIBLE : GONE, mReplyAllButton);
698    }
699
700    @SuppressLint("NewApi")
701    private static void setChildMarginEnd(View childView, int marginEnd) {
702        MarginLayoutParams mlp = (MarginLayoutParams) childView.getLayoutParams();
703        if (Utils.isRunningJBMR1OrLater()) {
704            mlp.setMarginEnd(marginEnd);
705        } else {
706            mlp.rightMargin = marginEnd;
707        }
708        childView.setLayoutParams(mlp);
709    }
710
711
712
713    @VisibleForTesting
714    static CharSequence getRecipientSummaryText(Context context, String meEmailAddress,
715            String myName, String[] to, String[] cc, String[] bcc,
716            Map<String, Address> addressCache, VeiledAddressMatcher matcher,
717            BidiFormatter bidiFormatter) {
718
719        final RecipientListsBuilder builder = new RecipientListsBuilder(
720                context, meEmailAddress, myName, addressCache, matcher, bidiFormatter);
721
722        builder.append(to);
723        builder.append(cc);
724        builder.append(bcc);
725
726        return builder.build();
727    }
728
729    /**
730     * Utility class to build a list of recipient lists.
731     */
732    private static class RecipientListsBuilder {
733        private final Context mContext;
734        private final String mMeEmailAddress;
735        private final String mMyName;
736        private final StringBuilder mBuilder = new StringBuilder();
737        private final CharSequence mComma;
738        private final Map<String, Address> mAddressCache;
739        private final VeiledAddressMatcher mMatcher;
740        private final BidiFormatter mBidiFormatter;
741
742        int mRecipientCount = 0;
743        boolean mFirst = true;
744
745        public RecipientListsBuilder(Context context, String meEmailAddress, String myName,
746                Map<String, Address> addressCache, VeiledAddressMatcher matcher,
747                BidiFormatter bidiFormatter) {
748            mContext = context;
749            mMeEmailAddress = meEmailAddress;
750            mMyName = myName;
751            mComma = mContext.getText(R.string.enumeration_comma);
752            mAddressCache = addressCache;
753            mMatcher = matcher;
754            mBidiFormatter = bidiFormatter;
755        }
756
757        public void append(String[] recipients) {
758            final int addLimit = SUMMARY_MAX_RECIPIENTS - mRecipientCount;
759            final boolean hasRecipients = appendRecipients(recipients, addLimit);
760            if (hasRecipients) {
761                mRecipientCount += Math.min(addLimit, recipients.length);
762            }
763        }
764
765        /**
766         * Appends formatted recipients of the message to the recipient list,
767         * as long as there are recipients left to append and the maximum number
768         * of addresses limit has not been reached.
769         * @param rawAddrs The addresses to append.
770         * @param maxToCopy The maximum number of addresses to append.
771         * @return {@code true} if a recipient has been appended. {@code false}, otherwise.
772         */
773        private boolean appendRecipients(String[] rawAddrs,
774                int maxToCopy) {
775            if (rawAddrs == null || rawAddrs.length == 0 || maxToCopy == 0) {
776                return false;
777            }
778
779            final int len = Math.min(maxToCopy, rawAddrs.length);
780            for (int i = 0; i < len; i++) {
781                final Address email = Utils.getAddress(mAddressCache, rawAddrs[i]);
782                final String emailAddress = email.getAddress();
783                final String name;
784                if (mMatcher != null && mMatcher.isVeiledAddress(emailAddress)) {
785                    if (TextUtils.isEmpty(email.getPersonal())) {
786                        // Let's write something more readable.
787                        name = mContext.getString(VeiledAddressMatcher.VEILED_SUMMARY_UNKNOWN);
788                    } else {
789                        name = email.getSimplifiedName();
790                    }
791                } else {
792                    // Not a veiled address, show first part of email, or "me".
793                    name = mMeEmailAddress.equals(emailAddress) ?
794                            mMyName : email.getSimplifiedName();
795                }
796
797                // duplicate TextUtils.join() logic to minimize temporary allocations
798                if (mFirst) {
799                    mFirst = false;
800                } else {
801                    mBuilder.append(mComma);
802                }
803                mBuilder.append(mBidiFormatter.unicodeWrap(name));
804            }
805
806            return true;
807        }
808
809        public CharSequence build() {
810            return mContext.getString(R.string.to_message_header, mBuilder);
811        }
812    }
813
814    private void updateContactInfo() {
815        if (mContactInfoSource == null || mSender == null) {
816            mPhotoView.setImageToDefault();
817            mPhotoView.setContentDescription(getResources().getString(
818                    R.string.contact_info_string_default));
819            return;
820        }
821
822        // Set the photo to either a found Bitmap or the default
823        // and ensure either the contact URI or email is set so the click
824        // handling works
825        String contentDesc = getResources().getString(R.string.contact_info_string,
826                !TextUtils.isEmpty(mSender.getPersonal())
827                        ? mSender.getPersonal()
828                        : mSender.getAddress());
829        mPhotoView.setContentDescription(contentDesc);
830        boolean photoSet = false;
831        final String email = mSender.getAddress();
832        final ContactInfo info = mContactInfoSource.getContactInfo(email);
833        final Resources res = getResources();
834        if (info != null) {
835            if (info.contactUri != null) {
836                mPhotoView.assignContactUri(info.contactUri);
837            } else {
838                mPhotoView.assignContactFromEmail(email, true /* lazyLookup */);
839            }
840
841            if (info.photo != null) {
842                mPhotoView.setImageBitmap(frameBitmapInCircle(info.photo));
843                photoSet = true;
844            }
845        } else {
846            mPhotoView.assignContactFromEmail(email, true /* lazyLookup */);
847        }
848
849        if (!photoSet) {
850            mPhotoView.setImageBitmap(
851                    frameBitmapInCircle(makeLetterTile(mSender.getPersonal(), email)));
852        }
853    }
854
855    private Bitmap makeLetterTile(
856            String displayName, String senderAddress) {
857        if (mLetterTileProvider == null) {
858            mLetterTileProvider = new LetterTileProvider(getContext());
859        }
860
861        final ImageCanvas.Dimensions dimensions = new ImageCanvas.Dimensions(
862                mContactPhotoWidth, mContactPhotoHeight, ImageCanvas.Dimensions.SCALE_ONE);
863        return mLetterTileProvider.getLetterTile(dimensions, displayName, senderAddress);
864    }
865
866    /**
867     * Frames the input bitmap in a circle.
868     */
869    private static Bitmap frameBitmapInCircle(Bitmap input) {
870        if (input == null) {
871            return null;
872        }
873
874        // Crop the image if not squared.
875        int inputWidth = input.getWidth();
876        int inputHeight = input.getHeight();
877        int targetX, targetY, targetSize;
878        if (inputWidth >= inputHeight) {
879            targetX = inputWidth / 2 - inputHeight / 2;
880            targetY = 0;
881            targetSize = inputHeight;
882        } else {
883            targetX = 0;
884            targetY = inputHeight / 2 - inputWidth / 2;
885            targetSize = inputWidth;
886        }
887
888        // Create an output bitmap and a canvas to draw on it.
889        Bitmap output = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888);
890        Canvas canvas = new Canvas(output);
891
892        // Create a black paint to draw the mask.
893        Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
894        paint.setColor(Color.BLACK);
895
896        // Draw a circle.
897        canvas.drawCircle(targetSize / 2, targetSize / 2, targetSize / 2, paint);
898
899        // Replace the black parts of the mask with the input image.
900        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
901        canvas.drawBitmap(input, targetX /* left */, targetY /* top */, paint);
902
903        return output;
904    }
905
906    @Override
907    public boolean onMenuItemClick(MenuItem item) {
908        mPopup.dismiss();
909        return onClick(null, item.getItemId());
910    }
911
912    @Override
913    public void onClick(View v) {
914        onClick(v, v.getId());
915    }
916
917    /**
918     * Handles clicks on either views or menu items. View parameter can be null
919     * for menu item clicks.
920     */
921    public boolean onClick(final View v, final int id) {
922        if (mMessage == null) {
923            LogUtils.i(LOG_TAG, "ignoring message header tap on unbound view");
924            return false;
925        }
926
927        boolean handled = true;
928
929        if (id == R.id.reply) {
930            ComposeActivity.reply(getContext(), getAccount(), mMessage);
931        } else if (id == R.id.reply_all) {
932            ComposeActivity.replyAll(getContext(), getAccount(), mMessage);
933        } else if (id == R.id.forward) {
934            ComposeActivity.forward(getContext(), getAccount(), mMessage);
935        } else if (id == R.id.print_message) {
936            printMessage();
937        } else if (id == R.id.report_rendering_problem) {
938            final String text = getContext().getString(R.string.report_rendering_problem_desc);
939            ComposeActivity.reportRenderingFeedback(getContext(), getAccount(), mMessage,
940                    text + "\n\n" + mCallbacks.getMessageTransforms(mMessage));
941        } else if (id == R.id.report_rendering_improvement) {
942            final String text = getContext().getString(R.string.report_rendering_improvement_desc);
943            ComposeActivity.reportRenderingFeedback(getContext(), getAccount(), mMessage,
944                    text + "\n\n" + mCallbacks.getMessageTransforms(mMessage));
945        } else if (id == R.id.edit_draft) {
946            ComposeActivity.editDraft(getContext(), getAccount(), mMessage);
947        } else if (id == R.id.overflow) {
948            if (mPopup == null) {
949                mPopup = new PopupMenu(getContext(), v);
950                mPopup.getMenuInflater().inflate(R.menu.message_header_overflow_menu,
951                        mPopup.getMenu());
952                mPopup.setOnMenuItemClickListener(this);
953            }
954            final boolean defaultReplyAll = getAccount().settings.replyBehavior
955                    == UIProvider.DefaultReplyBehavior.REPLY_ALL;
956            final Menu m = mPopup.getMenu();
957            m.findItem(R.id.reply).setVisible(defaultReplyAll);
958            m.findItem(R.id.reply_all).setVisible(!defaultReplyAll);
959            m.findItem(R.id.print_message).setVisible(Utils.isRunningKitkatOrLater());
960
961            final boolean reportRendering = ENABLE_REPORT_RENDERING_PROBLEM
962                && mCallbacks.supportsMessageTransforms();
963            m.findItem(R.id.report_rendering_improvement).setVisible(reportRendering);
964            m.findItem(R.id.report_rendering_problem).setVisible(reportRendering);
965
966            mPopup.show();
967        } else if (id == R.id.send_date || id == R.id.hide_details ||
968                id == R.id.details_expanded_content) {
969            toggleMessageDetails();
970        } else if (id == R.id.upper_header) {
971            toggleExpanded();
972        } else if (id == R.id.show_pictures_text) {
973            handleShowImagePromptClick(v);
974        } else {
975            LogUtils.i(LOG_TAG, "unrecognized header tap: %d", id);
976            handled = false;
977        }
978
979        if (handled && id != R.id.overflow) {
980            Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id,
981                    "message_header", 0);
982        }
983
984        return handled;
985    }
986
987    private void printMessage() {
988        // Secure conversation view does not use a conversation view adapter
989        // so it's safe to test for existence as a signal to use javascript or not.
990        final boolean useJavascript = mMessageHeaderItem.getAdapter() != null;
991        final Account account = getAccount();
992        final Conversation conversation = mMessage.getConversation();
993        final String baseUri =
994                AbstractConversationViewFragment.buildBaseUri(getContext(), account, conversation);
995        PrintUtils.printMessage(getContext(), mMessage, conversation.subject,
996                mAddressCache, conversation.getBaseUri(baseUri), useJavascript);
997    }
998
999    /**
1000     * Set to true if the user should not be able to perform message actions
1001     * on the message such as reply/reply all/forward/star/etc.
1002     *
1003     * Default is false.
1004     */
1005    public void setViewOnlyMode(boolean isViewOnlyMode) {
1006        mIsViewOnlyMode = isViewOnlyMode;
1007    }
1008
1009    public void setExpandable(boolean expandable) {
1010        mExpandable = expandable;
1011    }
1012
1013    public void toggleExpanded() {
1014        if (!mExpandable) {
1015            return;
1016        }
1017        setExpanded(!isExpanded());
1018
1019        // The snappy header will disappear; no reason to update text.
1020        if (!isSnappy()) {
1021            mSenderNameView.setText(getHeaderTitle());
1022            setRecipientSummary();
1023            setDateText();
1024            mSnippetView.setText(mSnippet);
1025        }
1026
1027        updateChildVisibility();
1028
1029        // Force-measure the new header height so we can set the spacer size and
1030        // reveal the message div in one pass. Force-measuring makes it unnecessary to set
1031        // mSizeChanged.
1032        int h = measureHeight();
1033        mMessageHeaderItem.setHeight(h);
1034        if (mCallbacks != null) {
1035            mCallbacks.setMessageExpanded(mMessageHeaderItem, h);
1036        }
1037    }
1038
1039    private static boolean isValidPosition(int position, int size) {
1040        return position >= 0 && position < size;
1041    }
1042
1043    @Override
1044    public void setSnappy() {
1045        mIsSnappy = true;
1046        hideMessageDetails();
1047    }
1048
1049    private boolean isSnappy() {
1050        return mIsSnappy;
1051    }
1052
1053    private void toggleMessageDetails() {
1054        int heightBefore = measureHeight();
1055        final boolean expand =
1056                (mExpandedDetailsView == null || mExpandedDetailsView.getVisibility() == GONE);
1057        setMessageDetailsExpanded(expand);
1058        updateSpacerHeight();
1059        if (mCallbacks != null) {
1060            mCallbacks.setMessageDetailsExpanded(mMessageHeaderItem, expand, heightBefore);
1061        }
1062    }
1063
1064    private void setMessageDetailsExpanded(boolean expand) {
1065        if (expand) {
1066            showExpandedDetails();
1067        } else {
1068            hideExpandedDetails();
1069        }
1070
1071        if (mMessageHeaderItem != null) {
1072            mMessageHeaderItem.detailsExpanded = expand;
1073        }
1074    }
1075
1076    public void setMessageDetailsVisibility(int vis) {
1077        if (vis == GONE) {
1078            hideExpandedDetails();
1079            hideSpamWarning();
1080            hideShowImagePrompt();
1081            hideInvite();
1082            mUpperHeaderView.setOnCreateContextMenuListener(null);
1083        } else {
1084            setMessageDetailsExpanded(mMessageHeaderItem.detailsExpanded);
1085            if (mMessage.spamWarningString == null) {
1086                hideSpamWarning();
1087            } else {
1088                showSpamWarning();
1089            }
1090            if (mShowImagePrompt) {
1091                if (mMessageHeaderItem.getShowImages()) {
1092                    showImagePromptAlways(true);
1093                } else {
1094                    showImagePromptOnce();
1095                }
1096            } else {
1097                hideShowImagePrompt();
1098            }
1099            if (mMessage.isFlaggedCalendarInvite()) {
1100                showInvite();
1101            } else {
1102                hideInvite();
1103            }
1104            mUpperHeaderView.setOnCreateContextMenuListener(mEmailCopyMenu);
1105        }
1106    }
1107
1108    private void hideMessageDetails() {
1109        setMessageDetailsVisibility(GONE);
1110    }
1111
1112    private void hideExpandedDetails() {
1113        if (mExpandedDetailsView != null) {
1114            mExpandedDetailsView.setVisibility(GONE);
1115        }
1116        mDateView.setVisibility(VISIBLE);
1117        mHideDetailsView.setVisibility(GONE);
1118    }
1119
1120    private void hideInvite() {
1121        if (mInviteView != null) {
1122            mInviteView.setVisibility(GONE);
1123        }
1124    }
1125
1126    private void showInvite() {
1127        if (mInviteView == null) {
1128            mInviteView = (MessageInviteView) mInflater.inflate(
1129                    R.layout.conversation_message_invite, this, false);
1130            mExtraContentView.addView(mInviteView);
1131        }
1132        mInviteView.bind(mMessage);
1133        mInviteView.setVisibility(VISIBLE);
1134    }
1135
1136    private void hideShowImagePrompt() {
1137        if (mImagePromptView != null) {
1138            mImagePromptView.setVisibility(GONE);
1139        }
1140    }
1141
1142    private void showImagePromptOnce() {
1143        if (mImagePromptView == null) {
1144            mImagePromptView = (TextView) mInflater.inflate(
1145                    R.layout.conversation_message_show_pics, this, false);
1146            mExtraContentView.addView(mImagePromptView);
1147            mImagePromptView.setOnClickListener(this);
1148        }
1149        mImagePromptView.setVisibility(VISIBLE);
1150        mImagePromptView.setText(R.string.show_images);
1151        mImagePromptView.setTag(SHOW_IMAGE_PROMPT_ONCE);
1152    }
1153
1154    /**
1155     * Shows the "Always show pictures" message
1156     *
1157     * @param initialShowing <code>true</code> if this is the first time we are showing the prompt
1158     *        for "show images", <code>false</code> if we are transitioning from "Show pictures"
1159     */
1160    private void showImagePromptAlways(final boolean initialShowing) {
1161        if (initialShowing) {
1162            // Initialize the view
1163            showImagePromptOnce();
1164        }
1165
1166        mImagePromptView.setText(R.string.always_show_images);
1167        mImagePromptView.setTag(SHOW_IMAGE_PROMPT_ALWAYS);
1168
1169        if (!initialShowing) {
1170            // the new text's line count may differ, so update the spacer height
1171            updateSpacerHeight();
1172        }
1173    }
1174
1175    private void hideSpamWarning() {
1176        if (mSpamWarningView != null) {
1177            mSpamWarningView.setVisibility(GONE);
1178        }
1179    }
1180
1181    private void showSpamWarning() {
1182        if (mSpamWarningView == null) {
1183            mSpamWarningView = (SpamWarningView)
1184                    mInflater.inflate(R.layout.conversation_message_spam_warning, this, false);
1185            mExtraContentView.addView(mSpamWarningView);
1186        }
1187
1188        mSpamWarningView.showSpamWarning(mMessage, mSender);
1189    }
1190
1191    private void handleShowImagePromptClick(View v) {
1192        Integer state = (Integer) v.getTag();
1193        if (state == null) {
1194            return;
1195        }
1196        switch (state) {
1197            case SHOW_IMAGE_PROMPT_ONCE:
1198                if (mCallbacks != null) {
1199                    mCallbacks.showExternalResources(mMessage);
1200                }
1201                if (mMessageHeaderItem != null) {
1202                    mMessageHeaderItem.setShowImages(true);
1203                }
1204                if (mIsViewOnlyMode) {
1205                    hideShowImagePrompt();
1206                } else {
1207                    showImagePromptAlways(false);
1208                }
1209                break;
1210            case SHOW_IMAGE_PROMPT_ALWAYS:
1211                mMessage.markAlwaysShowImages(getQueryHandler(), 0 /* token */, null /* cookie */);
1212
1213                if (mCallbacks != null) {
1214                    mCallbacks.showExternalResources(mMessage.getFrom());
1215                }
1216
1217                mShowImagePrompt = false;
1218                v.setTag(null);
1219                v.setVisibility(GONE);
1220                updateSpacerHeight();
1221                Toast.makeText(getContext(), R.string.always_show_images_toast, Toast.LENGTH_SHORT)
1222                        .show();
1223                break;
1224        }
1225    }
1226
1227    private AsyncQueryHandler getQueryHandler() {
1228        if (mQueryHandler == null) {
1229            mQueryHandler = new AsyncQueryHandler(getContext().getContentResolver()) {};
1230        }
1231        return mQueryHandler;
1232    }
1233
1234    /**
1235     * Makes expanded details visible. If necessary, will inflate expanded
1236     * details layout and render using saved-off state (senders, timestamp,
1237     * etc).
1238     */
1239    private void showExpandedDetails() {
1240        // lazily create expanded details view
1241        final boolean expandedViewCreated = ensureExpandedDetailsView();
1242        if (expandedViewCreated) {
1243            mExtraContentView.addView(mExpandedDetailsView, 0);
1244        }
1245        mExpandedDetailsView.setVisibility(VISIBLE);
1246        mDateView.setVisibility(GONE);
1247        mHideDetailsView.setVisibility(VISIBLE);
1248    }
1249
1250    private boolean ensureExpandedDetailsView() {
1251        boolean viewCreated = false;
1252        if (mExpandedDetailsView == null) {
1253            View v = inflateExpandedDetails(mInflater);
1254            v.setOnClickListener(this);
1255
1256            mExpandedDetailsView = (ViewGroup) v;
1257            viewCreated = true;
1258        }
1259        if (!mExpandedDetailsValid) {
1260            renderExpandedDetails(getResources(), mExpandedDetailsView, mMessage.viaDomain,
1261                    mAddressCache, getAccount(), mVeiledMatcher, mFrom, mReplyTo, mTo, mCc, mBcc,
1262                    mMessageHeaderItem.getTimestampFull(),
1263                    getBidiFormatter());
1264
1265            mExpandedDetailsValid = true;
1266        }
1267        return viewCreated;
1268    }
1269
1270    public static View inflateExpandedDetails(LayoutInflater inflater) {
1271        return inflater.inflate(R.layout.conversation_message_header_details, null, false);
1272    }
1273
1274    public static void renderExpandedDetails(Resources res, View detailsView,
1275            String viaDomain, Map<String, Address> addressCache, Account account,
1276            VeiledAddressMatcher veiledMatcher, String[] from, String[] replyTo,
1277            String[] to, String[] cc, String[] bcc, CharSequence receivedTimestamp,
1278            BidiFormatter bidiFormatter) {
1279        renderEmailList(res, R.id.from_heading, R.id.from_details, from, viaDomain,
1280                detailsView, addressCache, account, veiledMatcher, bidiFormatter);
1281        renderEmailList(res, R.id.replyto_heading, R.id.replyto_details, replyTo, viaDomain,
1282                detailsView, addressCache, account, veiledMatcher, bidiFormatter);
1283        renderEmailList(res, R.id.to_heading, R.id.to_details, to, viaDomain,
1284                detailsView, addressCache, account, veiledMatcher, bidiFormatter);
1285        renderEmailList(res, R.id.cc_heading, R.id.cc_details, cc, viaDomain,
1286                detailsView, addressCache, account, veiledMatcher, bidiFormatter);
1287        renderEmailList(res, R.id.bcc_heading, R.id.bcc_details, bcc, viaDomain,
1288                detailsView, addressCache, account, veiledMatcher, bidiFormatter);
1289
1290        // Render date
1291        detailsView.findViewById(R.id.date_heading).setVisibility(VISIBLE);
1292        final TextView date = (TextView) detailsView.findViewById(R.id.date_details);
1293        date.setText(receivedTimestamp);
1294        date.setVisibility(VISIBLE);
1295    }
1296
1297    /**
1298     * Render an email list for the expanded message details view.
1299     */
1300    private static void renderEmailList(Resources res, int headerId, int detailsId,
1301            String[] emails, String viaDomain, View rootView,
1302            Map<String, Address> addressCache, Account account,
1303            VeiledAddressMatcher veiledMatcher, BidiFormatter bidiFormatter) {
1304        if (emails == null || emails.length == 0) {
1305            return;
1306        }
1307        final String[] formattedEmails = new String[emails.length];
1308        for (int i = 0; i < emails.length; i++) {
1309            final Address email = Utils.getAddress(addressCache, emails[i]);
1310            String name = email.getPersonal();
1311            final String address = email.getAddress();
1312            // Check if the address here is a veiled address.  If it is, we need to display an
1313            // alternate layout
1314            final boolean isVeiledAddress = veiledMatcher != null &&
1315                    veiledMatcher.isVeiledAddress(address);
1316            final String addressShown;
1317            if (isVeiledAddress) {
1318                // Add the warning at the end of the name, and remove the address.  The alternate
1319                // text cannot be put in the address part, because the address is made into a link,
1320                // and the alternate human-readable text is not a link.
1321                addressShown = "";
1322                if (TextUtils.isEmpty(name)) {
1323                    // Empty name and we will block out the address. Let's write something more
1324                    // readable.
1325                    name = res.getString(VeiledAddressMatcher.VEILED_ALTERNATE_TEXT_UNKNOWN_PERSON);
1326                } else {
1327                    name = name + res.getString(VeiledAddressMatcher.VEILED_ALTERNATE_TEXT);
1328                }
1329            } else {
1330                addressShown = address;
1331            }
1332            if (name == null || name.length() == 0 || name.equalsIgnoreCase(addressShown)) {
1333                formattedEmails[i] = bidiFormatter.unicodeWrap(addressShown);
1334            } else {
1335                // The one downside to having the showViaDomain here is that
1336                // if the sender does not have a name, it will not show the via info
1337                if (viaDomain != null) {
1338                    formattedEmails[i] = res.getString(
1339                            R.string.address_display_format_with_via_domain,
1340                            bidiFormatter.unicodeWrap(name),
1341                            bidiFormatter.unicodeWrap(addressShown),
1342                            bidiFormatter.unicodeWrap(viaDomain));
1343                } else {
1344                    formattedEmails[i] = res.getString(R.string.address_display_format,
1345                            bidiFormatter.unicodeWrap(name),
1346                            bidiFormatter.unicodeWrap(addressShown));
1347                }
1348            }
1349        }
1350
1351        rootView.findViewById(headerId).setVisibility(VISIBLE);
1352        final TextView detailsText = (TextView) rootView.findViewById(detailsId);
1353        detailsText.setText(TextUtils.join("\n", formattedEmails));
1354        stripUnderlines(detailsText, account);
1355        detailsText.setVisibility(VISIBLE);
1356    }
1357
1358    private static void stripUnderlines(TextView textView, Account account) {
1359        final Spannable spannable = (Spannable) textView.getText();
1360        final URLSpan[] urls = textView.getUrls();
1361
1362        for (URLSpan span : urls) {
1363            final int start = spannable.getSpanStart(span);
1364            final int end = spannable.getSpanEnd(span);
1365            spannable.removeSpan(span);
1366            span = new EmailAddressSpan(account, span.getURL().substring(7));
1367            spannable.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1368        }
1369    }
1370
1371    /**
1372     * Returns a short plaintext snippet generated from the given HTML message
1373     * body. Collapses whitespace, ignores '&lt;' and '&gt;' characters and
1374     * everything in between, and truncates the snippet to no more than 100
1375     * characters.
1376     *
1377     * @return Short plaintext snippet
1378     */
1379    @VisibleForTesting
1380    static String makeSnippet(final String messageBody) {
1381        if (TextUtils.isEmpty(messageBody)) {
1382            return null;
1383        }
1384
1385        final StringBuilder snippet = new StringBuilder(MAX_SNIPPET_LENGTH);
1386
1387        final StringReader reader = new StringReader(messageBody);
1388        try {
1389            int c;
1390            while ((c = reader.read()) != -1 && snippet.length() < MAX_SNIPPET_LENGTH) {
1391                // Collapse whitespace.
1392                if (Character.isWhitespace(c)) {
1393                    snippet.append(' ');
1394                    do {
1395                        c = reader.read();
1396                    } while (Character.isWhitespace(c));
1397                    if (c == -1) {
1398                        break;
1399                    }
1400                }
1401
1402                if (c == '<') {
1403                    // Ignore everything up to and including the next '>'
1404                    // character.
1405                    while ((c = reader.read()) != -1) {
1406                        if (c == '>') {
1407                            break;
1408                        }
1409                    }
1410
1411                    // If we reached the end of the message body, exit.
1412                    if (c == -1) {
1413                        break;
1414                    }
1415                } else if (c == '&') {
1416                    // Read HTML entity.
1417                    StringBuilder sb = new StringBuilder();
1418
1419                    while ((c = reader.read()) != -1) {
1420                        if (c == ';') {
1421                            break;
1422                        }
1423                        sb.append((char) c);
1424                    }
1425
1426                    String entity = sb.toString();
1427                    if ("nbsp".equals(entity)) {
1428                        snippet.append(' ');
1429                    } else if ("lt".equals(entity)) {
1430                        snippet.append('<');
1431                    } else if ("gt".equals(entity)) {
1432                        snippet.append('>');
1433                    } else if ("amp".equals(entity)) {
1434                        snippet.append('&');
1435                    } else if ("quot".equals(entity)) {
1436                        snippet.append('"');
1437                    } else if ("apos".equals(entity) || "#39".equals(entity)) {
1438                        snippet.append('\'');
1439                    } else {
1440                        // Unknown entity; just append the literal string.
1441                        snippet.append('&').append(entity);
1442                        if (c == ';') {
1443                            snippet.append(';');
1444                        }
1445                    }
1446
1447                    // If we reached the end of the message body, exit.
1448                    if (c == -1) {
1449                        break;
1450                    }
1451                } else {
1452                    // The current character is a non-whitespace character that
1453                    // isn't inside some
1454                    // HTML tag and is not part of an HTML entity.
1455                    snippet.append((char) c);
1456                }
1457            }
1458        } catch (IOException e) {
1459            LogUtils.wtf(LOG_TAG, e, "Really? IOException while reading a freaking string?!? ");
1460        }
1461
1462        return snippet.toString();
1463    }
1464
1465    @Override
1466    protected void onLayout(boolean changed, int l, int t, int r, int b) {
1467        Timer perf = new Timer();
1468        perf.start(LAYOUT_TAG);
1469        super.onLayout(changed, l, t, r, b);
1470        perf.pause(LAYOUT_TAG);
1471    }
1472
1473    @Override
1474    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
1475        Timer t = new Timer();
1476        if (Timer.ENABLE_TIMER && !mPreMeasuring) {
1477            t.count("header measure id=" + mMessage.id);
1478            t.start(MEASURE_TAG);
1479        }
1480        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
1481        if (!mPreMeasuring) {
1482            t.pause(MEASURE_TAG);
1483        }
1484    }
1485}
1486