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