1 /* 2 * Copyright (C) 2009 The Android Open Source Project 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.email.activity; 18 19 import android.content.Context; 20 import android.content.res.Configuration; 21 import android.content.res.Resources; 22 import android.graphics.Bitmap; 23 import android.graphics.BitmapFactory; 24 import android.graphics.Canvas; 25 import android.graphics.Paint; 26 import android.graphics.Typeface; 27 import android.graphics.drawable.Drawable; 28 import android.text.Layout.Alignment; 29 import android.text.Spannable; 30 import android.text.SpannableString; 31 import android.text.SpannableStringBuilder; 32 import android.text.StaticLayout; 33 import android.text.TextPaint; 34 import android.text.TextUtils; 35 import android.text.TextUtils.TruncateAt; 36 import android.text.format.DateUtils; 37 import android.text.style.ForegroundColorSpan; 38 import android.text.style.StyleSpan; 39 import android.util.AttributeSet; 40 import android.view.MotionEvent; 41 import android.view.View; 42 import android.view.accessibility.AccessibilityEvent; 43 44 import com.android.email.R; 45 import com.android.emailcommon.utility.TextUtilities; 46 import com.google.common.base.Objects; 47 48 /** 49 * This custom View is the list item for the MessageList activity, and serves two purposes: 50 * 1. It's a container to store message metadata (e.g. the ids of the message, mailbox, & account) 51 * 2. It handles internal clicks such as the checkbox or the favorite star 52 */ 53 public class MessageListItem extends View { 54 // Note: messagesAdapter directly fiddles with these fields. 55 /* package */ long mMessageId; 56 /* package */ long mMailboxId; 57 /* package */ long mAccountId; 58 59 private ThreePaneLayout mLayout; 60 private MessagesAdapter mAdapter; 61 private MessageListItemCoordinates mCoordinates; 62 private Context mContext; 63 private boolean mIsSearchResult = false; 64 65 private boolean mDownEvent; 66 67 public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL = 68 "com.android.email.MESSAGE_LIST_ITEMS"; 69 70 public MessageListItem(Context context) { 71 super(context); 72 init(context); 73 } 74 75 public MessageListItem(Context context, AttributeSet attrs) { 76 super(context, attrs); 77 init(context); 78 } 79 80 public MessageListItem(Context context, AttributeSet attrs, int defStyle) { 81 super(context, attrs, defStyle); 82 init(context); 83 } 84 85 // Wide mode shows sender, snippet, time, and favorite spread out across the screen 86 private static final int MODE_WIDE = MessageListItemCoordinates.WIDE_MODE; 87 // Sentinel indicating that the view needs layout 88 public static final int NEEDS_LAYOUT = -1; 89 90 private static boolean sInit = false; 91 private static final TextPaint sDefaultPaint = new TextPaint(); 92 private static final TextPaint sBoldPaint = new TextPaint(); 93 private static final TextPaint sDatePaint = new TextPaint(); 94 private static Bitmap sAttachmentIcon; 95 private static Bitmap sInviteIcon; 96 private static int sBadgeMargin; 97 private static Bitmap sFavoriteIconOff; 98 private static Bitmap sFavoriteIconOn; 99 private static Bitmap sSelectedIconOn; 100 private static Bitmap sSelectedIconOff; 101 private static Bitmap sStateReplied; 102 private static Bitmap sStateForwarded; 103 private static Bitmap sStateRepliedAndForwarded; 104 private static String sSubjectSnippetDivider; 105 private static String sSubjectDescription; 106 private static String sSubjectEmptyDescription; 107 108 // Static colors. 109 private static int DEFAULT_TEXT_COLOR; 110 private static int ACTIVATED_TEXT_COLOR; 111 private static int LIGHT_TEXT_COLOR; 112 private static int DRAFT_TEXT_COLOR; 113 private static int SUBJECT_TEXT_COLOR_READ; 114 private static int SUBJECT_TEXT_COLOR_UNREAD; 115 private static int SNIPPET_TEXT_COLOR_READ; 116 private static int SNIPPET_TEXT_COLOR_UNREAD; 117 private static int SENDERS_TEXT_COLOR_READ; 118 private static int SENDERS_TEXT_COLOR_UNREAD; 119 private static int DATE_TEXT_COLOR_READ; 120 private static int DATE_TEXT_COLOR_UNREAD; 121 122 public String mSender; 123 public SpannableStringBuilder mText; 124 public CharSequence mSnippet; 125 private String mSubject; 126 private StaticLayout mSubjectLayout; 127 public boolean mRead; 128 public boolean mHasAttachment = false; 129 public boolean mHasInvite = true; 130 public boolean mIsFavorite = false; 131 public boolean mHasBeenRepliedTo = false; 132 public boolean mHasBeenForwarded = false; 133 /** {@link Paint} for account color chips. null if no chips should be drawn. */ 134 public Paint mColorChipPaint; 135 136 private int mMode = -1; 137 138 private int mViewWidth = 0; 139 private int mViewHeight = 0; 140 141 private static int sItemHeightWide; 142 private static int sItemHeightNormal; 143 144 // Note: these cannot be shared Drawables because they are selectors which have state. 145 private Drawable mReadSelector; 146 private Drawable mUnreadSelector; 147 private Drawable mWideReadSelector; 148 private Drawable mWideUnreadSelector; 149 150 private CharSequence mFormattedSender; 151 // We must initialize this to something, in case the timestamp of the message is zero (which 152 // should be very rare); this is otherwise set in setTimestamp 153 private CharSequence mFormattedDate = ""; 154 155 private void init(Context context) { 156 mContext = context; 157 if (!sInit) { 158 Resources r = context.getResources(); 159 sSubjectDescription = r.getString(R.string.message_subject_description).concat(", "); 160 sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description); 161 sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider); 162 sItemHeightWide = 163 r.getDimensionPixelSize(R.dimen.message_list_item_height_wide); 164 sItemHeightNormal = 165 r.getDimensionPixelSize(R.dimen.message_list_item_height_normal); 166 167 sDefaultPaint.setTypeface(Typeface.DEFAULT); 168 sDefaultPaint.setAntiAlias(true); 169 sDatePaint.setTypeface(Typeface.DEFAULT); 170 sDatePaint.setAntiAlias(true); 171 sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD); 172 sBoldPaint.setAntiAlias(true); 173 174 sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment); 175 sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite_holo_light); 176 sBadgeMargin = r.getDimensionPixelSize(R.dimen.message_list_badge_margin); 177 sFavoriteIconOff = 178 BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light); 179 sFavoriteIconOn = 180 BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light); 181 sSelectedIconOff = 182 BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light); 183 sSelectedIconOn = 184 BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light); 185 186 sStateReplied = 187 BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_holo_light); 188 sStateForwarded = 189 BitmapFactory.decodeResource(r, R.drawable.ic_badge_forward_holo_light); 190 sStateRepliedAndForwarded = 191 BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_forward_holo_light); 192 193 DEFAULT_TEXT_COLOR = r.getColor(R.color.default_text_color); 194 ACTIVATED_TEXT_COLOR = r.getColor(android.R.color.white); 195 SUBJECT_TEXT_COLOR_READ = r.getColor(R.color.subject_text_color_read); 196 SUBJECT_TEXT_COLOR_UNREAD = r.getColor(R.color.subject_text_color_unread); 197 SNIPPET_TEXT_COLOR_READ = r.getColor(R.color.snippet_text_color_read); 198 SNIPPET_TEXT_COLOR_UNREAD = r.getColor(R.color.snippet_text_color_unread); 199 SENDERS_TEXT_COLOR_READ = r.getColor(R.color.senders_text_color_read); 200 SENDERS_TEXT_COLOR_UNREAD = r.getColor(R.color.senders_text_color_unread); 201 DATE_TEXT_COLOR_READ = r.getColor(R.color.date_text_color_read); 202 DATE_TEXT_COLOR_UNREAD = r.getColor(R.color.date_text_color_unread); 203 204 sInit = true; 205 } 206 } 207 208 /** 209 * Invalidate all drawing caches associated with drawing message list items. 210 * This is an expensive operation, and should be done rarely, such as when system font size 211 * changes occurs. 212 */ 213 public static void resetDrawingCaches() { 214 MessageListItemCoordinates.resetCaches(); 215 sInit = false; 216 } 217 218 /** 219 * Sets message subject and snippet safely, ensuring the cache is invalidated. 220 */ 221 public void setText(String subject, String snippet, boolean forceUpdate) { 222 boolean changed = false; 223 if (!Objects.equal(mSubject, subject)) { 224 mSubject = subject; 225 changed = true; 226 populateContentDescription(); 227 } 228 229 if (!Objects.equal(mSnippet, snippet)) { 230 mSnippet = snippet; 231 changed = true; 232 } 233 234 if (forceUpdate || changed || (mSubject == null && mSnippet == null) /* first time */) { 235 SpannableStringBuilder ssb = new SpannableStringBuilder(); 236 boolean hasSubject = false; 237 if (!TextUtils.isEmpty(mSubject)) { 238 SpannableString ss = new SpannableString(mSubject); 239 ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), 240 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 241 ssb.append(ss); 242 hasSubject = true; 243 } 244 if (!TextUtils.isEmpty(mSnippet)) { 245 if (hasSubject) { 246 ssb.append(sSubjectSnippetDivider); 247 } 248 ssb.append(mSnippet); 249 } 250 mText = ssb; 251 requestLayout(); 252 } 253 } 254 255 long mTimeFormatted = 0; 256 257 public void setTimestamp(long timestamp) { 258 if (mTimeFormatted != timestamp) { 259 mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); 260 mTimeFormatted = timestamp; 261 } 262 } 263 264 /** 265 * Determine the mode of this view (WIDE or NORMAL) 266 * 267 * @param width The width of the view 268 * @return The mode of the view 269 */ 270 private int getViewMode(int width) { 271 return MessageListItemCoordinates.getMode(mContext, width, mIsSearchResult); 272 } 273 274 private Drawable mCurentBackground = null; // Only used by updateBackground() 275 276 private void updateBackground() { 277 final Drawable newBackground; 278 boolean isMultiPane = MessageListItemCoordinates.isMultiPane(mContext); 279 if (mRead) { 280 if (isMultiPane && mLayout.isLeftPaneVisible()) { 281 if (mWideReadSelector == null) { 282 mWideReadSelector = getContext().getResources() 283 .getDrawable(R.drawable.conversation_wide_read_selector); 284 } 285 newBackground = mWideReadSelector; 286 } else { 287 if (mReadSelector == null) { 288 mReadSelector = getContext().getResources() 289 .getDrawable(R.drawable.conversation_read_selector); 290 } 291 newBackground = mReadSelector; 292 } 293 } else { 294 if (isMultiPane && mLayout.isLeftPaneVisible()) { 295 if (mWideUnreadSelector == null) { 296 mWideUnreadSelector = getContext().getResources().getDrawable( 297 R.drawable.conversation_wide_unread_selector); 298 } 299 newBackground = mWideUnreadSelector; 300 } else { 301 if (mUnreadSelector == null) { 302 mUnreadSelector = getContext().getResources() 303 .getDrawable(R.drawable.conversation_unread_selector); 304 } 305 newBackground = mUnreadSelector; 306 } 307 } 308 if (newBackground != mCurentBackground) { 309 // setBackgroundDrawable is a heavy operation. Only call it when really needed. 310 setBackgroundDrawable(newBackground); 311 mCurentBackground = newBackground; 312 } 313 } 314 315 private void calculateSubjectText() { 316 if (mText == null || mText.length() == 0) { 317 return; 318 } 319 boolean hasSubject = false; 320 int snippetStart = 0; 321 if (!TextUtils.isEmpty(mSubject)) { 322 int subjectColor = getFontColor(mRead ? SUBJECT_TEXT_COLOR_READ 323 : SUBJECT_TEXT_COLOR_UNREAD); 324 mText.setSpan(new ForegroundColorSpan(subjectColor), 0, mSubject.length(), 325 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 326 snippetStart = mSubject.length() + 1; 327 } 328 if (!TextUtils.isEmpty(mSnippet)) { 329 int snippetColor = getFontColor(mRead ? SNIPPET_TEXT_COLOR_READ 330 : SNIPPET_TEXT_COLOR_UNREAD); 331 mText.setSpan(new ForegroundColorSpan(snippetColor), snippetStart, mText.length(), 332 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 333 } 334 } 335 336 private void calculateDrawingData() { 337 sDefaultPaint.setTextSize(mCoordinates.subjectFontSize); 338 calculateSubjectText(); 339 mSubjectLayout = new StaticLayout(mText, sDefaultPaint, 340 mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */); 341 if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) { 342 // TODO: ellipsize. 343 int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1); 344 mSubjectLayout = new StaticLayout(mText.subSequence(0, end), 345 sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 346 } 347 348 // Now, format the sender for its width 349 TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint; 350 // And get the ellipsized string for the calculated width 351 if (TextUtils.isEmpty(mSender)) { 352 mFormattedSender = ""; 353 } else { 354 int senderWidth = mCoordinates.sendersWidth; 355 senderPaint.setTextSize(mCoordinates.sendersFontSize); 356 senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ 357 : SENDERS_TEXT_COLOR_UNREAD)); 358 mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth, 359 TruncateAt.END); 360 } 361 } 362 @Override 363 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 364 if (widthMeasureSpec != 0 || mViewWidth == 0) { 365 mViewWidth = MeasureSpec.getSize(widthMeasureSpec); 366 int mode = getViewMode(mViewWidth); 367 if (mode != mMode) { 368 mMode = mode; 369 } 370 mViewHeight = measureHeight(heightMeasureSpec, mMode); 371 } 372 setMeasuredDimension(mViewWidth, mViewHeight); 373 } 374 375 /** 376 * Determine the height of this view 377 * 378 * @param measureSpec A measureSpec packed into an int 379 * @param mode The current mode of this view 380 * @return The height of the view, honoring constraints from measureSpec 381 */ 382 private int measureHeight(int measureSpec, int mode) { 383 int result = 0; 384 int specMode = MeasureSpec.getMode(measureSpec); 385 int specSize = MeasureSpec.getSize(measureSpec); 386 387 if (specMode == MeasureSpec.EXACTLY) { 388 // We were told how big to be 389 result = specSize; 390 } else { 391 // Measure the text 392 if (mMode == MODE_WIDE) { 393 result = sItemHeightWide; 394 } else { 395 result = sItemHeightNormal; 396 } 397 if (specMode == MeasureSpec.AT_MOST) { 398 // Respect AT_MOST value if that was what is called for by 399 // measureSpec 400 result = Math.min(result, specSize); 401 } 402 } 403 return result; 404 } 405 406 @Override 407 public void draw(Canvas canvas) { 408 // Update the background, before View.draw() draws it. 409 setSelected(mAdapter.isSelected(this)); 410 updateBackground(); 411 super.draw(canvas); 412 } 413 414 @Override 415 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 416 super.onLayout(changed, left, top, right, bottom); 417 418 mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth, mIsSearchResult); 419 calculateDrawingData(); 420 } 421 422 private int getFontColor(int defaultColor) { 423 return isActivated() && MessageListItemCoordinates.isMultiPane(mContext) ? 424 ACTIVATED_TEXT_COLOR : defaultColor; 425 } 426 427 @Override 428 protected void onDraw(Canvas canvas) { 429 // Draw the color chip indicating the mailbox this belongs to 430 if (mColorChipPaint != null) { 431 canvas.drawRect( 432 mCoordinates.chipX, mCoordinates.chipY, 433 mCoordinates.chipX + mCoordinates.chipWidth, 434 mCoordinates.chipY + mCoordinates.chipHeight, 435 mColorChipPaint); 436 } 437 438 // Draw the checkbox 439 canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff, 440 mCoordinates.checkmarkX, mCoordinates.checkmarkY, null); 441 442 // Draw the sender name 443 Paint senderPaint = mRead ? sDefaultPaint : sBoldPaint; 444 senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ 445 : SENDERS_TEXT_COLOR_UNREAD)); 446 senderPaint.setTextSize(mCoordinates.sendersFontSize); 447 canvas.drawText(mFormattedSender, 0, mFormattedSender.length(), 448 mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent, 449 senderPaint); 450 451 // Draw the reply state. Draw nothing if neither replied nor forwarded. 452 if (mHasBeenRepliedTo && mHasBeenForwarded) { 453 canvas.drawBitmap(sStateRepliedAndForwarded, 454 mCoordinates.stateX, mCoordinates.stateY, null); 455 } else if (mHasBeenRepliedTo) { 456 canvas.drawBitmap(sStateReplied, 457 mCoordinates.stateX, mCoordinates.stateY, null); 458 } else if (mHasBeenForwarded) { 459 canvas.drawBitmap(sStateForwarded, 460 mCoordinates.stateX, mCoordinates.stateY, null); 461 } 462 463 // Subject and snippet. 464 sDefaultPaint.setTextSize(mCoordinates.subjectFontSize); 465 canvas.save(); 466 canvas.translate( 467 mCoordinates.subjectX, 468 mCoordinates.subjectY); 469 mSubjectLayout.draw(canvas); 470 canvas.restore(); 471 472 // Draw the date 473 sDatePaint.setTextSize(mCoordinates.dateFontSize); 474 sDatePaint.setColor(mRead ? DATE_TEXT_COLOR_READ : DATE_TEXT_COLOR_UNREAD); 475 int dateX = mCoordinates.dateXEnd 476 - (int) sDatePaint.measureText(mFormattedDate, 0, mFormattedDate.length()); 477 478 canvas.drawText(mFormattedDate, 0, mFormattedDate.length(), 479 dateX, mCoordinates.dateY - mCoordinates.dateAscent, sDatePaint); 480 481 // Draw the favorite icon 482 canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff, 483 mCoordinates.starX, mCoordinates.starY, null); 484 485 // TODO: deal with the icon layouts better from the coordinate class so that this logic 486 // doesn't have to exist. 487 // Draw the attachment and invite icons, if necessary. 488 int iconsLeft = dateX - sBadgeMargin; 489 if (mHasAttachment) { 490 iconsLeft = iconsLeft - sAttachmentIcon.getWidth(); 491 canvas.drawBitmap(sAttachmentIcon, iconsLeft, mCoordinates.paperclipY, null); 492 } 493 if (mHasInvite) { 494 iconsLeft -= sInviteIcon.getWidth(); 495 canvas.drawBitmap(sInviteIcon, iconsLeft, mCoordinates.paperclipY, null); 496 } 497 498 } 499 500 /** 501 * Called by the adapter at bindView() time 502 * 503 * @param adapter the adapter that creates this view 504 * @param layout If this is a three pane implementation, the 505 * ThreePaneLayout. Otherwise, null. 506 */ 507 public void bindViewInit(MessagesAdapter adapter, ThreePaneLayout layout, 508 boolean isSearchResult) { 509 mLayout = layout; 510 mAdapter = adapter; 511 mIsSearchResult = isSearchResult; 512 requestLayout(); 513 } 514 515 private static final int TOUCH_SLOP = 24; 516 private static int sScaledTouchSlop = -1; 517 518 private void initializeSlop(Context context) { 519 if (sScaledTouchSlop == -1) { 520 final Resources res = context.getResources(); 521 final Configuration config = res.getConfiguration(); 522 final float density = res.getDisplayMetrics().density; 523 final float sizeAndDensity; 524 if (config.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_XLARGE)) { 525 sizeAndDensity = density * 1.5f; 526 } else { 527 sizeAndDensity = density; 528 } 529 sScaledTouchSlop = (int) (sizeAndDensity * TOUCH_SLOP + 0.5f); 530 } 531 } 532 533 /** 534 * Overriding this method allows us to "catch" clicks in the checkbox or star 535 * and process them accordingly. 536 */ 537 @Override 538 public boolean onTouchEvent(MotionEvent event) { 539 initializeSlop(getContext()); 540 541 boolean handled = false; 542 int touchX = (int) event.getX(); 543 int checkRight = mCoordinates.checkmarkX 544 + mCoordinates.checkmarkWidthIncludingMargins + sScaledTouchSlop; 545 int starLeft = mCoordinates.starX - sScaledTouchSlop; 546 547 switch (event.getAction()) { 548 case MotionEvent.ACTION_DOWN: 549 if (touchX < checkRight || touchX > starLeft) { 550 mDownEvent = true; 551 if ((touchX < checkRight) || (touchX > starLeft)) { 552 handled = true; 553 } 554 } 555 break; 556 557 case MotionEvent.ACTION_CANCEL: 558 mDownEvent = false; 559 break; 560 561 case MotionEvent.ACTION_UP: 562 if (mDownEvent) { 563 if (touchX < checkRight) { 564 mAdapter.toggleSelected(this); 565 handled = true; 566 } else if (touchX > starLeft) { 567 mIsFavorite = !mIsFavorite; 568 mAdapter.updateFavorite(this, mIsFavorite); 569 handled = true; 570 } 571 } 572 break; 573 } 574 575 if (handled) { 576 invalidate(); 577 } else { 578 handled = super.onTouchEvent(event); 579 } 580 581 return handled; 582 } 583 584 @Override 585 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 586 event.setClassName(getClass().getName()); 587 event.setPackageName(getContext().getPackageName()); 588 event.setEnabled(true); 589 event.setContentDescription(getContentDescription()); 590 return true; 591 } 592 593 /** 594 * Sets the content description for this item, used for accessibility. 595 */ 596 private void populateContentDescription() { 597 if (!TextUtils.isEmpty(mSubject)) { 598 setContentDescription(sSubjectDescription + mSubject); 599 } else { 600 setContentDescription(sSubjectEmptyDescription); 601 } 602 } 603 } 604