ConversationItemView.java revision 08720495e48fbe84f72efe0d914396c904fd7afe
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.browse; 19 20import android.animation.Animator; 21import android.animation.AnimatorSet; 22import android.animation.ObjectAnimator; 23import android.content.ClipData; 24import android.content.ClipData.Item; 25import android.content.Context; 26import android.content.res.Resources; 27import android.database.Cursor; 28import android.graphics.Bitmap; 29import android.graphics.BitmapFactory; 30import android.graphics.Canvas; 31import android.graphics.LinearGradient; 32import android.graphics.Paint; 33import android.graphics.Point; 34import android.graphics.Shader; 35import android.graphics.Typeface; 36import android.graphics.drawable.Drawable; 37import android.net.Uri; 38import android.text.Layout.Alignment; 39import android.text.Spannable; 40import android.text.SpannableString; 41import android.text.SpannableStringBuilder; 42import android.text.StaticLayout; 43import android.text.TextPaint; 44import android.text.TextUtils; 45import android.text.TextUtils.TruncateAt; 46import android.text.format.DateUtils; 47import android.text.style.CharacterStyle; 48import android.text.style.ForegroundColorSpan; 49import android.text.style.TextAppearanceSpan; 50import android.util.SparseArray; 51import android.util.TypedValue; 52import android.view.DragEvent; 53import android.view.MotionEvent; 54import android.view.View; 55import android.view.ViewGroup; 56import android.view.ViewParent; 57import android.view.animation.DecelerateInterpolator; 58import android.widget.TextView; 59 60import com.android.mail.R; 61import com.android.mail.browse.ConversationItemViewModel.SenderFragment; 62import com.android.mail.perf.Timer; 63import com.android.mail.photomanager.ContactPhotoManager; 64import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier; 65import com.android.mail.photomanager.PhotoManager.PhotoIdentifier; 66import com.android.mail.providers.Conversation; 67import com.android.mail.providers.Folder; 68import com.android.mail.providers.UIProvider; 69import com.android.mail.providers.UIProvider.ConversationColumns; 70import com.android.mail.providers.UIProvider.ConversationListIcon; 71import com.android.mail.providers.UIProvider.FolderType; 72import com.android.mail.ui.AnimatedAdapter; 73import com.android.mail.ui.ControllableActivity; 74import com.android.mail.ui.ConversationSelectionSet; 75import com.android.mail.ui.DividedImageCanvas; 76import com.android.mail.ui.DividedImageCanvas.InvalidateCallback; 77import com.android.mail.ui.FolderDisplayer; 78import com.android.mail.ui.SwipeableItemView; 79import com.android.mail.ui.SwipeableListView; 80import com.android.mail.ui.ViewMode; 81import com.android.mail.utils.HardwareLayerEnabler; 82import com.android.mail.utils.LogTag; 83import com.android.mail.utils.LogUtils; 84import com.android.mail.utils.Utils; 85import com.google.common.annotations.VisibleForTesting; 86 87// TODO(pwestbro): References to non AOSP code should be moved out of UnifiedEmail 88import com.google.analytics.tracking.android.EasyTracker; 89import com.google.analytics.tracking.android.Tracker; 90 91import java.util.ArrayList; 92import java.util.List; 93 94public class ConversationItemView extends View implements SwipeableItemView, ToggleableItem, 95 InvalidateCallback { 96 // Timer. 97 private static int sLayoutCount = 0; 98 private static Timer sTimer; // Create the sTimer here if you need to do 99 // perf analysis. 100 private static final int PERF_LAYOUT_ITERATIONS = 50; 101 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 102 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 103 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 104 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 105 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 106 private static final String LOG_TAG = LogTag.getLogTag(); 107 108 // Analytics string values 109 private static final String CONV_ITEM_VIEW_CATEGORY = "ConversationItemView"; 110 private static final String CONTACT_PHOTO_ACTION = "ContactPhoto"; 111 private static final String NUM_PHOTOS_LABEL = "num_photos"; 112 private static final String CUSTOM_DIMEN_ACCOUNT_TYPE_GOOGLE_COM = "account_type_google_com"; 113 private static final String CUSTOM_DIMEN_ACCOUNT_TYPE_NON_GOOGLE_COM 114 = "account_type_non_google_com"; 115 private static final boolean REPORT_ANALYTICS = true; 116 117 118 // Static bitmaps. 119 private static Bitmap STAR_OFF; 120 private static Bitmap STAR_ON; 121 private static Bitmap ATTACHMENT; 122 private static Bitmap ONLY_TO_ME; 123 private static Bitmap TO_ME_AND_OTHERS; 124 private static Bitmap IMPORTANT_ONLY_TO_ME; 125 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 126 private static Bitmap IMPORTANT_TO_OTHERS; 127 private static Bitmap STATE_REPLIED; 128 private static Bitmap STATE_FORWARDED; 129 private static Bitmap STATE_REPLIED_AND_FORWARDED; 130 private static Bitmap STATE_CALENDAR_INVITE; 131 132 private static String sSendersSplitToken; 133 private static String sElidedPaddingToken; 134 135 // Static colors. 136 private static int sActivatedTextColor; 137 private static int sSendersTextColorRead; 138 private static int sSendersTextColorUnread; 139 private static int sDateTextColor; 140 private static int sTouchSlop; 141 @Deprecated 142 private static int sStandardScaledDimen; 143 private static int sShrinkAnimationDuration; 144 private static int sSlideAnimationDuration; 145 private static int sAnimatingBackgroundColor; 146 147 // Static paints. 148 private static TextPaint sPaint = new TextPaint(); 149 private static TextPaint sFoldersPaint = new TextPaint(); 150 151 private static Tracker sConversationItemViewTracker; 152 153 154 // Backgrounds for different states. 155 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 156 157 // Dimensions and coordinates. 158 private int mViewWidth = -1; 159 /** The view mode at which we calculated mViewWidth previously. */ 160 private int mPreviousMode; 161 private int mMode = -1; 162 private int mDateX; 163 private int mPaperclipX; 164 private int mSendersWidth; 165 166 /** Whether we're running under test mode. */ 167 private boolean mTesting = false; 168 /** Whether we are on a tablet device or not */ 169 private final boolean mTabletDevice; 170 171 /** Whether we have reported analytics for this view */ 172 private boolean mReportedStats = false; 173 174 @VisibleForTesting 175 ConversationItemViewCoordinates mCoordinates; 176 177 private ConversationItemViewCoordinates.Config mConfig; 178 179 private final Context mContext; 180 181 public ConversationItemViewModel mHeader; 182 private boolean mDownEvent; 183 private boolean mSelected = false; 184 private ConversationSelectionSet mSelectedConversationSet; 185 private Folder mDisplayedFolder; 186 private boolean mStarEnabled; 187 private boolean mSwipeEnabled; 188 private int mLastTouchX; 189 private int mLastTouchY; 190 private AnimatedAdapter mAdapter; 191 private float mAnimatedHeightFraction = 1.0f; 192 private final String mAccount; 193 private ControllableActivity mActivity; 194 private final TextView mSubjectTextView; 195 private final TextView mSendersTextView; 196 private int mGadgetMode; 197 private final DividedImageCanvas mContactImagesHolder; 198 private int mAttachmentPreviewMode; 199 private final DividedImageCanvas mAttachmentPreviewsCanvas; 200 201 private static int sFoldersLeftPadding; 202 private static TextAppearanceSpan sSubjectTextUnreadSpan; 203 private static TextAppearanceSpan sSubjectTextReadSpan; 204 private static ForegroundColorSpan sSnippetTextUnreadSpan; 205 private static ForegroundColorSpan sSnippetTextReadSpan; 206 private static int sScrollSlop; 207 private static CharacterStyle sActivatedTextSpan; 208 private static ContactPhotoManager sContactPhotoManager; 209 private static ContactPhotoManager sAttachmentPreviewsManager; 210 private static final String EMPTY_SNIPPET = ""; 211 212 static { 213 sPaint.setAntiAlias(true); 214 sFoldersPaint.setAntiAlias(true); 215 } 216 217 /** 218 * Handles displaying folders in a conversation header view. 219 */ 220 static class ConversationItemFolderDisplayer extends FolderDisplayer { 221 222 private int mFoldersCount; 223 224 public ConversationItemFolderDisplayer(Context context) { 225 super(context); 226 } 227 228 @Override 229 public void loadConversationFolders(Conversation conv, final Uri ignoreFolderUri, 230 final int ignoreFolderType) { 231 super.loadConversationFolders(conv, ignoreFolderUri, ignoreFolderType); 232 mFoldersCount = mFoldersSortedSet.size(); 233 } 234 235 @Override 236 public void reset() { 237 super.reset(); 238 mFoldersCount = 0; 239 } 240 241 public boolean hasVisibleFolders() { 242 return mFoldersCount > 0; 243 } 244 245 private int measureFolders(int mode, int availableSpace, int cellSize) { 246 int totalWidth = 0; 247 boolean firstTime = true; 248 for (Folder f : mFoldersSortedSet) { 249 final String folderString = f.name; 250 int width = (int) sFoldersPaint.measureText(folderString) + cellSize; 251 if (firstTime) { 252 firstTime = false; 253 } else { 254 width += sFoldersLeftPadding; 255 } 256 totalWidth += width; 257 if (totalWidth > availableSpace) { 258 break; 259 } 260 } 261 262 return totalWidth; 263 } 264 265 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates, 266 int mode) { 267 if (mFoldersCount == 0) { 268 return; 269 } 270 final int xMinStart = coordinates.foldersX; 271 final int xEnd = coordinates.foldersXEnd; 272 final int y = coordinates.foldersY; 273 final int height = coordinates.foldersHeight; 274 final int ascent = coordinates.foldersAscent; 275 int textBottomPadding = coordinates.foldersTextBottomPadding; 276 277 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 278 sFoldersPaint.setTypeface(coordinates.foldersTypeface); 279 280 // Initialize space and cell size based on the current mode. 281 int availableSpace = xEnd - xMinStart; 282 int averageWidth = availableSpace / mFoldersCount; 283 int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext); 284 285 // TODO(ath): sFoldersPaint.measureText() is done 3x in this method. stop that. 286 // Extra credit: maybe cache results across items as long as font size doesn't change. 287 288 final int totalWidth = measureFolders(mode, availableSpace, cellSize); 289 int xStart = xEnd - Math.min(availableSpace, totalWidth); 290 final boolean overflow = totalWidth > availableSpace; 291 292 // Second pass to draw folders. 293 int i = 0; 294 for (Folder f : mFoldersSortedSet) { 295 if (availableSpace <= 0) { 296 break; 297 } 298 final String folderString = f.name; 299 final int fgColor = f.getForegroundColor(mDefaultFgColor); 300 final int bgColor = f.getBackgroundColor(mDefaultBgColor); 301 boolean labelTooLong = false; 302 final int textW = (int) sFoldersPaint.measureText(folderString); 303 int width = textW + cellSize + sFoldersLeftPadding; 304 305 if (overflow && width > averageWidth) { 306 if (i < mFoldersCount - 1) { 307 width = averageWidth; 308 } else { 309 // allow the last label to take all remaining space 310 // (and don't let it make room for padding) 311 width = availableSpace + sFoldersLeftPadding; 312 } 313 labelTooLong = true; 314 } 315 316 // TODO (mindyp): how to we get this? 317 final boolean isMuted = false; 318 // labelValues.folderId == 319 // sGmail.getFolderMap(mAccount).getFolderIdIgnored(); 320 321 // Draw the box. 322 sFoldersPaint.setColor(bgColor); 323 sFoldersPaint.setStyle(Paint.Style.FILL); 324 canvas.drawRect(xStart, y, xStart + width - sFoldersLeftPadding, 325 y + height, sFoldersPaint); 326 327 // Draw the text. 328 final int padding = cellSize / 2; 329 sFoldersPaint.setColor(fgColor); 330 sFoldersPaint.setStyle(Paint.Style.FILL); 331 if (labelTooLong) { 332 final int rightBorder = xStart + width - sFoldersLeftPadding - padding; 333 final Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, 334 y, fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); 335 sFoldersPaint.setShader(shader); 336 } 337 canvas.drawText(folderString, xStart + padding, y + height - textBottomPadding, 338 sFoldersPaint); 339 if (labelTooLong) { 340 sFoldersPaint.setShader(null); 341 } 342 343 availableSpace -= width; 344 xStart += width; 345 i++; 346 } 347 } 348 } 349 350 /** 351 * Helpers function to align an element in the center of a space. 352 */ 353 private static int getPadding(int space, int length) { 354 return (space - length) / 2; 355 } 356 357 public ConversationItemView(Context context, String account) { 358 super(context); 359 setClickable(true); 360 setLongClickable(true); 361 mContext = context.getApplicationContext(); 362 final Resources res = mContext.getResources(); 363 mTabletDevice = Utils.useTabletUI(res); 364 mAccount = account; 365 366 if (STAR_OFF == null) { 367 // Initialize static bitmaps. 368 STAR_OFF = BitmapFactory.decodeResource(res, 369 R.drawable.btn_star_off_normal_email_holo_light); 370 STAR_ON = BitmapFactory.decodeResource(res, 371 R.drawable.btn_star_on_normal_email_holo_light); 372 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); 373 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 374 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 375 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 376 R.drawable.ic_email_caret_double_important_unread); 377 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 378 R.drawable.ic_email_caret_single_important_unread); 379 IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, 380 R.drawable.ic_email_caret_none_important_unread); 381 STATE_REPLIED = 382 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 383 STATE_FORWARDED = 384 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 385 STATE_REPLIED_AND_FORWARDED = 386 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 387 STATE_CALENDAR_INVITE = 388 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 389 390 // Initialize colors. 391 sActivatedTextColor = res.getColor(android.R.color.white); 392 sActivatedTextSpan = CharacterStyle.wrap(new ForegroundColorSpan(sActivatedTextColor)); 393 sSendersTextColorRead = res.getColor(R.color.senders_text_color_read); 394 sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread); 395 sSubjectTextUnreadSpan = new TextAppearanceSpan(mContext, 396 R.style.SubjectAppearanceUnreadStyle); 397 sSubjectTextReadSpan = new TextAppearanceSpan(mContext, 398 R.style.SubjectAppearanceReadStyle); 399 sSnippetTextUnreadSpan = 400 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_unread)); 401 sSnippetTextReadSpan = 402 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_read)); 403 sDateTextColor = res.getColor(R.color.date_text_color); 404 sTouchSlop = res.getDimensionPixelSize(R.dimen.touch_slop); 405 sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen); 406 sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration); 407 sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration); 408 // Initialize static color. 409 sSendersSplitToken = res.getString(R.string.senders_split_token); 410 sElidedPaddingToken = res.getString(R.string.elided_padding_token); 411 sAnimatingBackgroundColor = res.getColor(R.color.animating_item_background_color); 412 sScrollSlop = res.getInteger(R.integer.swipeScrollSlop); 413 sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding); 414 sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context); 415 sAttachmentPreviewsManager = ContactPhotoManager.createContactPhotoManager(context); 416 417 if (REPORT_ANALYTICS) { 418 EasyTracker.getInstance().setContext(context); 419 sConversationItemViewTracker = EasyTracker.getTracker(); 420 } 421 } 422 423 424 mSendersTextView = new TextView(mContext); 425 mSendersTextView.setEllipsize(TextUtils.TruncateAt.END); 426 mSendersTextView.setIncludeFontPadding(false); 427 428 mSubjectTextView = new TextView(mContext); 429 mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END); 430 mSubjectTextView.setIncludeFontPadding(false); 431 432 mContactImagesHolder = new DividedImageCanvas(context, new InvalidateCallback() { 433 @Override 434 public void invalidate() { 435 if (mCoordinates == null) { 436 return; 437 } 438 ConversationItemView.this.invalidate(mCoordinates.contactImagesX, 439 mCoordinates.contactImagesY, 440 mCoordinates.contactImagesX + mCoordinates.contactImagesWidth, 441 mCoordinates.contactImagesY + mCoordinates.contactImagesHeight); 442 } 443 444 @Override 445 public void onImagesResolved() { 446 if (REPORT_ANALYTICS && !mReportedStats) { 447 final int numTiles = mContactImagesHolder.getDivisionCount(); 448 final List<String> photoKeys = mContactImagesHolder.getDivisionIds(); 449 int numPhotos = 0; 450 for (final String photoKey : photoKeys) { 451 final Boolean isResolved = 452 mContactImagesHolder.imageResolved(photoKey); 453 if (isResolved != null && isResolved) { 454 numPhotos++; 455 } 456 } 457 458 // Number of subtiles 459 sConversationItemViewTracker.setCustomMetric(1, (long)numTiles); 460 // Number of resolved photos 461 sConversationItemViewTracker.setCustomMetric(2, (long)numPhotos); 462 // Number of letter subtiles 463 sConversationItemViewTracker.setCustomMetric(3, (long)(numTiles - numPhotos)); 464 final String accountTypeCustomDimen = mAccount.endsWith("google.com") ? 465 CUSTOM_DIMEN_ACCOUNT_TYPE_GOOGLE_COM : 466 CUSTOM_DIMEN_ACCOUNT_TYPE_NON_GOOGLE_COM; 467 sConversationItemViewTracker.setCustomDimension(3, accountTypeCustomDimen); 468 // This is a hack. Ideally this would check the folder object to determine if it 469 // is the primary section 470 final String isPrimarySection = 471 TextUtils.equals(mDisplayedFolder.persistentId, "^sq_ig_i_personal") ? 472 "primary" : "not_primary"; 473 sConversationItemViewTracker.setCustomDimension(4, isPrimarySection); 474 475 sConversationItemViewTracker.sendEvent(CONV_ITEM_VIEW_CATEGORY, 476 CONTACT_PHOTO_ACTION, NUM_PHOTOS_LABEL, (long)numTiles); 477 478 mReportedStats = true; 479 } 480 } 481 }); 482 mAttachmentPreviewsCanvas = new DividedImageCanvas(context, this); 483 } 484 485 public void bind(Cursor cursor, ControllableActivity activity, ConversationSelectionSet set, 486 Folder folder, int checkboxOrSenderImage, boolean swipeEnabled, 487 boolean priorityArrowEnabled, AnimatedAdapter adapter) { 488 bind(ConversationItemViewModel.forCursor(mAccount, cursor), activity, set, folder, 489 checkboxOrSenderImage, swipeEnabled, priorityArrowEnabled, adapter); 490 } 491 492 public void bind(Conversation conversation, ControllableActivity activity, 493 ConversationSelectionSet set, Folder folder, int checkboxOrSenderImage, 494 boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) { 495 bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity, set, 496 folder, checkboxOrSenderImage, swipeEnabled, priorityArrowEnabled, adapter); 497 } 498 499 private void bind(ConversationItemViewModel header, ControllableActivity activity, 500 ConversationSelectionSet set, Folder folder, int checkboxOrSenderImage, 501 boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) { 502 // If this was previously bound to a conversation, remove any contact 503 // photo manager requests. 504 // TODO:MARKWEI attachment previews 505 if (mHeader != null) { 506 final ArrayList<String> divisionIds = mContactImagesHolder.getDivisionIds(); 507 if (divisionIds != null) { 508 mContactImagesHolder.reset(); 509 for (int pos = 0; pos < divisionIds.size(); pos++) { 510 sContactPhotoManager.removePhoto(DividedImageCanvas.generateHash( 511 mContactImagesHolder, pos, divisionIds.get(pos))); 512 } 513 } 514 } 515 mCoordinates = null; 516 mHeader = header; 517 mActivity = activity; 518 mSelectedConversationSet = set; 519 mDisplayedFolder = folder; 520 mStarEnabled = folder != null && !folder.isTrash(); 521 mSwipeEnabled = swipeEnabled; 522 mAdapter = adapter; 523 mReportedStats = false; 524 if (mHeader.conversation.getAttachmentsCount() == 0) { 525 mAttachmentPreviewMode = ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE; 526 } else { 527 mAttachmentPreviewMode = mHeader.conversation.read ? 528 ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_SHORT 529 : ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_TALL; 530 } 531 532 if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) { 533 mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO; 534 } else { 535 mGadgetMode = ConversationItemViewCoordinates.GADGET_NONE; 536 } 537 538 // Initialize folder displayer. 539 if (mHeader.folderDisplayer == null) { 540 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 541 } else { 542 mHeader.folderDisplayer.reset(); 543 } 544 545 final int ignoreFolderType; 546 if (mDisplayedFolder.isInbox()) { 547 ignoreFolderType = FolderType.INBOX; 548 } else { 549 ignoreFolderType = -1; 550 } 551 552 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder.uri, 553 ignoreFolderType); 554 555 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 556 mHeader.conversation.dateMs); 557 558 mConfig = new ConversationItemViewCoordinates.Config() 559 .withGadget(mGadgetMode) 560 .withAttachmentPreviews(mAttachmentPreviewMode); 561 if (header.folderDisplayer.hasVisibleFolders()) { 562 mConfig.showFolders(); 563 } 564 if (header.hasBeenForwarded || header.hasBeenRepliedTo || header.isInvite) { 565 mConfig.showReplyState(); 566 } 567 if (mHeader.conversation.color != 0) { 568 mConfig.showColorBlock(); 569 } 570 // Personal level. 571 mHeader.personalLevelBitmap = null; 572 if (true) { // TODO: hook this up to a setting 573 final int personalLevel = mHeader.conversation.personalLevel; 574 final boolean isImportant = 575 mHeader.conversation.priority == UIProvider.ConversationPriority.IMPORTANT; 576 final boolean useImportantMarkers = isImportant && priorityArrowEnabled; 577 578 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 579 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 580 : ONLY_TO_ME; 581 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 582 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 583 : TO_ME_AND_OTHERS; 584 } else if (useImportantMarkers) { 585 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 586 } 587 } 588 if (mHeader.personalLevelBitmap != null) { 589 mConfig.showPersonalIndicator(); 590 } 591 592 setContentDescription(); 593 requestLayout(); 594 } 595 596 /** 597 * Get the Conversation object associated with this view. 598 */ 599 public Conversation getConversation() { 600 return mHeader.conversation; 601 } 602 603 /** 604 * Sets the mode. Only used for testing. 605 */ 606 @VisibleForTesting 607 void setMode(int mode) { 608 mMode = mode; 609 mTesting = true; 610 } 611 612 private static void startTimer(String tag) { 613 if (sTimer != null) { 614 sTimer.start(tag); 615 } 616 } 617 618 private static void pauseTimer(String tag) { 619 if (sTimer != null) { 620 sTimer.pause(tag); 621 } 622 } 623 624 @Override 625 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 626 final int wSize = MeasureSpec.getSize(widthMeasureSpec); 627 628 final int currentMode = mActivity.getViewMode().getMode(); 629 if (wSize != mViewWidth || mPreviousMode != currentMode) { 630 mViewWidth = wSize; 631 mPreviousMode = currentMode; 632 if (!mTesting) { 633 mMode = ConversationItemViewCoordinates.getMode(mContext, mPreviousMode); 634 } 635 } 636 mHeader.viewWidth = mViewWidth; 637 638 mConfig.updateWidth(wSize).setMode(mMode); 639 640 Resources res = getResources(); 641 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 642 if (mHeader.standardScaledDimen != sStandardScaledDimen) { 643 // Large Text has been toggle on/off. Update the static dimens. 644 sStandardScaledDimen = mHeader.standardScaledDimen; 645 ConversationItemViewCoordinates.refreshConversationDimens(mContext); 646 } 647 648 mCoordinates = ConversationItemViewCoordinates.forConfig(mContext, mConfig, 649 mAdapter.getCoordinatesCache()); 650 651 final int h = (mAnimatedHeightFraction != 1.0f) ? 652 Math.round(mAnimatedHeightFraction * mCoordinates.height) : mCoordinates.height; 653 setMeasuredDimension(mConfig.getWidth(), h); 654 } 655 656 @Override 657 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 658 startTimer(PERF_TAG_LAYOUT); 659 660 super.onLayout(changed, left, top, right, bottom); 661 662 calculateTextsAndBitmaps(); 663 calculateCoordinates(); 664 665 // Subject. 666 createSubject(mHeader.unread); 667 668 if (!mHeader.isLayoutValid(mContext)) { 669 setContentDescription(); 670 } 671 mHeader.validate(mContext); 672 673 pauseTimer(PERF_TAG_LAYOUT); 674 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 675 sTimer.dumpResults(); 676 sTimer = new Timer(); 677 sLayoutCount = 0; 678 } 679 } 680 681 private void setContentDescription() { 682 if (mActivity.isAccessibilityEnabled()) { 683 mHeader.resetContentDescription(); 684 setContentDescription(mHeader.getContentDescription(mContext)); 685 } 686 } 687 688 @Override 689 public void setBackgroundResource(int resourceId) { 690 Drawable drawable = mBackgrounds.get(resourceId); 691 if (drawable == null) { 692 drawable = getResources().getDrawable(resourceId); 693 mBackgrounds.put(resourceId, drawable); 694 } 695 if (getBackground() != drawable) { 696 super.setBackgroundDrawable(drawable); 697 } 698 } 699 700 private void calculateTextsAndBitmaps() { 701 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 702 703 if (mSelectedConversationSet != null) { 704 mSelected = mSelectedConversationSet.contains(mHeader.conversation); 705 } 706 mHeader.gadgetMode = mGadgetMode; 707 708 final boolean isUnread = mHeader.unread; 709 updateBackground(isUnread); 710 711 mHeader.sendersDisplayText = new SpannableStringBuilder(); 712 mHeader.styledSendersString = new SpannableStringBuilder(); 713 714 // Parse senders fragments. 715 if (mHeader.conversation.conversationInfo != null) { 716 // This is Gmail 717 Context context = getContext(); 718 mHeader.messageInfoString = SendersView 719 .createMessageInfo(context, mHeader.conversation, true); 720 int maxChars = ConversationItemViewCoordinates.getSendersLength(context, 721 ConversationItemViewCoordinates.getMode(context, mActivity.getViewMode()), 722 mHeader.conversation.hasAttachments); 723 mHeader.displayableSenderEmails = new ArrayList<String>(); 724 mHeader.displayableSenderNames = new ArrayList<String>(); 725 mHeader.styledSenders = new ArrayList<SpannableString>(); 726 SendersView.format(context, mHeader.conversation.conversationInfo, 727 mHeader.messageInfoString.toString(), maxChars, mHeader.styledSenders, 728 mHeader.displayableSenderNames, mHeader.displayableSenderEmails, mAccount, 729 true); 730 // If we have displayable senders, load their thumbnails 731 loadSenderImages(); 732 } else { 733 // This is Email 734 SendersView.formatSenders(mHeader, getContext(), true); 735 if (mHeader.conversation.senders != null) { 736 mHeader.displayableSenderEmails = new ArrayList<String>(); 737 mHeader.displayableSenderEmails.add(mHeader.conversation.senders); 738 mHeader.displayableSenderNames = new ArrayList<String>(); 739 // Does Email have display name for sender? 740 mHeader.displayableSenderNames.add(mHeader.conversation.senders); 741 loadSenderImages(); 742 } 743 } 744 745 if (mAttachmentPreviewMode != ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE) { 746 loadAttachmentPreviews(); 747 } 748 749 if (mHeader.isLayoutValid(mContext)) { 750 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 751 return; 752 } 753 startTimer(PERF_TAG_CALCULATE_FOLDERS); 754 755 756 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 757 758 // Paper clip icon. 759 mHeader.paperclip = null; 760 if (mHeader.conversation.hasAttachments) { 761 mHeader.paperclip = ATTACHMENT; 762 } 763 764 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 765 766 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 767 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 768 } 769 770 // FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which 771 // is immutable. 772 private void loadSenderImages() { 773 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 774 && mHeader.displayableSenderEmails != null 775 && mHeader.displayableSenderEmails.size() > 0) { 776 if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) { 777 LogUtils.w(LOG_TAG, 778 "Contact image width(%d) or height(%d) is 0 for mode: (%d).", 779 mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, mMode); 780 return; 781 } 782 mContactImagesHolder.setDimensions(mCoordinates.contactImagesWidth, 783 mCoordinates.contactImagesHeight); 784 mContactImagesHolder.setDivisionIds(mHeader.displayableSenderEmails); 785 final int size = mHeader.displayableSenderEmails.size(); 786 final int numTiles = Math.min(DividedImageCanvas.MAX_DIVISIONS, size); 787 String emailAddress; 788 for (int i = 0; i < numTiles; i++) { 789 emailAddress = mHeader.displayableSenderEmails.get(i); 790 final PhotoIdentifier photoIdentifier = new ContactIdentifier( 791 mHeader.displayableSenderNames.get(i), emailAddress, i); 792 sContactPhotoManager.loadThumbnail(photoIdentifier, mContactImagesHolder); 793 } 794 } 795 } 796 797 private void loadAttachmentPreviews() { 798 if (mAttachmentPreviewMode != ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE) { 799 final int attachmentPreviewsHeight = ConversationItemViewCoordinates 800 .getAttachmentPreviewsHeight(mContext, mAttachmentPreviewMode); 801 if (mCoordinates.attachmentPreviewsWidth <= 0 || attachmentPreviewsHeight <= 0) { 802 LogUtils.w(LOG_TAG, 803 "Attachment preview width(%d) or height(%d) is 0 for mode: (%d,%d).", 804 mCoordinates.attachmentPreviewsWidth, attachmentPreviewsHeight, mMode, 805 mAttachmentPreviewMode); 806 return; 807 } 808 mAttachmentPreviewsCanvas.setDimensions(mCoordinates.attachmentPreviewsWidth, 809 attachmentPreviewsHeight); 810 ArrayList<String> attachments = mHeader.conversation.getAttachments(); 811 mAttachmentPreviewsCanvas.setDivisionIds(attachments); 812 int size = attachments.size(); 813 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 814 String attachment = attachments.get(i); 815 PhotoIdentifier photoIdentifier = new ContactIdentifier( 816 attachment, attachment, i); 817 sAttachmentPreviewsManager.loadThumbnail( 818 photoIdentifier, mAttachmentPreviewsCanvas); 819 } 820 } 821 } 822 823 private static int makeExactSpecForSize(int size) { 824 return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY); 825 } 826 827 private static void layoutViewExactly(View v, int w, int h) { 828 v.measure(makeExactSpecForSize(w), makeExactSpecForSize(h)); 829 v.layout(0, 0, w, h); 830 } 831 832 private void layoutSenders() { 833 if (mHeader.styledSendersString != null) { 834 if (isActivated() && showActivatedText()) { 835 mHeader.styledSendersString.setSpan(sActivatedTextSpan, 0, 836 mHeader.styledMessageInfoStringOffset, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 837 } else { 838 mHeader.styledSendersString.removeSpan(sActivatedTextSpan); 839 } 840 841 final int w = mSendersWidth; 842 final int h = mCoordinates.sendersHeight; 843 mSendersTextView.setLayoutParams(new ViewGroup.LayoutParams(w, h)); 844 mSendersTextView.setMaxLines(mCoordinates.sendersLineCount); 845 mSendersTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.sendersFontSize); 846 layoutViewExactly(mSendersTextView, w, h); 847 848 mSendersTextView.setText(mHeader.styledSendersString); 849 } 850 } 851 852 private void createSubject(final boolean isUnread) { 853 final String subject = filterTag(mHeader.conversation.subject); 854 final String snippet = mHeader.conversation.getSnippet(); 855 final SpannableStringBuilder displayedStringBuilder = new SpannableStringBuilder( 856 Conversation.getSubjectAndSnippetForDisplay(mContext, subject, snippet)); 857 858 // since spans affect text metrics, add spans to the string before measure/layout or fancy 859 // ellipsizing 860 final int subjectTextLength = (subject != null) ? subject.length() : 0; 861 if (!TextUtils.isEmpty(subject)) { 862 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap( 863 isUnread ? sSubjectTextUnreadSpan : sSubjectTextReadSpan), 0, subjectTextLength, 864 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 865 } 866 if (!TextUtils.isEmpty(snippet)) { 867 final int startOffset = subjectTextLength; 868 // Start after the end of the subject text; since the subject may be 869 // "" or null, this could start at the 0th character in the subjectText string 870 displayedStringBuilder.setSpan(ForegroundColorSpan.wrap( 871 isUnread ? sSnippetTextUnreadSpan : sSnippetTextReadSpan), startOffset, 872 displayedStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 873 } 874 if (isActivated() && showActivatedText()) { 875 displayedStringBuilder.setSpan(sActivatedTextSpan, 0, displayedStringBuilder.length(), 876 Spannable.SPAN_INCLUSIVE_INCLUSIVE); 877 } 878 879 final int subjectWidth = mCoordinates.subjectWidth; 880 final int subjectHeight = mCoordinates.subjectHeight; 881 mSubjectTextView.setLayoutParams(new ViewGroup.LayoutParams(subjectWidth, subjectHeight)); 882 mSubjectTextView.setMaxLines(mCoordinates.subjectLineCount); 883 mSubjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.subjectFontSize); 884 layoutViewExactly(mSubjectTextView, subjectWidth, subjectHeight); 885 886 mSubjectTextView.setText(displayedStringBuilder); 887 } 888 889 /** 890 * Returns the resource for the text color depending on whether the element is activated or not. 891 * @param defaultColor 892 */ 893 private int getFontColor(int defaultColor) { 894 final boolean isBackGroundBlue = isActivated() && showActivatedText(); 895 return isBackGroundBlue ? sActivatedTextColor : defaultColor; 896 } 897 898 private boolean showActivatedText() { 899 // For activated elements in tablet in conversation mode, we show an activated color, since 900 // the background is dark blue for activated versus gray for non-activated. 901 final boolean isListCollapsed = mContext.getResources().getBoolean(R.bool.list_collapsed); 902 return mTabletDevice && !isListCollapsed; 903 } 904 905 private boolean canFitFragment(int width, int line, int fixedWidth) { 906 if (line == mCoordinates.sendersLineCount) { 907 return width + fixedWidth <= mSendersWidth; 908 } else { 909 return width <= mSendersWidth; 910 } 911 } 912 913 private void calculateCoordinates() { 914 startTimer(PERF_TAG_CALCULATE_COORDINATES); 915 916 sPaint.setTextSize(mCoordinates.dateFontSize); 917 sPaint.setTypeface(Typeface.DEFAULT); 918 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText( 919 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 920 921 mPaperclipX = mDateX - ATTACHMENT.getWidth() - mCoordinates.datePaddingLeft; 922 923 if (mConfig.isWide()) { 924 // In wide mode, the end of the senders should align with 925 // the start of the subject and is based on a max width. 926 mSendersWidth = mCoordinates.sendersWidth; 927 } else { 928 // In normal mode, the width is based on where the date/attachment icon start. 929 final int dateAttachmentStart; 930 // Have this end near the paperclip or date, not the folders. 931 if (mHeader.paperclip != null) { 932 dateAttachmentStart = mPaperclipX - mCoordinates.paperclipPaddingLeft; 933 } else { 934 dateAttachmentStart = mDateX - mCoordinates.datePaddingLeft; 935 } 936 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX; 937 } 938 939 // Second pass to layout each fragment. 940 int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent; 941 942 if (mHeader.styledSenders != null) { 943 ellipsizeStyledSenders(); 944 layoutSenders(); 945 } else { 946 // First pass to calculate width of each fragment. 947 int totalWidth = 0; 948 int fixedWidth = 0; 949 sPaint.setTextSize(mCoordinates.sendersFontSize); 950 sPaint.setTypeface(Typeface.DEFAULT); 951 for (SenderFragment senderFragment : mHeader.senderFragments) { 952 CharacterStyle style = senderFragment.style; 953 int start = senderFragment.start; 954 int end = senderFragment.end; 955 style.updateDrawState(sPaint); 956 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 957 boolean isFixed = senderFragment.isFixed; 958 if (isFixed) { 959 fixedWidth += senderFragment.width; 960 } 961 totalWidth += senderFragment.width; 962 } 963 964 if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) { 965 sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0; 966 } 967 if (mSendersWidth < 0) { 968 mSendersWidth = 0; 969 } 970 totalWidth = ellipsize(fixedWidth); 971 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 972 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 973 } 974 975 sPaint.setTextSize(mCoordinates.sendersFontSize); 976 sPaint.setTypeface(Typeface.DEFAULT); 977 if (mSendersWidth < 0) { 978 mSendersWidth = 0; 979 } 980 981 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 982 } 983 984 // The rules for displaying ellipsized senders are as follows: 985 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 986 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 987 // appending new senders 988 private int ellipsizeStyledSenders() { 989 SpannableStringBuilder builder = new SpannableStringBuilder(); 990 float totalWidth = 0; 991 boolean ellipsize = false; 992 float width; 993 SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 994 if (messageInfoString.length() > 0) { 995 CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), 996 CharacterStyle.class); 997 // There is only 1 character style span; make sure we apply all the 998 // styles to the paint object before measuring. 999 if (spans.length > 0) { 1000 spans[0].updateDrawState(sPaint); 1001 } 1002 // Paint the message info string to see if we lose space. 1003 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 1004 totalWidth += messageInfoWidth; 1005 } 1006 SpannableString prevSender = null; 1007 SpannableString ellipsizedText; 1008 for (SpannableString sender : mHeader.styledSenders) { 1009 // There may be null sender strings if there were dupes we had to remove. 1010 if (sender == null) { 1011 continue; 1012 } 1013 // No more width available, we'll only show fixed fragments. 1014 if (ellipsize) { 1015 break; 1016 } 1017 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 1018 // There is only 1 character style span. 1019 if (spans.length > 0) { 1020 spans[0].updateDrawState(sPaint); 1021 } 1022 // If there are already senders present in this string, we need to 1023 // make sure we prepend the dividing token 1024 if (SendersView.sElidedString.equals(sender.toString())) { 1025 prevSender = sender; 1026 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 1027 } else if (builder.length() > 0 1028 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 1029 .toString()))) { 1030 prevSender = sender; 1031 sender = copyStyles(spans, sSendersSplitToken + sender); 1032 } else { 1033 prevSender = sender; 1034 } 1035 if (spans.length > 0) { 1036 spans[0].updateDrawState(sPaint); 1037 } 1038 // Measure the width of the current sender and make sure we have space 1039 width = (int) sPaint.measureText(sender.toString()); 1040 if (width + totalWidth > mSendersWidth) { 1041 // The text is too long, new line won't help. We have to 1042 // ellipsize text. 1043 ellipsize = true; 1044 width = mSendersWidth - totalWidth; // ellipsis width? 1045 ellipsizedText = copyStyles(spans, 1046 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 1047 width = (int) sPaint.measureText(ellipsizedText.toString()); 1048 } else { 1049 ellipsizedText = null; 1050 } 1051 totalWidth += width; 1052 1053 final CharSequence fragmentDisplayText; 1054 if (ellipsizedText != null) { 1055 fragmentDisplayText = ellipsizedText; 1056 } else { 1057 fragmentDisplayText = sender; 1058 } 1059 builder.append(fragmentDisplayText); 1060 } 1061 mHeader.styledMessageInfoStringOffset = builder.length(); 1062 if (messageInfoString != null) { 1063 builder.append(messageInfoString); 1064 } 1065 mHeader.styledSendersString = builder; 1066 return (int)totalWidth; 1067 } 1068 1069 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 1070 SpannableString s = new SpannableString(newText); 1071 if (spans != null && spans.length > 0) { 1072 s.setSpan(spans[0], 0, s.length(), 0); 1073 } 1074 return s; 1075 } 1076 1077 private int ellipsize(int fixedWidth) { 1078 int totalWidth = 0; 1079 int currentLine = 1; 1080 boolean ellipsize = false; 1081 for (SenderFragment senderFragment : mHeader.senderFragments) { 1082 CharacterStyle style = senderFragment.style; 1083 int start = senderFragment.start; 1084 int end = senderFragment.end; 1085 int width = senderFragment.width; 1086 boolean isFixed = senderFragment.isFixed; 1087 style.updateDrawState(sPaint); 1088 1089 // No more width available, we'll only show fixed fragments. 1090 if (ellipsize && !isFixed) { 1091 senderFragment.shouldDisplay = false; 1092 continue; 1093 } 1094 1095 // New line and ellipsize text if needed. 1096 senderFragment.ellipsizedText = null; 1097 if (isFixed) { 1098 fixedWidth -= width; 1099 } 1100 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 1101 // The text is too long, new line won't help. We have to 1102 // ellipsize text. 1103 if (totalWidth == 0) { 1104 ellipsize = true; 1105 } else { 1106 // New line. 1107 if (currentLine < mCoordinates.sendersLineCount) { 1108 currentLine++; 1109 totalWidth = 0; 1110 // The text is still too long, we have to ellipsize 1111 // text. 1112 if (totalWidth + width > mSendersWidth) { 1113 ellipsize = true; 1114 } 1115 } else { 1116 ellipsize = true; 1117 } 1118 } 1119 1120 if (ellipsize) { 1121 width = mSendersWidth - totalWidth; 1122 // No more new line, we have to reserve width for fixed 1123 // fragments. 1124 if (currentLine == mCoordinates.sendersLineCount) { 1125 width -= fixedWidth; 1126 } 1127 senderFragment.ellipsizedText = TextUtils.ellipsize( 1128 mHeader.sendersText.substring(start, end), sPaint, width, 1129 TruncateAt.END).toString(); 1130 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 1131 } 1132 } 1133 senderFragment.shouldDisplay = true; 1134 totalWidth += width; 1135 1136 final CharSequence fragmentDisplayText; 1137 if (senderFragment.ellipsizedText != null) { 1138 fragmentDisplayText = senderFragment.ellipsizedText; 1139 } else { 1140 fragmentDisplayText = mHeader.sendersText.substring(start, end); 1141 } 1142 final int spanStart = mHeader.sendersDisplayText.length(); 1143 mHeader.sendersDisplayText.append(fragmentDisplayText); 1144 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 1145 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1146 } 1147 return totalWidth; 1148 } 1149 1150 /** 1151 * If the subject contains the tag of a mailing-list (text surrounded with 1152 * []), return the subject with that tag ellipsized, e.g. 1153 * "[android-gmail-team] Hello" -> "[andr...] Hello" 1154 */ 1155 private String filterTag(String subject) { 1156 String result = subject; 1157 String formatString = getContext().getResources().getString(R.string.filtered_tag); 1158 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 1159 int end = subject.indexOf(']'); 1160 if (end > 0) { 1161 String tag = subject.substring(1, end); 1162 result = String.format(formatString, Utils.ellipsize(tag, 7), 1163 subject.substring(end + 1)); 1164 } 1165 } 1166 return result; 1167 } 1168 1169 @Override 1170 protected void onDraw(Canvas canvas) { 1171 // Contact photo 1172 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) { 1173 canvas.save(); 1174 drawContactImages(canvas); 1175 canvas.restore(); 1176 } 1177 // Senders. 1178 boolean isUnread = mHeader.unread; 1179 // Old style senders; apply text colors/ sizes/ styling. 1180 canvas.save(); 1181 if (mHeader.sendersDisplayLayout != null) { 1182 sPaint.setTextSize(mCoordinates.sendersFontSize); 1183 sPaint.setTypeface(SendersView.getTypeface(isUnread)); 1184 sPaint.setColor(getFontColor(isUnread ? 1185 sSendersTextColorUnread : sSendersTextColorRead)); 1186 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY 1187 + mHeader.sendersDisplayLayout.getTopPadding()); 1188 mHeader.sendersDisplayLayout.draw(canvas); 1189 } else { 1190 drawSenders(canvas); 1191 } 1192 canvas.restore(); 1193 1194 1195 // Subject. 1196 sPaint.setTypeface(Typeface.DEFAULT); 1197 canvas.save(); 1198 drawSubject(canvas); 1199 canvas.restore(); 1200 1201 // Folders. 1202 if (mConfig.areFoldersVisible()) { 1203 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mMode); 1204 } 1205 1206 // If this folder has a color (combined view/Email), show it here 1207 if (mConfig.isColorBlockVisible()) { 1208 sFoldersPaint.setColor(mHeader.conversation.color); 1209 sFoldersPaint.setStyle(Paint.Style.FILL); 1210 canvas.drawRect(mCoordinates.colorBlockX, mCoordinates.colorBlockY, 1211 mCoordinates.colorBlockX + mCoordinates.colorBlockWidth, 1212 mCoordinates.colorBlockY + mCoordinates.colorBlockHeight, sFoldersPaint); 1213 } 1214 1215 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1216 if (mConfig.isReplyStateVisible()) { 1217 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1218 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1219 mCoordinates.replyStateY, null); 1220 } else if (mHeader.hasBeenRepliedTo) { 1221 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1222 mCoordinates.replyStateY, null); 1223 } else if (mHeader.hasBeenForwarded) { 1224 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1225 mCoordinates.replyStateY, null); 1226 } else if (mHeader.isInvite) { 1227 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1228 mCoordinates.replyStateY, null); 1229 } 1230 } 1231 1232 if (mConfig.isPersonalIndicatorVisible()) { 1233 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalIndicatorX, 1234 mCoordinates.personalIndicatorY, null); 1235 } 1236 1237 // Date. 1238 sPaint.setTextSize(mCoordinates.dateFontSize); 1239 sPaint.setTypeface(Typeface.DEFAULT); 1240 sPaint.setColor(sDateTextColor); 1241 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateYBaseline, 1242 sPaint); 1243 1244 // Paper clip icon. 1245 if (mHeader.paperclip != null) { 1246 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1247 } 1248 1249 if (mStarEnabled) { 1250 // Star. 1251 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1252 } 1253 1254 // Attachment previews 1255 if (mAttachmentPreviewMode != ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE) { 1256 canvas.save(); 1257 drawAttachmentPreviews(canvas); 1258 canvas.restore(); 1259 } 1260 } 1261 1262 private void drawContactImages(Canvas canvas) { 1263 canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY); 1264 mContactImagesHolder.draw(canvas); 1265 } 1266 1267 private void drawAttachmentPreviews(Canvas canvas) { 1268 canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY); 1269 mAttachmentPreviewsCanvas.draw(canvas); 1270 } 1271 1272 private void drawSubject(Canvas canvas) { 1273 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY); 1274 mSubjectTextView.draw(canvas); 1275 } 1276 1277 private void drawSenders(Canvas canvas) { 1278 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY); 1279 mSendersTextView.draw(canvas); 1280 } 1281 1282 private Bitmap getStarBitmap() { 1283 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1284 } 1285 1286 private static void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1287 canvas.drawText(s, 0, s.length(), x, y, paint); 1288 } 1289 1290 /** 1291 * Set the background for this item based on: 1292 * 1. Read / Unread (unread messages have a lighter background) 1293 * 2. Tablet / Phone 1294 * 3. Checkbox checked / Unchecked (controls CAB color for item) 1295 * 4. Activated / Not activated (controls the blue highlight on tablet) 1296 * @param isUnread 1297 */ 1298 private void updateBackground(boolean isUnread) { 1299 final boolean isListOnTablet = mTabletDevice && mActivity.getViewMode().isListMode(); 1300 final int background; 1301 if (isUnread) { 1302 if (isListOnTablet) { 1303 if (mSelected) { 1304 background = R.drawable.list_conversation_wide_unread_selected_holo; 1305 } else { 1306 background = R.drawable.conversation_wide_unread_selector; 1307 } 1308 } else { 1309 if (mSelected) { 1310 background = getCheckedActivatedBackground(); 1311 } else { 1312 background = R.drawable.conversation_unread_selector; 1313 } 1314 } 1315 } else { 1316 if (isListOnTablet) { 1317 if (mSelected) { 1318 background = R.drawable.list_conversation_wide_read_selected_holo; 1319 } else { 1320 background = R.drawable.conversation_wide_read_selector; 1321 } 1322 } else { 1323 if (mSelected) { 1324 background = getCheckedActivatedBackground(); 1325 } else { 1326 background = R.drawable.conversation_read_selector; 1327 } 1328 } 1329 } 1330 setBackgroundResource(background); 1331 } 1332 1333 private final int getCheckedActivatedBackground() { 1334 if (isActivated() && mTabletDevice) { 1335 return R.drawable.list_arrow_selected_holo; 1336 } else { 1337 return R.drawable.list_selected_holo; 1338 } 1339 } 1340 1341 /** 1342 * Toggle the check mark on this view and update the conversation or begin 1343 * drag, if drag is enabled. 1344 */ 1345 @Override 1346 public void toggleSelectedStateOrBeginDrag() { 1347 ViewMode mode = mActivity.getViewMode(); 1348 if (mTabletDevice && mode.isListMode()) { 1349 beginDragMode(); 1350 } else { 1351 toggleSelectedState(); 1352 } 1353 } 1354 1355 @Override 1356 public void toggleSelectedState() { 1357 if (mHeader != null && mHeader.conversation != null) { 1358 mSelected = !mSelected; 1359 Conversation conv = mHeader.conversation; 1360 // Set the list position of this item in the conversation 1361 SwipeableListView listView = getListView(); 1362 conv.position = mSelected && listView != null ? listView.getPositionForView(this) 1363 : Conversation.NO_POSITION; 1364 if (mSelectedConversationSet != null) { 1365 mSelectedConversationSet.toggle(conv); 1366 } 1367 if (mSelectedConversationSet.isEmpty()) { 1368 listView.commitDestructiveActions(true); 1369 } 1370 // We update the background after the checked state has changed 1371 // now that we have a selected background asset. Setting the background 1372 // usually waits for a layout pass, but we don't need a full layout, 1373 // just an update to the background. 1374 requestLayout(); 1375 } 1376 } 1377 1378 /** 1379 * Toggle the star on this view and update the conversation. 1380 */ 1381 public void toggleStar() { 1382 mHeader.conversation.starred = !mHeader.conversation.starred; 1383 Bitmap starBitmap = getStarBitmap(); 1384 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1385 + starBitmap.getWidth(), 1386 mCoordinates.starY + starBitmap.getHeight()); 1387 ConversationCursor cursor = (ConversationCursor) mAdapter.getCursor(); 1388 if (cursor != null) { 1389 cursor.updateBoolean(mHeader.conversation, ConversationColumns.STARRED, 1390 mHeader.conversation.starred); 1391 } 1392 } 1393 1394 private boolean isTouchInContactPhoto(float x) { 1395 // Everything before the right edge of contact photo 1396 return (mHeader.gadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 1397 && x < (mCoordinates.contactImagesX + mCoordinates.contactImagesWidth)); 1398 } 1399 1400 private boolean isTouchInStar(float x, float y) { 1401 // Everything after the star and include a touch slop. 1402 return mStarEnabled && x > mCoordinates.starX - sTouchSlop; 1403 } 1404 1405 @Override 1406 public boolean canChildBeDismissed() { 1407 return true; 1408 } 1409 1410 @Override 1411 public void dismiss() { 1412 SwipeableListView listView = getListView(); 1413 if (listView != null) { 1414 getListView().dismissChild(this); 1415 } 1416 } 1417 1418 private boolean onTouchEventNoSwipe(MotionEvent event) { 1419 boolean handled = false; 1420 1421 int x = (int) event.getX(); 1422 int y = (int) event.getY(); 1423 mLastTouchX = x; 1424 mLastTouchY = y; 1425 switch (event.getAction()) { 1426 case MotionEvent.ACTION_DOWN: 1427 if (isTouchInContactPhoto(x) || isTouchInStar(x, y)) { 1428 mDownEvent = true; 1429 handled = true; 1430 } 1431 break; 1432 1433 case MotionEvent.ACTION_CANCEL: 1434 mDownEvent = false; 1435 break; 1436 1437 case MotionEvent.ACTION_UP: 1438 if (mDownEvent) { 1439 if (isTouchInContactPhoto(x)) { 1440 // Touch on the check mark 1441 toggleSelectedState(); 1442 } else if (isTouchInStar(x, y)) { 1443 // Touch on the star 1444 toggleStar(); 1445 } 1446 handled = true; 1447 } 1448 break; 1449 } 1450 1451 if (!handled) { 1452 handled = super.onTouchEvent(event); 1453 } 1454 1455 return handled; 1456 } 1457 1458 /** 1459 * ConversationItemView is given the first chance to handle touch events. 1460 */ 1461 @Override 1462 public boolean onTouchEvent(MotionEvent event) { 1463 int x = (int) event.getX(); 1464 int y = (int) event.getY(); 1465 mLastTouchX = x; 1466 mLastTouchY = y; 1467 if (!mSwipeEnabled) { 1468 return onTouchEventNoSwipe(event); 1469 } 1470 switch (event.getAction()) { 1471 case MotionEvent.ACTION_DOWN: 1472 if (isTouchInContactPhoto(x) || isTouchInStar(x, y)) { 1473 mDownEvent = true; 1474 return true; 1475 } 1476 break; 1477 case MotionEvent.ACTION_UP: 1478 if (mDownEvent) { 1479 if (isTouchInContactPhoto(x)) { 1480 // Touch on the check mark 1481 mDownEvent = false; 1482 toggleSelectedState(); 1483 return true; 1484 } else if (isTouchInStar(x, y)) { 1485 // Touch on the star 1486 mDownEvent = false; 1487 toggleStar(); 1488 return true; 1489 } 1490 } 1491 break; 1492 } 1493 // Let View try to handle it as well. 1494 boolean handled = super.onTouchEvent(event); 1495 if (event.getAction() == MotionEvent.ACTION_DOWN) { 1496 return true; 1497 } 1498 return handled; 1499 } 1500 1501 @Override 1502 public boolean performClick() { 1503 boolean handled = super.performClick(); 1504 SwipeableListView list = getListView(); 1505 if (list != null && list.getAdapter() != null) { 1506 int pos = list.findConversation(this, mHeader.conversation); 1507 list.performItemClick(this, pos, mHeader.conversation.id); 1508 } 1509 return handled; 1510 } 1511 1512 private SwipeableListView getListView() { 1513 SwipeableListView v = (SwipeableListView) ((SwipeableConversationItemView) getParent()) 1514 .getListView(); 1515 if (v == null) { 1516 v = mAdapter.getListView(); 1517 } 1518 return v; 1519 } 1520 1521 /** 1522 * Reset any state associated with this conversation item view so that it 1523 * can be reused. 1524 */ 1525 public void reset() { 1526 setAlpha(1f); 1527 setTranslationX(0f); 1528 mAnimatedHeightFraction = 1.0f; 1529 } 1530 1531 @SuppressWarnings("deprecation") 1532 @Override 1533 public void setTranslationX(float translationX) { 1534 super.setTranslationX(translationX); 1535 1536 final ViewParent vp = getParent(); 1537 if (vp == null || !(vp instanceof SwipeableConversationItemView)) { 1538 LogUtils.w(LOG_TAG, 1539 "CIV.setTranslationX unexpected ConversationItemView parent: %s x=%s", 1540 vp, translationX); 1541 } 1542 1543 // When a list item is being swiped or animated, ensure that the hosting view has a 1544 // background color set. We only enable the background during the X-translation effect to 1545 // reduce overdraw during normal list scrolling. 1546 final SwipeableConversationItemView parent = (SwipeableConversationItemView) vp; 1547 if (translationX != 0f) { 1548 parent.setBackgroundResource(R.color.swiped_bg_color); 1549 } else { 1550 parent.setBackgroundDrawable(null); 1551 } 1552 } 1553 1554 /** 1555 * Grow the height of the item and fade it in when bringing a conversation 1556 * back from a destructive action. 1557 */ 1558 public Animator createSwipeUndoAnimation() { 1559 ObjectAnimator undoAnimator = createTranslateXAnimation(true); 1560 return undoAnimator; 1561 } 1562 1563 /** 1564 * Grow the height of the item and fade it in when bringing a conversation 1565 * back from a destructive action. 1566 */ 1567 public Animator createUndoAnimation() { 1568 ObjectAnimator height = createHeightAnimation(true); 1569 Animator fade = ObjectAnimator.ofFloat(this, "alpha", 0, 1.0f); 1570 fade.setDuration(sShrinkAnimationDuration); 1571 fade.setInterpolator(new DecelerateInterpolator(2.0f)); 1572 AnimatorSet transitionSet = new AnimatorSet(); 1573 transitionSet.playTogether(height, fade); 1574 transitionSet.addListener(new HardwareLayerEnabler(this)); 1575 return transitionSet; 1576 } 1577 1578 /** 1579 * Grow the height of the item and fade it in when bringing a conversation 1580 * back from a destructive action. 1581 */ 1582 public Animator createDestroyWithSwipeAnimation() { 1583 ObjectAnimator slide = createTranslateXAnimation(false); 1584 ObjectAnimator height = createHeightAnimation(false); 1585 AnimatorSet transitionSet = new AnimatorSet(); 1586 transitionSet.playSequentially(slide, height); 1587 return transitionSet; 1588 } 1589 1590 private ObjectAnimator createTranslateXAnimation(boolean show) { 1591 SwipeableListView parent = getListView(); 1592 // If we can't get the parent...we have bigger problems. 1593 int width = parent != null ? parent.getMeasuredWidth() : 0; 1594 final float start = show ? width : 0f; 1595 final float end = show ? 0f : width; 1596 ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); 1597 slide.setInterpolator(new DecelerateInterpolator(2.0f)); 1598 slide.setDuration(sSlideAnimationDuration); 1599 return slide; 1600 } 1601 1602 public Animator createDestroyAnimation() { 1603 return createHeightAnimation(false); 1604 } 1605 1606 private ObjectAnimator createHeightAnimation(boolean show) { 1607 final float start = show ? 0f : 1.0f; 1608 final float end = show ? 1.0f : 0f; 1609 ObjectAnimator height = ObjectAnimator.ofFloat(this, "animatedHeightFraction", start, end); 1610 height.setInterpolator(new DecelerateInterpolator(2.0f)); 1611 height.setDuration(sShrinkAnimationDuration); 1612 return height; 1613 } 1614 1615 // Used by animator 1616 public void setAnimatedHeightFraction(float height) { 1617 mAnimatedHeightFraction = height; 1618 requestLayout(); 1619 } 1620 1621 @Override 1622 public View getSwipeableView() { 1623 return this; 1624 } 1625 1626 /** 1627 * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag. 1628 */ 1629 private void beginDragMode() { 1630 if (mLastTouchX < 0 || mLastTouchY < 0) { 1631 return; 1632 } 1633 // If this is already checked, don't bother unchecking it! 1634 if (!mSelected) { 1635 toggleSelectedState(); 1636 } 1637 1638 // Clip data has form: [conversations_uri, conversationId1, 1639 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 1640 final int count = mSelectedConversationSet.size(); 1641 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 1642 1643 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 1644 Conversation.MOVE_CONVERSATIONS_URI); 1645 for (Conversation conversation : mSelectedConversationSet.values()) { 1646 data.addItem(new Item(String.valueOf(conversation.position))); 1647 } 1648 // Protect against non-existent views: only happens for monkeys 1649 final int width = this.getWidth(); 1650 final int height = this.getHeight(); 1651 final boolean isDimensionNegative = (width < 0) || (height < 0); 1652 if (isDimensionNegative) { 1653 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 1654 + "width=%d, height=%d", width, height); 1655 return; 1656 } 1657 mActivity.startDragMode(); 1658 // Start drag mode 1659 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 1660 } 1661 1662 /** 1663 * Handles the drag event. 1664 * 1665 * @param event the drag event to be handled 1666 */ 1667 @Override 1668 public boolean onDragEvent(DragEvent event) { 1669 switch (event.getAction()) { 1670 case DragEvent.ACTION_DRAG_ENDED: 1671 mActivity.stopDragMode(); 1672 return true; 1673 } 1674 return false; 1675 } 1676 1677 private class ShadowBuilder extends DragShadowBuilder { 1678 private final Drawable mBackground; 1679 1680 private final View mView; 1681 private final String mDragDesc; 1682 private final int mTouchX; 1683 private final int mTouchY; 1684 private int mDragDescX; 1685 private int mDragDescY; 1686 1687 public ShadowBuilder(View view, int count, int touchX, int touchY) { 1688 super(view); 1689 mView = view; 1690 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 1691 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 1692 mTouchX = touchX; 1693 mTouchY = touchY; 1694 } 1695 1696 @Override 1697 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 1698 int width = mView.getWidth(); 1699 int height = mView.getHeight(); 1700 mDragDescX = mCoordinates.sendersX; 1701 mDragDescY = getPadding(height, (int) mCoordinates.subjectFontSize) 1702 - mCoordinates.subjectAscent; 1703 shadowSize.set(width, height); 1704 shadowTouchPoint.set(mTouchX, mTouchY); 1705 } 1706 1707 @Override 1708 public void onDrawShadow(Canvas canvas) { 1709 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 1710 mBackground.draw(canvas); 1711 sPaint.setTextSize(mCoordinates.subjectFontSize); 1712 canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); 1713 } 1714 } 1715 1716 @Override 1717 public float getMinAllowScrollDistance() { 1718 return sScrollSlop; 1719 } 1720 1721 @Override 1722 public void onImagesResolved() { 1723 // Do nothing 1724 } 1725} 1726