MessageHeaderView.java revision 65fe28fa88daad08f3be4c084ca5b4eaa366d1a7
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.content.AsyncQueryHandler; 20import android.content.Context; 21import android.graphics.Canvas; 22import android.graphics.Typeface; 23import android.provider.ContactsContract; 24import android.text.Spannable; 25import android.text.SpannableStringBuilder; 26import android.text.TextUtils; 27import android.text.style.StyleSpan; 28import android.util.AttributeSet; 29import android.view.LayoutInflater; 30import android.view.MenuItem; 31import android.view.View; 32import android.view.View.OnClickListener; 33import android.view.ViewGroup; 34import android.widget.ImageView; 35import android.widget.LinearLayout; 36import android.widget.PopupMenu; 37import android.widget.PopupMenu.OnMenuItemClickListener; 38import android.widget.QuickContactBadge; 39import android.widget.TextView; 40import android.widget.Toast; 41 42import com.android.mail.ContactInfoSource; 43import com.android.mail.FormattedDateBuilder; 44import com.android.mail.R; 45import com.android.mail.SenderInfoLoader.ContactInfo; 46import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem; 47import com.android.mail.compose.ComposeActivity; 48import com.android.mail.perf.Timer; 49import com.android.mail.providers.Account; 50import com.android.mail.providers.Address; 51import com.android.mail.providers.Message; 52import com.android.mail.providers.UIProvider; 53import com.android.mail.utils.LogUtils; 54import com.android.mail.utils.Utils; 55import com.google.common.annotations.VisibleForTesting; 56 57import java.io.IOException; 58import java.io.StringReader; 59import java.util.Map; 60 61public class MessageHeaderView extends LinearLayout implements OnClickListener, 62 OnMenuItemClickListener, HeaderBlock { 63 64 /** 65 * Cap very long recipient lists during summary construction for efficiency. 66 */ 67 private static final int SUMMARY_MAX_RECIPIENTS = 50; 68 69 private static final int MAX_SNIPPET_LENGTH = 100; 70 71 private static final int SHOW_IMAGE_PROMPT_ONCE = 1; 72 private static final int SHOW_IMAGE_PROMPT_ALWAYS = 2; 73 74 private static final String HEADER_INFLATE_TAG = "message header inflate"; 75 private static final String HEADER_ADDVIEW_TAG = "message header addView"; 76 private static final String HEADER_RENDER_TAG = "message header render"; 77 private static final String PREMEASURE_TAG = "message header pre-measure"; 78 private static final String LAYOUT_TAG = "message header layout"; 79 private static final String MEASURE_TAG = "message header measure"; 80 81 private static final String RECIPIENT_HEADING_DELIMITER = " "; 82 83 private static final String LOG_TAG = new LogUtils().getLogTag(); 84 85 private MessageHeaderViewCallbacks mCallbacks; 86 87 private ViewGroup mUpperHeaderView; 88 private TextView mSenderNameView; 89 private TextView mSenderEmailView; 90 private QuickContactBadge mPhotoView; 91 private ImageView mStarView; 92 private ViewGroup mTitleContainerView; 93 private ViewGroup mCollapsedDetailsView; 94 private ViewGroup mExpandedDetailsView; 95 private ViewGroup mImagePromptView; 96 private View mBottomBorderView; 97 private ImageView mPresenceView; 98 private View mPhotoSpacerView; 99 private View mForwardButton; 100 private View mOverflowButton; 101 private View mDraftIcon; 102 private View mEditDraftButton; 103 private TextView mUpperDateView; 104 private View mReplyButton; 105 private View mReplyAllButton; 106 private View mAttachmentIcon; 107 108 // temporary fields to reference raw data between initial render and details 109 // expansion 110 private String[] mTo; 111 private String[] mCc; 112 private String[] mBcc; 113 private String[] mReplyTo; 114 private long mTimestampMs; 115 private FormattedDateBuilder mDateBuilder; 116 117 private boolean mIsDraft = false; 118 119 private boolean mIsSending; 120 121 /** 122 * The snappy header has special visibility rules (i.e. no details header, 123 * even though it has an expanded appearance) 124 */ 125 private boolean mIsSnappy; 126 127 private String mSnippet; 128 129 private Address mSender; 130 131 private ContactInfoSource mContactInfoSource; 132 133 private boolean mPreMeasuring; 134 135 private Account mAccount; 136 137 private Map<String, Address> mAddressCache; 138 139 private boolean mShowImagePrompt; 140 141 private boolean mDefaultReplyAll; 142 143 private int mDrawTranslateY; 144 145 private CharSequence mTimestampShort; 146 147 /** 148 * Take the initial visibility of the star view to mean its collapsed 149 * visibility. Star is always visible when expanded, but sometimes, like on 150 * phones, there isn't enough room to warrant showing star when collapsed. 151 */ 152 private int mCollapsedStarVis; 153 154 /** 155 * Take the initial right margin of the header title container to mean its 156 * right margin when collapsed. There's currently no need for additional 157 * margin when expanded, but if that need ever arises, title_container can 158 * simply tack on some extra right padding. 159 */ 160 private int mTitleContainerCollapsedMarginRight; 161 162 private PopupMenu mPopup; 163 164 private MessageHeaderItem mMessageHeaderItem; 165 private Message mMessage; 166 167 private boolean mCollapsedDetailsValid; 168 private boolean mExpandedDetailsValid; 169 170 private final LayoutInflater mInflater; 171 172 private AsyncQueryHandler mQueryHandler; 173 174 public interface MessageHeaderViewCallbacks { 175 void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeight); 176 177 void setMessageExpanded(MessageHeaderItem item, int newSpacerHeight); 178 179 void showExternalResources(Message msg); 180 } 181 182 public MessageHeaderView(Context context) { 183 this(context, null); 184 } 185 186 public MessageHeaderView(Context context, AttributeSet attrs) { 187 this(context, attrs, -1); 188 } 189 190 public MessageHeaderView(Context context, AttributeSet attrs, int defStyle) { 191 super(context, attrs, defStyle); 192 193 mInflater = LayoutInflater.from(context); 194 } 195 196 @Override 197 protected void onFinishInflate() { 198 super.onFinishInflate(); 199 mUpperHeaderView = (ViewGroup) findViewById(R.id.upper_header); 200 mSenderNameView = (TextView) findViewById(R.id.sender_name); 201 mSenderEmailView = (TextView) findViewById(R.id.sender_email); 202 mPhotoView = (QuickContactBadge) findViewById(R.id.photo); 203 mPhotoSpacerView = findViewById(R.id.photo_spacer); 204 mReplyButton = findViewById(R.id.reply); 205 mReplyAllButton = findViewById(R.id.reply_all); 206 mForwardButton = findViewById(R.id.forward); 207 mStarView = (ImageView) findViewById(R.id.star); 208 mPresenceView = (ImageView) findViewById(R.id.presence); 209 mTitleContainerView = (ViewGroup) findViewById(R.id.title_container); 210 mOverflowButton = findViewById(R.id.overflow); 211 mDraftIcon = findViewById(R.id.draft); 212 mEditDraftButton = findViewById(R.id.edit_draft); 213 mUpperDateView = (TextView) findViewById(R.id.upper_date); 214 mAttachmentIcon = findViewById(R.id.attachment); 215 216 mCollapsedStarVis = mStarView.getVisibility(); 217 mTitleContainerCollapsedMarginRight = ((MarginLayoutParams) mTitleContainerView 218 .getLayoutParams()).rightMargin; 219 220 mBottomBorderView = findViewById(R.id.details_bottom_border); 221 222 setExpanded(true); 223 224 registerMessageClickTargets(R.id.reply, R.id.reply_all, R.id.forward, R.id.star, 225 R.id.edit_draft, R.id.overflow, R.id.upper_header); 226 } 227 228 private void registerMessageClickTargets(int... ids) { 229 for (int id : ids) { 230 View v = findViewById(id); 231 if (v != null) { 232 v.setOnClickListener(this); 233 } 234 } 235 } 236 237 /** 238 * Associate the header with a contact info source for later contact 239 * presence/photo lookup. 240 */ 241 public void setContactInfoSource(ContactInfoSource contactInfoSource) { 242 mContactInfoSource = contactInfoSource; 243 } 244 245 public void setCallbacks(MessageHeaderViewCallbacks callbacks) { 246 mCallbacks = callbacks; 247 } 248 249 /** 250 * Find the header view corresponding to a message with given local ID. 251 * 252 * @param parent the view parent to search within 253 * @param localMessageId local message ID 254 * @return a header view or null 255 */ 256 public static MessageHeaderView find(ViewGroup parent, long localMessageId) { 257 return (MessageHeaderView) parent.findViewWithTag(localMessageId); 258 } 259 260 public boolean isExpanded() { 261 // (let's just arbitrarily say that unbound views are expanded by default) 262 return mMessageHeaderItem == null || mMessageHeaderItem.isExpanded(); 263 } 264 265 @Override 266 public boolean canSnap() { 267 return isExpanded(); 268 } 269 270 @Override 271 public MessageHeaderView getSnapView() { 272 return this; 273 } 274 275 public void setSnappy(boolean snappy) { 276 mIsSnappy = snappy; 277 hideMessageDetails(); 278 if (snappy) { 279 setBackgroundDrawable(null); 280 } else { 281 setBackgroundColor(android.R.color.white); 282 } 283 } 284 285 /** 286 * Check if this header's displayed data matches that of another header. 287 * 288 * @param other another header 289 * @return true if the headers are displaying data for the same message 290 */ 291 public boolean matches(MessageHeaderView other) { 292 return other != null && mMessage != null && mMessage.equals(other.mMessage); 293 } 294 295 /** 296 * Headers that are unbound will not match any rendered header (matches() 297 * will return false). Unbinding is not guaranteed to *hide* the view's old 298 * data, though. To re-bind this header to message data, call render() or 299 * renderUpperHeaderFrom(). 300 */ 301 public void unbind() { 302 mMessageHeaderItem = null; 303 mMessage = null; 304 } 305 306 public void renderUpperHeaderFrom(MessageHeaderView other) { 307 mMessageHeaderItem = other.mMessageHeaderItem; 308 mMessage = other.mMessage; 309 mSender = other.mSender; 310 mDefaultReplyAll = other.mDefaultReplyAll; 311 312 mSenderNameView.setText(other.mSenderNameView.getText()); 313 mSenderEmailView.setText(other.mSenderEmailView.getText()); 314 mStarView.setSelected(other.mStarView.isSelected()); 315 mStarView.setContentDescription(getResources().getString( 316 mStarView.isSelected() ? R.string.remove_star : R.string.add_star)); 317 318 updateContactInfo(); 319 320 mIsDraft = other.mIsDraft; 321 updateChildVisibility(); 322 } 323 324 public void initialize(FormattedDateBuilder dateBuilder, Account account, 325 Map<String, Address> addressCache) { 326 mDateBuilder = dateBuilder; 327 mAccount = account; 328 mAddressCache = addressCache; 329 } 330 331 public void bind(MessageHeaderItem headerItem, boolean defaultReplyAll) { 332 Timer t = new Timer(); 333 t.start(HEADER_RENDER_TAG); 334 335 mCollapsedDetailsValid = false; 336 mExpandedDetailsValid = false; 337 338 mMessageHeaderItem = headerItem; 339 mMessage = headerItem.message; 340 mShowImagePrompt = mMessage.shouldShowImagePrompt(); 341 mDefaultReplyAll = defaultReplyAll; 342 setExpanded(headerItem.isExpanded()); 343 344 mTimestampMs = mMessage.dateReceivedMs; 345 mTimestampShort = headerItem.timestampShort; 346 if (mTimestampShort == null) { 347 mTimestampShort = mDateBuilder.formatShortDate(mTimestampMs); 348 headerItem.timestampShort = mTimestampShort; 349 } 350 351 mTo = mMessage.getToAddresses(); 352 mCc = mMessage.getCcAddresses(); 353 mBcc = mMessage.getBccAddresses(); 354 mReplyTo = mMessage.getReplyToAddresses(); 355 356 /** 357 * Turns draft mode on or off. Draft mode hides message operations other 358 * than "edit", hides contact photo, hides presence, and changes the 359 * sender name to "Draft". 360 */ 361 mIsDraft = mMessage.draftType != UIProvider.DraftType.NOT_A_DRAFT; 362 mIsSending = isInOutbox(); 363 364 updateChildVisibility(); 365 366 if (mIsDraft || isInOutbox()) { 367 mSnippet = makeSnippet(mMessage.snippet); 368 } else { 369 mSnippet = mMessage.snippet; 370 } 371 372 // If this was a sent message AND: 373 // 1. the account has a custom from, the cursor will populate the 374 // selected custom from as the fromAddress when a message is sent but 375 // not yet synced. 376 // 2. the account has no custom froms, fromAddress will be empty, and we 377 // can safely fall back and show the account name as sender since it's 378 // the only possible fromAddress. 379 String from = mMessage.from; 380 if (TextUtils.isEmpty(from)) { 381 from = mAccount.name; 382 } 383 mSender = getAddress(from); 384 385 mSenderNameView.setText(getHeaderTitle()); 386 mSenderEmailView.setText(getHeaderSubtitle()); 387 388 if (mUpperDateView != null) { 389 mUpperDateView.setText(mTimestampShort); 390 } 391 392 mStarView.setSelected(mMessage.starred); 393 mStarView.setContentDescription(getResources().getString( 394 mStarView.isSelected() ? R.string.remove_star : R.string.add_star)); 395 396 updateContactInfo(); 397 398 t.pause(HEADER_RENDER_TAG); 399 } 400 401 private Address getAddress(String emailStr) { 402 return getAddress(mAddressCache, emailStr); 403 } 404 405 private static Address getAddress(Map<String, Address> cache, String emailStr) { 406 Address addr = null; 407 if (cache != null) { 408 addr = cache.get(emailStr); 409 } 410 if (addr == null) { 411 addr = Address.getEmailAddress(emailStr); 412 if (cache != null) { 413 cache.put(emailStr, addr); 414 } 415 } 416 return addr; 417 } 418 419 private boolean isInOutbox() { 420 // TODO: what should this read? Folder info? 421 return false; 422 } 423 424 private void updateSpacerHeight() { 425 final int h = measureHeight(); 426 427 mMessageHeaderItem.setHeight(h); 428 if (mCallbacks != null) { 429 mCallbacks.setMessageSpacerHeight(mMessageHeaderItem, h); 430 } 431 } 432 433 private int measureHeight() { 434 ViewGroup parent = (ViewGroup) getParent(); 435 if (parent == null) { 436 LogUtils.e(LOG_TAG, new Error(), "Unable to measure height of detached header"); 437 return getHeight(); 438 } 439 mPreMeasuring = true; 440 final int h = Utils.measureViewHeight(this, parent); 441 mPreMeasuring = false; 442 return h; 443 } 444 445 private CharSequence getHeaderTitle() { 446 CharSequence title; 447 448 if (mIsDraft) { 449 title = getResources().getQuantityText(R.plurals.draft, 1); 450 } else if (mIsSending) { 451 title = getResources().getString(R.string.sending); 452 } else { 453 title = getSenderName(mSender); 454 } 455 456 return title; 457 } 458 459 private CharSequence getHeaderSubtitle() { 460 CharSequence sub; 461 if (mIsSending) { 462 sub = null; 463 } else { 464 sub = isExpanded() ? getSenderAddress(mSender) : mSnippet; 465 } 466 return sub; 467 } 468 469 /** 470 * Return the name, if known, or just the address. 471 */ 472 private static CharSequence getSenderName(Address sender) { 473 final String displayName = sender.getName(); 474 return TextUtils.isEmpty(displayName) ? sender.getAddress() : displayName; 475 } 476 477 /** 478 * Return the address, if a name is present, or null if not. 479 */ 480 private static CharSequence getSenderAddress(Address sender) { 481 String displayName = sender == null ? "" : sender.getName(); 482 return TextUtils.isEmpty(displayName) ? null : sender.getAddress(); 483 } 484 485 private void setChildVisibility(int visibility, View... children) { 486 for (View v : children) { 487 if (v != null) { 488 v.setVisibility(visibility); 489 } 490 } 491 } 492 493 private void setExpanded(final boolean expanded) { 494 // use View's 'activated' flag to store expanded state 495 // child view state lists can use this to toggle drawables 496 setActivated(expanded); 497 if (mMessageHeaderItem != null) { 498 mMessageHeaderItem.setExpanded(expanded); 499 } 500 } 501 502 /** 503 * Update the visibility of the many child views based on expanded/collapsed 504 * and draft/normal state. 505 */ 506 private void updateChildVisibility() { 507 // Too bad this can't be done with an XML state list... 508 509 if (isExpanded()) { 510 int normalVis, draftVis; 511 512 setMessageDetailsVisibility((mIsSnappy) ? GONE : VISIBLE); 513 514 if (mIsDraft) { 515 normalVis = GONE; 516 draftVis = VISIBLE; 517 } else { 518 normalVis = VISIBLE; 519 draftVis = GONE; 520 } 521 522 setReplyOrReplyAllVisible(); 523 setChildVisibility(normalVis, mPhotoView, mPhotoSpacerView, mForwardButton, 524 mSenderEmailView, mOverflowButton); 525 setChildVisibility(draftVis, mDraftIcon, mEditDraftButton); 526 setChildVisibility(GONE, mAttachmentIcon, mUpperDateView); 527 setChildVisibility(VISIBLE, mStarView); 528 529 setChildMarginRight(mTitleContainerView, 0); 530 531 } else { 532 533 setMessageDetailsVisibility(GONE); 534 setChildVisibility(VISIBLE, mSenderEmailView, mUpperDateView); 535 536 setChildVisibility(GONE, mEditDraftButton, mReplyButton, mReplyAllButton, 537 mForwardButton); 538 setChildVisibility(GONE, mOverflowButton); 539 540 setChildVisibility(mMessage.hasAttachments ? VISIBLE : GONE, 541 mAttachmentIcon); 542 543 setChildVisibility(mCollapsedStarVis, mStarView); 544 545 setChildMarginRight(mTitleContainerView, mTitleContainerCollapsedMarginRight); 546 547 if (mIsDraft) { 548 549 setChildVisibility(VISIBLE, mDraftIcon); 550 setChildVisibility(GONE, mPhotoView, mPhotoSpacerView); 551 552 } else { 553 554 setChildVisibility(GONE, mDraftIcon); 555 setChildVisibility(VISIBLE, mPhotoView, mPhotoSpacerView); 556 557 } 558 } 559 560 } 561 562 /** 563 * If an overflow menu is present in this header's layout, set the 564 * visibility of "Reply" and "Reply All" actions based on a user preference. 565 * Only one of those actions will be visible when an overflow is present. If 566 * no overflow is present (e.g. big phone or tablet), it's assumed we have 567 * plenty of screen real estate and can show both. 568 */ 569 private void setReplyOrReplyAllVisible() { 570 if (mIsDraft) { 571 setChildVisibility(GONE, mReplyButton, mReplyAllButton); 572 return; 573 } else if (mOverflowButton == null) { 574 setChildVisibility(VISIBLE, mReplyButton, mReplyAllButton); 575 return; 576 } 577 578 setChildVisibility(mDefaultReplyAll ? GONE : VISIBLE, mReplyButton); 579 setChildVisibility(mDefaultReplyAll ? VISIBLE : GONE, mReplyAllButton); 580 } 581 582 private static void setChildMarginRight(View childView, int marginRight) { 583 MarginLayoutParams mlp = (MarginLayoutParams) childView.getLayoutParams(); 584 mlp.rightMargin = marginRight; 585 childView.setLayoutParams(mlp); 586 } 587 588 private void renderEmailList(int rowRes, int valueRes, String[] emails) { 589 if (emails == null || emails.length == 0) { 590 return; 591 } 592 String[] formattedEmails = new String[emails.length]; 593 for (int i = 0; i < emails.length; i++) { 594 Address e = getAddress(emails[i]); 595 String name = e.getName(); 596 String addr = e.getAddress(); 597 if (name == null || name.length() == 0) { 598 formattedEmails[i] = addr; 599 } else { 600 formattedEmails[i] = getResources().getString(R.string.address_display_format, 601 name, addr); 602 } 603 } 604 ((TextView) findViewById(valueRes)).setText(TextUtils.join("\n", formattedEmails)); 605 findViewById(rowRes).setVisibility(VISIBLE); 606 } 607 608 @Override 609 public void setMarginBottom(int bottomMargin) { 610 MarginLayoutParams p = (MarginLayoutParams) getLayoutParams(); 611 if (p.bottomMargin != bottomMargin) { 612 p.bottomMargin = bottomMargin; 613 setLayoutParams(p); 614 } 615 } 616 617 public void setMarginTop(int topMargin) { 618 MarginLayoutParams p = (MarginLayoutParams) getLayoutParams(); 619 if (p.topMargin != topMargin) { 620 p.topMargin = topMargin; 621 setLayoutParams(p); 622 } 623 } 624 625 public void setTranslateY(int offsetY) { 626 if (mDrawTranslateY != offsetY) { 627 mDrawTranslateY = offsetY; 628 invalidate(); 629 } 630 } 631 632 /** 633 * Utility class to build a list of recipient lists. 634 */ 635 private static class RecipientListsBuilder { 636 private final Context mContext; 637 private final String mMe; 638 private final SpannableStringBuilder mBuilder = new SpannableStringBuilder(); 639 private final CharSequence mComma; 640 private final Map<String, Address> mAddressCache; 641 642 int mRecipientCount = 0; 643 boolean mFirst = true; 644 645 public RecipientListsBuilder(Context context, String me, 646 Map<String, Address> addressCache) { 647 mContext = context; 648 mMe = me; 649 mComma = mContext.getText(R.string.enumeration_comma); 650 mAddressCache = addressCache; 651 } 652 653 public void append(String[] recipients, int headingRes) { 654 int addLimit = SUMMARY_MAX_RECIPIENTS - mRecipientCount; 655 CharSequence recipientList = getSummaryTextForHeading(headingRes, recipients, addLimit); 656 if (recipientList != null) { 657 // duplicate TextUtils.join() logic to minimize temporary 658 // allocations, and because we need to support spans 659 if (mFirst) { 660 mFirst = false; 661 } else { 662 mBuilder.append(RECIPIENT_HEADING_DELIMITER); 663 } 664 mBuilder.append(recipientList); 665 mRecipientCount += Math.min(addLimit, recipients.length); 666 } 667 } 668 669 private CharSequence getSummaryTextForHeading(int headingStrRes, String[] rawAddrs, 670 int maxToCopy) { 671 if (rawAddrs == null || rawAddrs.length == 0 || maxToCopy == 0) { 672 return null; 673 } 674 675 SpannableStringBuilder ssb = new SpannableStringBuilder( 676 mContext.getString(headingStrRes)); 677 ssb.setSpan(new StyleSpan(Typeface.BOLD), 0, ssb.length(), 678 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 679 ssb.append(' '); 680 681 final int len = Math.min(maxToCopy, rawAddrs.length); 682 boolean first = true; 683 for (int i = 0; i < len; i++) { 684 Address email = getAddress(mAddressCache, rawAddrs[i]); 685 String name = (mMe.equals(email.getAddress())) ? mContext.getString(R.string.me) 686 : email.getSimplifiedName(); 687 688 // duplicate TextUtils.join() logic to minimize temporary 689 // allocations, and because we need to support spans 690 if (first) { 691 first = false; 692 } else { 693 ssb.append(mComma); 694 } 695 ssb.append(name); 696 } 697 698 return ssb; 699 } 700 701 public CharSequence build() { 702 return mBuilder; 703 } 704 } 705 706 @VisibleForTesting 707 static CharSequence getRecipientSummaryText(Context context, String me, String[] to, 708 String[] cc, String[] bcc, Map<String, Address> addressCache) { 709 710 RecipientListsBuilder builder = new RecipientListsBuilder(context, me, addressCache); 711 712 builder.append(to, R.string.to_heading); 713 builder.append(cc, R.string.cc_heading); 714 builder.append(bcc, R.string.bcc_heading); 715 716 return builder.build(); 717 } 718 719 @Override 720 public void updateContactInfo() { 721 722 mPresenceView.setImageDrawable(null); 723 mPresenceView.setVisibility(GONE); 724 if (mContactInfoSource == null || mSender == null) { 725 mPhotoView.setImageToDefault(); 726 mPhotoView.setContentDescription(getResources().getString( 727 R.string.contact_info_string_default)); 728 return; 729 } 730 731 // Set the photo to either a found Bitmap or the default 732 // and ensure either the contact URI or email is set so the click 733 // handling works 734 String contentDesc = getResources().getString(R.string.contact_info_string, 735 !TextUtils.isEmpty(mSender.getName()) ? mSender.getName() : mSender.getAddress()); 736 mPhotoView.setContentDescription(contentDesc); 737 boolean photoSet = false; 738 String email = mSender.getAddress(); 739 ContactInfo info = mContactInfoSource.getContactInfo(email); 740 if (info != null) { 741 mPhotoView.assignContactUri(info.contactUri); 742 if (info.photo != null) { 743 mPhotoView.setImageBitmap(info.photo); 744 contentDesc = String.format(contentDesc, mSender.getName()); 745 photoSet = true; 746 } 747 if (!mIsDraft && info.status != null) { 748 mPresenceView.setImageResource(ContactsContract.StatusUpdates 749 .getPresenceIconResourceId(info.status)); 750 mPresenceView.setVisibility(VISIBLE); 751 } 752 } else { 753 mPhotoView.assignContactFromEmail(email, true /* lazyLookup */); 754 } 755 756 if (!photoSet) { 757 mPhotoView.setImageToDefault(); 758 } 759 } 760 761 762 @Override 763 public boolean onMenuItemClick(MenuItem item) { 764 mPopup.dismiss(); 765 return onClick(null, item.getItemId()); 766 } 767 768 @Override 769 public void onClick(View v) { 770 onClick(v, v.getId()); 771 } 772 773 /** 774 * Handles clicks on either views or menu items. View parameter can be null 775 * for menu item clicks. 776 */ 777 public boolean onClick(View v, int id) { 778 boolean handled = true; 779 780 switch (id) { 781 case R.id.reply: 782 ComposeActivity.reply(getContext(), mAccount, mMessage); 783 break; 784 case R.id.reply_all: 785 ComposeActivity.replyAll(getContext(), mAccount, mMessage); 786 break; 787 case R.id.forward: 788 ComposeActivity.forward(getContext(), mAccount, mMessage); 789 break; 790 case R.id.star: { 791 final boolean newValue = !v.isSelected(); 792 v.setSelected(newValue); 793 mMessage.star(newValue, getQueryHandler(), 0 /* token */, null /* cookie */); 794 // TODO: propagate the change to the entry in conversation list 795 break; 796 } 797 case R.id.edit_draft: 798 ComposeActivity.editDraft(getContext(), mAccount, mMessage); 799 break; 800 case R.id.overflow: { 801 if (mPopup == null) { 802 mPopup = new PopupMenu(getContext(), v); 803 mPopup.getMenuInflater().inflate(R.menu.message_header_overflow_menu, 804 mPopup.getMenu()); 805 mPopup.setOnMenuItemClickListener(this); 806 } 807 mPopup.getMenu().findItem(R.id.reply).setVisible(mDefaultReplyAll); 808 mPopup.getMenu().findItem(R.id.reply_all).setVisible(!mDefaultReplyAll); 809 810 mPopup.show(); 811 break; 812 } 813 case R.id.details_collapsed_content: 814 case R.id.details_expanded_content: 815 toggleMessageDetails(v); 816 break; 817 case R.id.upper_header: 818 toggleExpanded(); 819 break; 820 case R.id.show_pictures: 821 handleShowImagePromptClick(v); 822 break; 823 default: 824 LogUtils.i(LOG_TAG, "unrecognized header tap: %d", id); 825 handled = false; 826 break; 827 } 828 return handled; 829 } 830 831 public void toggleExpanded() { 832 if (mIsSnappy) { 833 // In addition to making the snappy header disappear, this will 834 // propagate the change to the normal header. It should only be 835 // possible to collapse an expanded snappy header; collapsed snappy 836 // headers should never exist. 837 838 // TODO: make this work right. the scroll position jumps and the 839 // snappy header doesn't re-appear bound to a subsequent message. 840 // mCallbacks.setMessageExpanded(mLocalMessageId, mServerMessageId, 841 // false); 842 // setVisibility(GONE); 843 // unbind(); 844 return; 845 } 846 847 setExpanded(!isExpanded()); 848 849 mSenderNameView.setText(getHeaderTitle()); 850 mSenderEmailView.setText(getHeaderSubtitle()); 851 852 updateChildVisibility(); 853 854 // Force-measure the new header height so we can set the spacer size and 855 // reveal the message 856 // div in one pass. Force-measuring makes it unnecessary to set 857 // mSizeChanged. 858 int h = measureHeight(); 859 mMessageHeaderItem.setHeight(h); 860 if (mCallbacks != null) { 861 mCallbacks.setMessageExpanded(mMessageHeaderItem, h); 862 } 863 } 864 865 private void toggleMessageDetails(View visibleDetailsView) { 866 final boolean detailsExpanded = (visibleDetailsView == mCollapsedDetailsView); 867 setMessageDetailsExpanded(detailsExpanded); 868 updateSpacerHeight(); 869 } 870 871 private void setMessageDetailsExpanded(boolean expand) { 872 if (expand) { 873 showExpandedDetails(); 874 hideCollapsedDetails(); 875 } else { 876 hideExpandedDetails(); 877 showCollapsedDetails(); 878 } 879 if (mMessageHeaderItem != null) { 880 mMessageHeaderItem.detailsExpanded = expand; 881 } 882 } 883 884 public void setMessageDetailsVisibility(int vis) { 885 if (vis == GONE) { 886 hideCollapsedDetails(); 887 hideExpandedDetails(); 888 hideShowImagePrompt(); 889 } else { 890 setMessageDetailsExpanded(mMessageHeaderItem.detailsExpanded); 891 if (mShowImagePrompt) { 892 showImagePrompt(); 893 } else { 894 hideShowImagePrompt(); 895 } 896 } 897 if (mBottomBorderView != null) { 898 mBottomBorderView.setVisibility(vis); 899 } 900 } 901 902 public void hideMessageDetails() { 903 setMessageDetailsVisibility(GONE); 904 } 905 906 @Override 907 public void setStarDisplay(boolean starred) { 908 if (mStarView.isSelected() != starred) { 909 mStarView.setSelected(starred); 910 } 911 } 912 913 private void hideCollapsedDetails() { 914 if (mCollapsedDetailsView != null) { 915 mCollapsedDetailsView.setVisibility(GONE); 916 } 917 } 918 919 private void hideExpandedDetails() { 920 if (mExpandedDetailsView != null) { 921 mExpandedDetailsView.setVisibility(GONE); 922 } 923 } 924 925 private void hideShowImagePrompt() { 926 if (mImagePromptView != null) { 927 mImagePromptView.setVisibility(GONE); 928 } 929 } 930 931 private void showImagePrompt() { 932 if (mImagePromptView == null) { 933 ViewGroup v = (ViewGroup) mInflater.inflate(R.layout.conversation_message_show_pics, 934 this, false); 935 addView(v); 936 v.setOnClickListener(this); 937 v.setTag(SHOW_IMAGE_PROMPT_ONCE); 938 939 mImagePromptView = v; 940 } 941 mImagePromptView.setVisibility(VISIBLE); 942 } 943 944 private void handleShowImagePromptClick(View v) { 945 Integer state = (Integer) v.getTag(); 946 if (state == null) { 947 return; 948 } 949 switch (state) { 950 case SHOW_IMAGE_PROMPT_ONCE: 951 if (mCallbacks != null) { 952 mCallbacks.showExternalResources(mMessage); 953 } 954 ImageView descriptionViewIcon = (ImageView) v.findViewById(R.id.show_pictures_icon); 955 descriptionViewIcon.setContentDescription(getResources().getString( 956 R.string.always_show_images)); 957 TextView descriptionView = (TextView) v.findViewById(R.id.show_pictures_text); 958 descriptionView.setText(R.string.always_show_images); 959 v.setTag(SHOW_IMAGE_PROMPT_ALWAYS); 960 // the new text's line count may differ, so update the spacer height 961 updateSpacerHeight(); 962 break; 963 case SHOW_IMAGE_PROMPT_ALWAYS: 964 mMessage.markAlwaysShowImages(getQueryHandler(), 0 /* token */, null /* cookie */); 965 966 mShowImagePrompt = false; 967 v.setTag(null); 968 v.setVisibility(GONE); 969 updateSpacerHeight(); 970 Toast.makeText(getContext(), R.string.always_show_images_toast, Toast.LENGTH_SHORT) 971 .show(); 972 break; 973 } 974 } 975 976 private AsyncQueryHandler getQueryHandler() { 977 if (mQueryHandler == null) { 978 mQueryHandler = new AsyncQueryHandler(getContext().getContentResolver()) {}; 979 } 980 return mQueryHandler; 981 } 982 983 /** 984 * Makes collapsed details visible. If necessary, will inflate details 985 * layout and render using saved-off state (senders, timestamp, etc). 986 */ 987 private void showCollapsedDetails() { 988 if (mCollapsedDetailsView == null) { 989 mCollapsedDetailsView = (ViewGroup) mInflater.inflate( 990 R.layout.conversation_message_details_header, this, false); 991 addView(mCollapsedDetailsView, indexOfChild(mUpperHeaderView) + 1); 992 mCollapsedDetailsView.setOnClickListener(this); 993 } 994 if (!mCollapsedDetailsValid) { 995 if (mMessageHeaderItem.recipientSummaryText == null) { 996 mMessageHeaderItem.recipientSummaryText = getRecipientSummaryText(getContext(), 997 mAccount.name, mTo, mCc, mBcc, mAddressCache); 998 } 999 ((TextView) findViewById(R.id.recipients_summary)) 1000 .setText(mMessageHeaderItem.recipientSummaryText); 1001 1002 ((TextView) findViewById(R.id.date_summary)).setText(mTimestampShort); 1003 1004 mCollapsedDetailsValid = true; 1005 } 1006 mCollapsedDetailsView.setVisibility(VISIBLE); 1007 } 1008 1009 /** 1010 * Makes expanded details visible. If necessary, will inflate expanded 1011 * details layout and render using saved-off state (senders, timestamp, 1012 * etc). 1013 */ 1014 private void showExpandedDetails() { 1015 // lazily create expanded details view 1016 if (mExpandedDetailsView == null) { 1017 View v = mInflater.inflate(R.layout.conversation_message_details_header_expanded, 1018 this, false); 1019 addView(v, indexOfChild(mUpperHeaderView) + 1); 1020 v.setOnClickListener(this); 1021 1022 mExpandedDetailsView = (ViewGroup) v; 1023 } 1024 if (!mExpandedDetailsValid) { 1025 if (mMessageHeaderItem.timestampLong == null) { 1026 mMessageHeaderItem.timestampLong = mDateBuilder.formatLongDateTime(mTimestampMs); 1027 } 1028 ((TextView) findViewById(R.id.date_value)).setText(mMessageHeaderItem.timestampLong); 1029 renderEmailList(R.id.replyto_row, R.id.replyto_value, mReplyTo); 1030 renderEmailList(R.id.to_row, R.id.to_value, mTo); 1031 renderEmailList(R.id.cc_row, R.id.cc_value, mCc); 1032 renderEmailList(R.id.bcc_row, R.id.bcc_value, mBcc); 1033 1034 mExpandedDetailsValid = true; 1035 } 1036 mExpandedDetailsView.setVisibility(VISIBLE); 1037 } 1038 1039 /** 1040 * Returns a short plaintext snippet generated from the given HTML message 1041 * body. Collapses whitespace, ignores '<' and '>' characters and 1042 * everything in between, and truncates the snippet to no more than 100 1043 * characters. 1044 * 1045 * @return Short plaintext snippet 1046 */ 1047 @VisibleForTesting 1048 static String makeSnippet(final String messageBody) { 1049 StringBuilder snippet = new StringBuilder(MAX_SNIPPET_LENGTH); 1050 1051 StringReader reader = new StringReader(messageBody); 1052 try { 1053 int c; 1054 while ((c = reader.read()) != -1 && snippet.length() < MAX_SNIPPET_LENGTH) { 1055 // Collapse whitespace. 1056 if (Character.isWhitespace(c)) { 1057 snippet.append(' '); 1058 do { 1059 c = reader.read(); 1060 } while (Character.isWhitespace(c)); 1061 if (c == -1) { 1062 break; 1063 } 1064 } 1065 1066 if (c == '<') { 1067 // Ignore everything up to and including the next '>' 1068 // character. 1069 while ((c = reader.read()) != -1) { 1070 if (c == '>') { 1071 break; 1072 } 1073 } 1074 1075 // If we reached the end of the message body, exit. 1076 if (c == -1) { 1077 break; 1078 } 1079 } else if (c == '&') { 1080 // Read HTML entity. 1081 StringBuilder sb = new StringBuilder(); 1082 1083 while ((c = reader.read()) != -1) { 1084 if (c == ';') { 1085 break; 1086 } 1087 sb.append((char) c); 1088 } 1089 1090 String entity = sb.toString(); 1091 if ("nbsp".equals(entity)) { 1092 snippet.append(' '); 1093 } else if ("lt".equals(entity)) { 1094 snippet.append('<'); 1095 } else if ("gt".equals(entity)) { 1096 snippet.append('>'); 1097 } else if ("amp".equals(entity)) { 1098 snippet.append('&'); 1099 } else if ("quot".equals(entity)) { 1100 snippet.append('"'); 1101 } else if ("apos".equals(entity) || "#39".equals(entity)) { 1102 snippet.append('\''); 1103 } else { 1104 // Unknown entity; just append the literal string. 1105 snippet.append('&').append(entity); 1106 if (c == ';') { 1107 snippet.append(';'); 1108 } 1109 } 1110 1111 // If we reached the end of the message body, exit. 1112 if (c == -1) { 1113 break; 1114 } 1115 } else { 1116 // The current character is a non-whitespace character that 1117 // isn't inside some 1118 // HTML tag and is not part of an HTML entity. 1119 snippet.append((char) c); 1120 } 1121 } 1122 } catch (IOException e) { 1123 LogUtils.wtf(LOG_TAG, e, "Really? IOException while reading a freaking string?!? "); 1124 } 1125 1126 return snippet.toString(); 1127 } 1128 1129 @Override 1130 public void dispatchDraw(Canvas canvas) { 1131 boolean transform = mIsSnappy && (mDrawTranslateY != 0); 1132 int saved = -1; 1133 if (transform) { 1134 saved = canvas.save(); 1135 canvas.translate(0, mDrawTranslateY); 1136 } 1137 super.dispatchDraw(canvas); 1138 if (transform) { 1139 canvas.restoreToCount(saved); 1140 } 1141 } 1142 1143 @Override 1144 protected void onLayout(boolean changed, int l, int t, int r, int b) { 1145 Timer perf = new Timer(); 1146 perf.start(LAYOUT_TAG); 1147 super.onLayout(changed, l, t, r, b); 1148 perf.pause(LAYOUT_TAG); 1149 } 1150 1151 @Override 1152 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1153 Timer t = new Timer(); 1154 if (Timer.ENABLE_TIMER && !mPreMeasuring) { 1155 t.count("header measure id=" + mMessage.id); 1156 t.start(MEASURE_TAG); 1157 } 1158 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1159 if (!mPreMeasuring) { 1160 t.pause(MEASURE_TAG); 1161 } 1162 } 1163 1164} 1165