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