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