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 18 package com.android.mail.browse; 19 20 import android.animation.Animator; 21 import android.animation.Animator.AnimatorListener; 22 import android.animation.AnimatorListenerAdapter; 23 import android.animation.AnimatorSet; 24 import android.animation.ObjectAnimator; 25 import android.content.ClipData; 26 import android.content.ClipData.Item; 27 import android.content.Context; 28 import android.content.res.Resources; 29 import android.graphics.Bitmap; 30 import android.graphics.BitmapFactory; 31 import android.graphics.Canvas; 32 import android.graphics.Color; 33 import android.graphics.LinearGradient; 34 import android.graphics.Matrix; 35 import android.graphics.Paint; 36 import android.graphics.Point; 37 import android.graphics.Rect; 38 import android.graphics.Shader; 39 import android.graphics.Typeface; 40 import android.graphics.drawable.Drawable; 41 import android.text.Layout.Alignment; 42 import android.text.Spannable; 43 import android.text.SpannableString; 44 import android.text.SpannableStringBuilder; 45 import android.text.StaticLayout; 46 import android.text.TextPaint; 47 import android.text.TextUtils; 48 import android.text.TextUtils.TruncateAt; 49 import android.text.format.DateUtils; 50 import android.text.style.CharacterStyle; 51 import android.text.style.ForegroundColorSpan; 52 import android.text.style.TextAppearanceSpan; 53 import android.text.util.Rfc822Token; 54 import android.text.util.Rfc822Tokenizer; 55 import android.util.SparseArray; 56 import android.util.TypedValue; 57 import android.view.DragEvent; 58 import android.view.MotionEvent; 59 import android.view.View; 60 import android.view.ViewGroup; 61 import android.view.ViewParent; 62 import android.view.animation.DecelerateInterpolator; 63 import android.view.animation.LinearInterpolator; 64 import android.widget.AbsListView; 65 import android.widget.AbsListView.OnScrollListener; 66 import android.widget.TextView; 67 68 import com.android.mail.R; 69 import com.android.mail.R.drawable; 70 import com.android.mail.R.integer; 71 import com.android.mail.analytics.Analytics; 72 import com.android.mail.bitmap.AttachmentDrawable; 73 import com.android.mail.bitmap.AttachmentGridDrawable; 74 import com.android.mail.browse.ConversationItemViewModel.SenderFragment; 75 import com.android.mail.perf.Timer; 76 import com.android.mail.photomanager.ContactPhotoManager; 77 import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier; 78 import com.android.mail.photomanager.PhotoManager.PhotoIdentifier; 79 import com.android.mail.providers.Address; 80 import com.android.mail.providers.Attachment; 81 import com.android.mail.providers.Conversation; 82 import com.android.mail.providers.Folder; 83 import com.android.mail.providers.UIProvider; 84 import com.android.mail.providers.UIProvider.AttachmentRendition; 85 import com.android.mail.providers.UIProvider.ConversationColumns; 86 import com.android.mail.providers.UIProvider.ConversationListIcon; 87 import com.android.mail.providers.UIProvider.FolderType; 88 import com.android.mail.ui.AnimatedAdapter; 89 import com.android.mail.ui.AnimatedAdapter.ConversationListListener; 90 import com.android.mail.ui.ControllableActivity; 91 import com.android.mail.ui.ConversationSelectionSet; 92 import com.android.mail.ui.DividedImageCanvas; 93 import com.android.mail.ui.DividedImageCanvas.InvalidateCallback; 94 import com.android.mail.ui.FolderDisplayer; 95 import com.android.mail.ui.SwipeableItemView; 96 import com.android.mail.ui.SwipeableListView; 97 import com.android.mail.ui.ViewMode; 98 import com.android.mail.utils.FolderUri; 99 import com.android.mail.utils.HardwareLayerEnabler; 100 import com.android.mail.utils.LogTag; 101 import com.android.mail.utils.LogUtils; 102 import com.android.mail.utils.Utils; 103 import com.google.common.annotations.VisibleForTesting; 104 import com.google.common.collect.Lists; 105 106 import java.util.ArrayList; 107 import java.util.List; 108 109 public class ConversationItemView extends View 110 implements SwipeableItemView, ToggleableItem, InvalidateCallback, OnScrollListener { 111 112 // Timer. 113 private static int sLayoutCount = 0; 114 private static Timer sTimer; // Create the sTimer here if you need to do 115 // perf analysis. 116 private static final int PERF_LAYOUT_ITERATIONS = 50; 117 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 118 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 119 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 120 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 121 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 122 private static final String LOG_TAG = LogTag.getLogTag(); 123 124 // Static bitmaps. 125 private static Bitmap STAR_OFF; 126 private static Bitmap STAR_ON; 127 private static Bitmap CHECK; 128 private static Bitmap ATTACHMENT; 129 private static Bitmap ONLY_TO_ME; 130 private static Bitmap TO_ME_AND_OTHERS; 131 private static Bitmap IMPORTANT_ONLY_TO_ME; 132 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 133 private static Bitmap IMPORTANT_TO_OTHERS; 134 private static Bitmap STATE_REPLIED; 135 private static Bitmap STATE_FORWARDED; 136 private static Bitmap STATE_REPLIED_AND_FORWARDED; 137 private static Bitmap STATE_CALENDAR_INVITE; 138 private static Bitmap VISIBLE_CONVERSATION_CARET; 139 private static Drawable RIGHT_EDGE_TABLET; 140 private static Drawable PLACEHOLDER; 141 private static Drawable PROGRESS_BAR; 142 143 private static String sSendersSplitToken; 144 private static String sElidedPaddingToken; 145 146 // Static colors. 147 private static int sSendersTextColorRead; 148 private static int sSendersTextColorUnread; 149 private static int sDateTextColor; 150 private static int sStarTouchSlop; 151 private static int sSenderImageTouchSlop; 152 private static int sShrinkAnimationDuration; 153 private static int sSlideAnimationDuration; 154 private static int sOverflowCountMax; 155 private static int sCabAnimationDuration; 156 157 // Static paints. 158 private static final TextPaint sPaint = new TextPaint(); 159 private static final TextPaint sFoldersPaint = new TextPaint(); 160 private static final Paint sCheckBackgroundPaint = new Paint(); 161 162 // Backgrounds for different states. 163 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 164 165 // Dimensions and coordinates. 166 private int mViewWidth = -1; 167 /** The view mode at which we calculated mViewWidth previously. */ 168 private int mPreviousMode; 169 170 private int mInfoIconX; 171 private int mDateX; 172 private int mPaperclipX; 173 private int mSendersWidth; 174 175 /** Whether we are on a tablet device or not */ 176 private final boolean mTabletDevice; 177 /** Whether we are on an expansive tablet */ 178 private final boolean mIsExpansiveTablet; 179 /** When in conversation mode, true if the list is hidden */ 180 private final boolean mListCollapsible; 181 182 @VisibleForTesting 183 ConversationItemViewCoordinates mCoordinates; 184 185 private ConversationItemViewCoordinates.Config mConfig; 186 187 private final Context mContext; 188 189 public ConversationItemViewModel mHeader; 190 private boolean mDownEvent; 191 private boolean mSelected = false; 192 private ConversationSelectionSet mSelectedConversationSet; 193 private Folder mDisplayedFolder; 194 private boolean mStarEnabled; 195 private boolean mSwipeEnabled; 196 private int mLastTouchX; 197 private int mLastTouchY; 198 private AnimatedAdapter mAdapter; 199 private float mAnimatedHeightFraction = 1.0f; 200 private final String mAccount; 201 private ControllableActivity mActivity; 202 private ConversationListListener mConversationListListener; 203 private final TextView mSubjectTextView; 204 private final TextView mSendersTextView; 205 private int mGadgetMode; 206 private boolean mAttachmentPreviewsEnabled; 207 private boolean mParallaxSpeedAlternative; 208 private boolean mParallaxDirectionAlternative; 209 private final DividedImageCanvas mContactImagesHolder; 210 private static ContactPhotoManager sContactPhotoManager; 211 212 private static int sFoldersLeftPadding; 213 private static TextAppearanceSpan sSubjectTextUnreadSpan; 214 private static TextAppearanceSpan sSubjectTextReadSpan; 215 private static ForegroundColorSpan sSnippetTextUnreadSpan; 216 private static ForegroundColorSpan sSnippetTextReadSpan; 217 private static int sScrollSlop; 218 private static CharacterStyle sActivatedTextSpan; 219 220 private final AttachmentGridDrawable mAttachmentsView; 221 222 private final Matrix mPhotoFlipMatrix = new Matrix(); 223 private final Matrix mCheckMatrix = new Matrix(); 224 225 private final CabAnimator mPhotoFlipAnimator; 226 227 /** 228 * The conversation id, if this conversation was selected the last time we were in a selection 229 * mode. This is reset after any animations complete upon exiting the selection mode. 230 */ 231 private long mLastSelectedId = -1; 232 233 /** The resource id of the color to use to override the background. */ 234 private int mBackgroundOverrideResId = -1; 235 /** The bitmap to use, or <code>null</code> for the default */ 236 private Bitmap mPhotoBitmap = null; 237 private Rect mPhotoRect = null; 238 239 /** 240 * A listener for clicks on the various areas of a conversation item. 241 */ 242 public interface ConversationItemAreaClickListener { 243 /** Called when the info icon is clicked. */ 244 void onInfoIconClicked(); 245 246 /** Called when the star is clicked. */ 247 void onStarClicked(); 248 } 249 250 /** If set, it will steal all clicks for which the interface has a click method. */ 251 private ConversationItemAreaClickListener mConversationItemAreaClickListener = null; 252 253 static { 254 sPaint.setAntiAlias(true); 255 sFoldersPaint.setAntiAlias(true); 256 257 sCheckBackgroundPaint.setColor(Color.GRAY); 258 } 259 260 public static void setScrollStateChanged(final int scrollState) { 261 if (sContactPhotoManager == null) { 262 return; 263 } 264 final boolean flinging = scrollState == OnScrollListener.SCROLL_STATE_FLING; 265 266 if (flinging) { 267 sContactPhotoManager.pause(); 268 } else { 269 sContactPhotoManager.resume(); 270 } 271 } 272 273 /** 274 * Handles displaying folders in a conversation header view. 275 */ 276 static class ConversationItemFolderDisplayer extends FolderDisplayer { 277 278 private int mFoldersCount; 279 280 public ConversationItemFolderDisplayer(Context context) { 281 super(context); 282 } 283 284 @Override 285 public void loadConversationFolders(Conversation conv, final FolderUri ignoreFolderUri, 286 final int ignoreFolderType) { 287 super.loadConversationFolders(conv, ignoreFolderUri, ignoreFolderType); 288 mFoldersCount = mFoldersSortedSet.size(); 289 } 290 291 @Override 292 public void reset() { 293 super.reset(); 294 mFoldersCount = 0; 295 } 296 297 public boolean hasVisibleFolders() { 298 return mFoldersCount > 0; 299 } 300 301 private int measureFolders(int availableSpace, int cellSize) { 302 int totalWidth = 0; 303 boolean firstTime = true; 304 for (Folder f : mFoldersSortedSet) { 305 final String folderString = f.name; 306 int width = (int) sFoldersPaint.measureText(folderString) + cellSize; 307 if (firstTime) { 308 firstTime = false; 309 } else { 310 width += sFoldersLeftPadding; 311 } 312 totalWidth += width; 313 if (totalWidth > availableSpace) { 314 break; 315 } 316 } 317 318 return totalWidth; 319 } 320 321 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates) { 322 if (mFoldersCount == 0) { 323 return; 324 } 325 final int xMinStart = coordinates.foldersX; 326 final int xEnd = coordinates.foldersXEnd; 327 final int y = coordinates.foldersY; 328 final int height = coordinates.foldersHeight; 329 int textBottomPadding = coordinates.foldersTextBottomPadding; 330 331 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 332 sFoldersPaint.setTypeface(coordinates.foldersTypeface); 333 334 // Initialize space and cell size based on the current mode. 335 int availableSpace = xEnd - xMinStart; 336 int maxFoldersCount = availableSpace / coordinates.getFolderMinimumWidth(); 337 int foldersCount = Math.min(mFoldersCount, maxFoldersCount); 338 int averageWidth = availableSpace / foldersCount; 339 int cellSize = coordinates.getFolderCellWidth(); 340 341 // TODO(ath): sFoldersPaint.measureText() is done 3x in this method. stop that. 342 // Extra credit: maybe cache results across items as long as font size doesn't change. 343 344 final int totalWidth = measureFolders(availableSpace, cellSize); 345 int xStart = xEnd - Math.min(availableSpace, totalWidth); 346 final boolean overflow = totalWidth > availableSpace; 347 348 // Second pass to draw folders. 349 int i = 0; 350 for (Folder f : mFoldersSortedSet) { 351 if (availableSpace <= 0) { 352 break; 353 } 354 final String folderString = f.name; 355 final int fgColor = f.getForegroundColor(mDefaultFgColor); 356 final int bgColor = f.getBackgroundColor(mDefaultBgColor); 357 boolean labelTooLong = false; 358 final int textW = (int) sFoldersPaint.measureText(folderString); 359 int width = textW + cellSize + sFoldersLeftPadding; 360 361 if (overflow && width > averageWidth) { 362 if (i < foldersCount - 1) { 363 width = averageWidth; 364 } else { 365 // allow the last label to take all remaining space 366 // (and don't let it make room for padding) 367 width = availableSpace + sFoldersLeftPadding; 368 } 369 labelTooLong = true; 370 } 371 372 // TODO (mindyp): how to we get this? 373 final boolean isMuted = false; 374 // labelValues.folderId == 375 // sGmail.getFolderMap(mAccount).getFolderIdIgnored(); 376 377 // Draw the box. 378 sFoldersPaint.setColor(bgColor); 379 sFoldersPaint.setStyle(Paint.Style.FILL); 380 canvas.drawRect(xStart, y, xStart + width - sFoldersLeftPadding, 381 y + height, sFoldersPaint); 382 383 // Draw the text. 384 final int padding = cellSize / 2; 385 sFoldersPaint.setColor(fgColor); 386 sFoldersPaint.setStyle(Paint.Style.FILL); 387 if (labelTooLong) { 388 final int rightBorder = xStart + width - sFoldersLeftPadding - padding; 389 final Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, 390 y, fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP); 391 sFoldersPaint.setShader(shader); 392 } 393 canvas.drawText(folderString, xStart + padding, y + height - textBottomPadding, 394 sFoldersPaint); 395 if (labelTooLong) { 396 sFoldersPaint.setShader(null); 397 } 398 399 availableSpace -= width; 400 xStart += width; 401 i++; 402 } 403 } 404 } 405 406 public ConversationItemView(Context context, String account) { 407 super(context); 408 Utils.traceBeginSection("CIVC constructor"); 409 setClickable(true); 410 setLongClickable(true); 411 mContext = context.getApplicationContext(); 412 final Resources res = mContext.getResources(); 413 mTabletDevice = Utils.useTabletUI(res); 414 mIsExpansiveTablet = 415 mTabletDevice ? res.getBoolean(R.bool.use_expansive_tablet_ui) : false; 416 mListCollapsible = res.getBoolean(R.bool.list_collapsible); 417 mAccount = account; 418 419 if (STAR_OFF == null) { 420 // Initialize static bitmaps. 421 STAR_OFF = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_off); 422 STAR_ON = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_on); 423 CHECK = BitmapFactory.decodeResource(res, R.drawable.ic_avatar_check); 424 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light); 425 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 426 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 427 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 428 R.drawable.ic_email_caret_double_important_unread); 429 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 430 R.drawable.ic_email_caret_single_important_unread); 431 IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res, 432 R.drawable.ic_email_caret_none_important_unread); 433 STATE_REPLIED = 434 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 435 STATE_FORWARDED = 436 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 437 STATE_REPLIED_AND_FORWARDED = 438 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 439 STATE_CALENDAR_INVITE = 440 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 441 VISIBLE_CONVERSATION_CARET = BitmapFactory.decodeResource(res, R.drawable.caret_grey); 442 RIGHT_EDGE_TABLET = res.getDrawable(R.drawable.list_edge_tablet); 443 PLACEHOLDER = res.getDrawable(drawable.ic_attachment_load); 444 PROGRESS_BAR = res.getDrawable(drawable.progress_holo); 445 446 // Initialize colors. 447 sActivatedTextSpan = CharacterStyle.wrap(new ForegroundColorSpan( 448 res.getColor(R.color.senders_text_color_read))); 449 sSendersTextColorRead = res.getColor(R.color.senders_text_color_read); 450 sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread); 451 sSubjectTextUnreadSpan = new TextAppearanceSpan(mContext, 452 R.style.SubjectAppearanceUnreadStyle); 453 sSubjectTextReadSpan = new TextAppearanceSpan(mContext, 454 R.style.SubjectAppearanceReadStyle); 455 sSnippetTextUnreadSpan = 456 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_unread)); 457 sSnippetTextReadSpan = 458 new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_read)); 459 sDateTextColor = res.getColor(R.color.date_text_color); 460 sStarTouchSlop = res.getDimensionPixelSize(R.dimen.star_touch_slop); 461 sSenderImageTouchSlop = res.getDimensionPixelSize(R.dimen.sender_image_touch_slop); 462 sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration); 463 sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration); 464 // Initialize static color. 465 sSendersSplitToken = res.getString(R.string.senders_split_token); 466 sElidedPaddingToken = res.getString(R.string.elided_padding_token); 467 sScrollSlop = res.getInteger(R.integer.swipeScrollSlop); 468 sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding); 469 sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context); 470 sOverflowCountMax = res.getInteger(integer.ap_overflow_max_count); 471 sCabAnimationDuration = 472 res.getInteger(R.integer.conv_item_view_cab_anim_duration); 473 } 474 475 mPhotoFlipAnimator = new CabAnimator("photoFlipFraction", 0, 2, 476 sCabAnimationDuration) { 477 @Override 478 public void invalidateArea() { 479 final int left = mCoordinates.contactImagesX; 480 final int right = left + mContactImagesHolder.getWidth(); 481 final int top = mCoordinates.contactImagesY; 482 final int bottom = top + mContactImagesHolder.getHeight(); 483 invalidate(left, top, right, bottom); 484 } 485 }; 486 487 mSendersTextView = new TextView(mContext); 488 mSendersTextView.setIncludeFontPadding(false); 489 490 mSubjectTextView = new TextView(mContext); 491 mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END); 492 mSubjectTextView.setIncludeFontPadding(false); 493 494 mContactImagesHolder = new DividedImageCanvas(context, new InvalidateCallback() { 495 @Override 496 public void invalidate() { 497 if (mCoordinates == null) { 498 return; 499 } 500 ConversationItemView.this.invalidate(mCoordinates.contactImagesX, 501 mCoordinates.contactImagesY, 502 mCoordinates.contactImagesX + mCoordinates.contactImagesWidth, 503 mCoordinates.contactImagesY + mCoordinates.contactImagesHeight); 504 } 505 }); 506 507 mAttachmentsView = new AttachmentGridDrawable(res, PLACEHOLDER, PROGRESS_BAR); 508 mAttachmentsView.setCallback(this); 509 510 Utils.traceEndSection(); 511 } 512 513 public void bind(final Conversation conversation, final ControllableActivity activity, 514 final ConversationListListener conversationListListener, 515 final ConversationSelectionSet set, final Folder folder, 516 final int checkboxOrSenderImage, final boolean showAttachmentPreviews, 517 final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative, 518 final boolean swipeEnabled, final boolean priorityArrowEnabled, 519 final AnimatedAdapter adapter) { 520 Utils.traceBeginSection("CIVC.bind"); 521 bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity, 522 conversationListListener, null /* conversationItemAreaClickListener */, set, folder, 523 checkboxOrSenderImage, showAttachmentPreviews, parallaxSpeedAlternative, 524 parallaxDirectionAlternative, swipeEnabled, priorityArrowEnabled, adapter, 525 -1 /* backgroundOverrideResId */, 526 null /* photoBitmap */); 527 Utils.traceEndSection(); 528 } 529 530 public void bindAd(final ConversationItemViewModel conversationItemViewModel, 531 final ControllableActivity activity, 532 final ConversationListListener conversationListListener, 533 final ConversationItemAreaClickListener conversationItemAreaClickListener, 534 final Folder folder, final int checkboxOrSenderImage, final AnimatedAdapter adapter, 535 final int backgroundOverrideResId, final Bitmap photoBitmap) { 536 Utils.traceBeginSection("CIVC.bindAd"); 537 bind(conversationItemViewModel, activity, conversationListListener, 538 conversationItemAreaClickListener, null /* set */, folder, checkboxOrSenderImage, 539 false /* attachment previews */, false /* parallax */, false /* parallax */, 540 true /* swipeEnabled */, false /* priorityArrowEnabled */, adapter, 541 backgroundOverrideResId, photoBitmap); 542 Utils.traceEndSection(); 543 } 544 545 private void bind(final ConversationItemViewModel header, final ControllableActivity activity, 546 final ConversationListListener conversationListListener, 547 final ConversationItemAreaClickListener conversationItemAreaClickListener, 548 final ConversationSelectionSet set, final Folder folder, 549 final int checkboxOrSenderImage, final boolean showAttachmentPreviews, 550 final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative, 551 boolean swipeEnabled, final boolean priorityArrowEnabled, final AnimatedAdapter adapter, 552 final int backgroundOverrideResId, final Bitmap photoBitmap) { 553 mBackgroundOverrideResId = backgroundOverrideResId; 554 mPhotoBitmap = photoBitmap; 555 mConversationItemAreaClickListener = conversationItemAreaClickListener; 556 557 if (mHeader != null) { 558 // If this was previously bound to a different conversation, remove any contact photo 559 // manager requests. 560 if (header.conversation.id != mHeader.conversation.id || 561 (mHeader.displayableSenderNames != null && !mHeader.displayableSenderNames 562 .equals(header.displayableSenderNames))) { 563 ArrayList<String> divisionIds = mContactImagesHolder.getDivisionIds(); 564 if (divisionIds != null) { 565 mContactImagesHolder.reset(); 566 for (int pos = 0; pos < divisionIds.size(); pos++) { 567 sContactPhotoManager.removePhoto(ContactPhotoManager.generateHash( 568 mContactImagesHolder, pos, divisionIds.get(pos))); 569 } 570 } 571 } 572 573 // If this was previously bound to a different conversation, 574 // remove any attachment preview manager requests. 575 if (header.conversation.id != mHeader.conversation.id 576 || header.conversation.attachmentPreviewsCount 577 != mHeader.conversation.attachmentPreviewsCount 578 || !header.conversation.getAttachmentPreviewUris() 579 .equals(mHeader.conversation.getAttachmentPreviewUris())) { 580 581 // unbind the attachments view (releasing bitmap references) 582 // (this also cancels all async tasks) 583 for (int i = 0, len = mAttachmentsView.getCount(); i < len; i++) { 584 mAttachmentsView.getOrCreateDrawable(i).unbind(); 585 } 586 // reset the grid, as the newly bound item may have a different attachment count 587 mAttachmentsView.setCount(0); 588 } 589 590 if (header.conversation.id != mHeader.conversation.id) { 591 // Stop the photo flip animation 592 mPhotoFlipAnimator.stopAnimation(); 593 } 594 } 595 mCoordinates = null; 596 mHeader = header; 597 mActivity = activity; 598 mConversationListListener = conversationListListener; 599 mSelectedConversationSet = set; 600 mDisplayedFolder = folder; 601 mStarEnabled = folder != null && !folder.isTrash(); 602 mSwipeEnabled = swipeEnabled; 603 mAdapter = adapter; 604 mAttachmentsView.setBitmapCache(mAdapter.getBitmapCache()); 605 mAttachmentsView.setDecodeAggregator(mAdapter.getDecodeAggregator()); 606 607 if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) { 608 mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO; 609 } else { 610 mGadgetMode = ConversationItemViewCoordinates.GADGET_NONE; 611 } 612 613 mAttachmentPreviewsEnabled = showAttachmentPreviews; 614 mParallaxSpeedAlternative = parallaxSpeedAlternative; 615 mParallaxDirectionAlternative = parallaxDirectionAlternative; 616 617 // Initialize folder displayer. 618 if (mHeader.folderDisplayer == null) { 619 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext); 620 } else { 621 mHeader.folderDisplayer.reset(); 622 } 623 624 final int ignoreFolderType; 625 if (mDisplayedFolder.isInbox()) { 626 ignoreFolderType = FolderType.INBOX; 627 } else { 628 ignoreFolderType = -1; 629 } 630 631 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, 632 mDisplayedFolder.folderUri, ignoreFolderType); 633 634 if (mHeader.dateOverrideText == null) { 635 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 636 mHeader.conversation.dateMs); 637 } else { 638 mHeader.dateText = mHeader.dateOverrideText; 639 } 640 641 mConfig = new ConversationItemViewCoordinates.Config() 642 .withGadget(mGadgetMode) 643 .withAttachmentPreviews(getAttachmentPreviewsMode()); 644 if (header.folderDisplayer.hasVisibleFolders()) { 645 mConfig.showFolders(); 646 } 647 if (header.hasBeenForwarded || header.hasBeenRepliedTo || header.isInvite) { 648 mConfig.showReplyState(); 649 } 650 if (mHeader.conversation.color != 0) { 651 mConfig.showColorBlock(); 652 } 653 // Personal level. 654 mHeader.personalLevelBitmap = null; 655 if (true) { // TODO: hook this up to a setting 656 final int personalLevel = mHeader.conversation.personalLevel; 657 final boolean isImportant = 658 mHeader.conversation.priority == UIProvider.ConversationPriority.IMPORTANT; 659 final boolean useImportantMarkers = isImportant && priorityArrowEnabled; 660 661 if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 662 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 663 : ONLY_TO_ME; 664 } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 665 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 666 : TO_ME_AND_OTHERS; 667 } else if (useImportantMarkers) { 668 mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS; 669 } 670 } 671 if (mHeader.personalLevelBitmap != null) { 672 mConfig.showPersonalIndicator(); 673 } 674 675 mAttachmentsView.setOverflowText(null); 676 677 setContentDescription(); 678 requestLayout(); 679 } 680 681 @Override 682 public void invalidateDrawable(Drawable who) { 683 boolean handled = false; 684 if (mCoordinates != null) { 685 if (mAttachmentsView.equals(who)) { 686 final Rect r = new Rect(who.getBounds()); 687 r.offset(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY); 688 ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom); 689 handled = true; 690 } 691 } 692 if (!handled) { 693 super.invalidateDrawable(who); 694 } 695 } 696 697 /** 698 * Get the Conversation object associated with this view. 699 */ 700 public Conversation getConversation() { 701 return mHeader.conversation; 702 } 703 704 private static void startTimer(String tag) { 705 if (sTimer != null) { 706 sTimer.start(tag); 707 } 708 } 709 710 private static void pauseTimer(String tag) { 711 if (sTimer != null) { 712 sTimer.pause(tag); 713 } 714 } 715 716 @Override 717 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 718 Utils.traceBeginSection("CIVC.measure"); 719 final int wSize = MeasureSpec.getSize(widthMeasureSpec); 720 721 final int currentMode = mActivity.getViewMode().getMode(); 722 if (wSize != mViewWidth || mPreviousMode != currentMode) { 723 mViewWidth = wSize; 724 mPreviousMode = currentMode; 725 } 726 mHeader.viewWidth = mViewWidth; 727 728 mConfig.updateWidth(wSize).setViewMode(currentMode); 729 730 Resources res = getResources(); 731 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 732 733 mCoordinates = ConversationItemViewCoordinates.forConfig(mContext, mConfig, 734 mAdapter.getCoordinatesCache()); 735 736 if (mPhotoBitmap != null) { 737 mPhotoRect = new Rect(0, 0, mCoordinates.contactImagesWidth, 738 mCoordinates.contactImagesHeight); 739 } 740 741 final int h = (mAnimatedHeightFraction != 1.0f) ? 742 Math.round(mAnimatedHeightFraction * mCoordinates.height) : mCoordinates.height; 743 setMeasuredDimension(mConfig.getWidth(), h); 744 Utils.traceEndSection(); 745 } 746 747 @Override 748 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 749 startTimer(PERF_TAG_LAYOUT); 750 Utils.traceBeginSection("CIVC.layout"); 751 752 super.onLayout(changed, left, top, right, bottom); 753 754 Utils.traceBeginSection("text and bitmaps"); 755 calculateTextsAndBitmaps(); 756 Utils.traceEndSection(); 757 758 Utils.traceBeginSection("coordinates"); 759 calculateCoordinates(); 760 Utils.traceEndSection(); 761 762 // Subject. 763 createSubject(mHeader.unread); 764 765 if (!mHeader.isLayoutValid()) { 766 setContentDescription(); 767 } 768 mHeader.validate(); 769 770 pauseTimer(PERF_TAG_LAYOUT); 771 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 772 sTimer.dumpResults(); 773 sTimer = new Timer(); 774 sLayoutCount = 0; 775 } 776 Utils.traceEndSection(); 777 } 778 779 private void setContentDescription() { 780 if (mActivity.isAccessibilityEnabled()) { 781 mHeader.resetContentDescription(); 782 setContentDescription(mHeader.getContentDescription(mContext)); 783 } 784 } 785 786 @Override 787 public void setBackgroundResource(int resourceId) { 788 Utils.traceBeginSection("set background resource"); 789 Drawable drawable = mBackgrounds.get(resourceId); 790 if (drawable == null) { 791 drawable = getResources().getDrawable(resourceId); 792 mBackgrounds.put(resourceId, drawable); 793 } 794 if (getBackground() != drawable) { 795 super.setBackgroundDrawable(drawable); 796 } 797 Utils.traceEndSection(); 798 } 799 800 private void calculateTextsAndBitmaps() { 801 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 802 803 if (mSelectedConversationSet != null) { 804 mSelected = mSelectedConversationSet.contains(mHeader.conversation); 805 } 806 setSelected(mSelected); 807 mHeader.gadgetMode = mGadgetMode; 808 809 final boolean isUnread = mHeader.unread; 810 updateBackground(isUnread); 811 812 mHeader.sendersDisplayText = new SpannableStringBuilder(); 813 mHeader.styledSendersString = null; 814 815 mHeader.hasDraftMessage = mHeader.conversation.numDrafts() > 0; 816 817 // Parse senders fragments. 818 if (mHeader.preserveSendersText) { 819 // This is a special view that doesn't need special sender formatting 820 mHeader.sendersDisplayText = new SpannableStringBuilder(mHeader.sendersText); 821 loadSenderImages(); 822 } else if (mHeader.conversation.conversationInfo != null) { 823 // This is Gmail 824 Context context = getContext(); 825 mHeader.messageInfoString = SendersView 826 .createMessageInfo(context, mHeader.conversation, true); 827 int maxChars = ConversationItemViewCoordinates.getSendersLength(context, 828 mCoordinates.getMode(), mHeader.conversation.hasAttachments); 829 mHeader.displayableSenderEmails = new ArrayList<String>(); 830 mHeader.displayableSenderNames = new ArrayList<String>(); 831 mHeader.styledSenders = new ArrayList<SpannableString>(); 832 SendersView.format(context, mHeader.conversation.conversationInfo, 833 mHeader.messageInfoString.toString(), maxChars, mHeader.styledSenders, 834 mHeader.displayableSenderNames, mHeader.displayableSenderEmails, mAccount, 835 true); 836 837 if (mHeader.displayableSenderEmails.isEmpty() && mHeader.hasDraftMessage) { 838 mHeader.displayableSenderEmails.add(mAccount); 839 mHeader.displayableSenderNames.add(mAccount); 840 } 841 842 // If we have displayable senders, load their thumbnails 843 loadSenderImages(); 844 } else { 845 // This is Email 846 SendersView.formatSenders(mHeader, getContext(), true); 847 if (!TextUtils.isEmpty(mHeader.conversation.senders)) { 848 mHeader.displayableSenderEmails = new ArrayList<String>(); 849 mHeader.displayableSenderNames = new ArrayList<String>(); 850 851 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(mHeader.conversation.senders); 852 for (int i = 0; i < tokens.length;i++) { 853 final Rfc822Token token = tokens[i]; 854 final String senderName = Address.decodeAddressName(token.getName()); 855 final String senderAddress = token.getAddress(); 856 mHeader.displayableSenderEmails.add(senderAddress); 857 mHeader.displayableSenderNames.add( 858 !TextUtils.isEmpty(senderName) ? senderName : senderAddress); 859 } 860 loadSenderImages(); 861 } 862 } 863 864 if (isAttachmentPreviewsEnabled()) { 865 loadAttachmentPreviews(); 866 } 867 868 if (mHeader.isLayoutValid()) { 869 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 870 return; 871 } 872 startTimer(PERF_TAG_CALCULATE_FOLDERS); 873 874 875 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 876 877 // Paper clip icon. 878 mHeader.paperclip = null; 879 if (mHeader.conversation.hasAttachments) { 880 mHeader.paperclip = ATTACHMENT; 881 } 882 883 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 884 885 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 886 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 887 } 888 889 private boolean isAttachmentPreviewsEnabled() { 890 return mAttachmentPreviewsEnabled && !mHeader.conversation.getAttachmentPreviewUris() 891 .isEmpty(); 892 } 893 894 private int getOverflowCount() { 895 return mHeader.conversation.attachmentPreviewsCount - mHeader.conversation 896 .getAttachmentPreviewUris().size(); 897 } 898 899 private int getAttachmentPreviewsMode() { 900 if (isAttachmentPreviewsEnabled()) { 901 return mHeader.conversation.read 902 ? ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_READ 903 : ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_UNREAD; 904 } else { 905 return ConversationItemViewCoordinates.ATTACHMENT_PREVIEW_NONE; 906 } 907 } 908 909 private float getParallaxSpeedMultiplier() { 910 return mParallaxSpeedAlternative 911 ? SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER_ALTERNATIVE 912 : SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER_NORMAL; 913 } 914 915 // FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which 916 // is immutable. 917 private void loadSenderImages() { 918 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 919 && mHeader.displayableSenderEmails != null 920 && mHeader.displayableSenderEmails.size() > 0) { 921 if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) { 922 LogUtils.w(LOG_TAG, 923 "Contact image width(%d) or height(%d) is 0 for mode: (%d).", 924 mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, 925 mCoordinates.getMode()); 926 return; 927 } 928 929 int size = mHeader.displayableSenderEmails.size(); 930 final List<Object> keys = Lists.newArrayListWithCapacity(size); 931 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 932 keys.add(mHeader.displayableSenderEmails.get(i)); 933 } 934 935 mContactImagesHolder.setDimensions(mCoordinates.contactImagesWidth, 936 mCoordinates.contactImagesHeight); 937 mContactImagesHolder.setDivisionIds(keys); 938 String emailAddress; 939 for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) { 940 emailAddress = mHeader.displayableSenderEmails.get(i); 941 PhotoIdentifier photoIdentifier = new ContactIdentifier( 942 mHeader.displayableSenderNames.get(i), emailAddress, i); 943 sContactPhotoManager.loadThumbnail(photoIdentifier, mContactImagesHolder); 944 } 945 } 946 } 947 948 private void loadAttachmentPreviews() { 949 if (mCoordinates.attachmentPreviewsWidth <= 0 950 || mCoordinates.attachmentPreviewsHeight <= 0) { 951 LogUtils.w(LOG_TAG, 952 "Attachment preview width(%d) or height(%d) is 0 for mode: (%d,%d).", 953 mCoordinates.attachmentPreviewsWidth, mCoordinates.attachmentPreviewsHeight, 954 mCoordinates.getMode(), getAttachmentPreviewsMode()); 955 return; 956 } 957 Utils.traceBeginSection("attachment previews"); 958 959 Utils.traceBeginSection("Setup load attachment previews"); 960 961 LogUtils.d(LOG_TAG, 962 "loadAttachmentPreviews: Loading attachment previews for conversation %s", 963 mHeader.conversation); 964 965 // Get list of attachments and states from conversation 966 final ArrayList<String> attachmentUris = mHeader.conversation.getAttachmentPreviewUris(); 967 final int previewStates = mHeader.conversation.attachmentPreviewStates; 968 final int displayCount = Math.min( 969 attachmentUris.size(), AttachmentGridDrawable.MAX_VISIBLE_ATTACHMENT_COUNT); 970 Utils.traceEndSection(); 971 972 mAttachmentsView.setCoordinates(mCoordinates); 973 mAttachmentsView.setCount(displayCount); 974 975 final int decodeHeight; 976 // if parallax is enabled, increase the desired vertical size of attachment bitmaps 977 // so we have extra pixels to scroll within 978 if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) { 979 decodeHeight = Math.round(mCoordinates.attachmentPreviewsDecodeHeight 980 * getParallaxSpeedMultiplier()); 981 } else { 982 decodeHeight = mCoordinates.attachmentPreviewsDecodeHeight; 983 } 984 985 // set the bounds before binding inner drawables so they can decode right away 986 // (they need the their bounds set to know whether to decode to 1x1 or 2x1 dimens) 987 mAttachmentsView.setBounds(0, 0, mCoordinates.attachmentPreviewsWidth, 988 mCoordinates.attachmentPreviewsHeight); 989 990 for (int i = 0; i < displayCount; i++) { 991 Utils.traceBeginSection("setup single attachment preview"); 992 final String uri = attachmentUris.get(i); 993 994 // Find the rendition to load based on availability. 995 LogUtils.v(LOG_TAG, "loadAttachmentPreviews: state [BEST, SIMPLE] is [%s, %s] for %s ", 996 Attachment.getPreviewState(previewStates, i, AttachmentRendition.BEST), 997 Attachment.getPreviewState(previewStates, i, AttachmentRendition.SIMPLE), 998 uri); 999 int bestAvailableRendition = -1; 1000 // BEST first, else use less preferred renditions 1001 for (final int rendition : AttachmentRendition.PREFERRED_RENDITIONS) { 1002 if (Attachment.getPreviewState(previewStates, i, rendition)) { 1003 bestAvailableRendition = rendition; 1004 break; 1005 } 1006 } 1007 1008 LogUtils.d(LOG_TAG, 1009 "creating/setting drawable region in CIV=%s canvas=%s rend=%s uri=%s", 1010 this, mAttachmentsView, bestAvailableRendition, uri); 1011 final AttachmentDrawable drawable = mAttachmentsView.getOrCreateDrawable(i); 1012 drawable.setDecodeDimensions(mCoordinates.attachmentPreviewsWidth, decodeHeight); 1013 drawable.setParallaxSpeedMultiplier(getParallaxSpeedMultiplier()); 1014 if (bestAvailableRendition != -1) { 1015 drawable.bind(getContext(), uri, bestAvailableRendition); 1016 } else { 1017 drawable.showStaticPlaceholder(); 1018 } 1019 1020 Utils.traceEndSection(); 1021 } 1022 1023 Utils.traceEndSection(); 1024 } 1025 1026 private static int makeExactSpecForSize(int size) { 1027 return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY); 1028 } 1029 1030 private static void layoutViewExactly(View v, int w, int h) { 1031 v.measure(makeExactSpecForSize(w), makeExactSpecForSize(h)); 1032 v.layout(0, 0, w, h); 1033 } 1034 1035 private void layoutSenders() { 1036 if (mHeader.styledSendersString != null) { 1037 if (isActivated() && showActivatedText()) { 1038 mHeader.styledSendersString.setSpan(sActivatedTextSpan, 0, 1039 mHeader.styledMessageInfoStringOffset, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1040 } else { 1041 mHeader.styledSendersString.removeSpan(sActivatedTextSpan); 1042 } 1043 1044 final int w = mSendersWidth; 1045 final int h = mCoordinates.sendersHeight; 1046 mSendersTextView.setLayoutParams(new ViewGroup.LayoutParams(w, h)); 1047 mSendersTextView.setMaxLines(mCoordinates.sendersLineCount); 1048 mSendersTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.sendersFontSize); 1049 layoutViewExactly(mSendersTextView, w, h); 1050 1051 mSendersTextView.setText(mHeader.styledSendersString); 1052 } 1053 } 1054 1055 private void createSubject(final boolean isUnread) { 1056 final String subject = filterTag(mHeader.conversation.subject); 1057 final String snippet = mHeader.conversation.getSnippet(); 1058 final Spannable displayedStringBuilder = new SpannableString( 1059 Conversation.getSubjectAndSnippetForDisplay(mContext, subject, snippet)); 1060 1061 // since spans affect text metrics, add spans to the string before measure/layout or fancy 1062 // ellipsizing 1063 final int subjectTextLength = (subject != null) ? subject.length() : 0; 1064 if (!TextUtils.isEmpty(subject)) { 1065 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap( 1066 isUnread ? sSubjectTextUnreadSpan : sSubjectTextReadSpan), 0, subjectTextLength, 1067 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1068 } 1069 if (!TextUtils.isEmpty(snippet)) { 1070 final int startOffset = subjectTextLength; 1071 // Start after the end of the subject text; since the subject may be 1072 // "" or null, this could start at the 0th character in the subjectText string 1073 displayedStringBuilder.setSpan(ForegroundColorSpan.wrap( 1074 isUnread ? sSnippetTextUnreadSpan : sSnippetTextReadSpan), startOffset, 1075 displayedStringBuilder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1076 } 1077 if (isActivated() && showActivatedText()) { 1078 displayedStringBuilder.setSpan(sActivatedTextSpan, 0, displayedStringBuilder.length(), 1079 Spannable.SPAN_INCLUSIVE_INCLUSIVE); 1080 } 1081 1082 final int subjectWidth = mCoordinates.subjectWidth; 1083 final int subjectHeight = mCoordinates.subjectHeight; 1084 mSubjectTextView.setLayoutParams(new ViewGroup.LayoutParams(subjectWidth, subjectHeight)); 1085 mSubjectTextView.setMaxLines(mCoordinates.subjectLineCount); 1086 mSubjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.subjectFontSize); 1087 layoutViewExactly(mSubjectTextView, subjectWidth, subjectHeight); 1088 1089 mSubjectTextView.setText(displayedStringBuilder); 1090 } 1091 1092 private boolean showActivatedText() { 1093 // For activated elements in tablet in conversation mode, we show an activated color, since 1094 // the background is dark blue for activated versus gray for non-activated. 1095 return mTabletDevice && !mListCollapsible; 1096 } 1097 1098 private boolean canFitFragment(int width, int line, int fixedWidth) { 1099 if (line == mCoordinates.sendersLineCount) { 1100 return width + fixedWidth <= mSendersWidth; 1101 } else { 1102 return width <= mSendersWidth; 1103 } 1104 } 1105 1106 private void calculateCoordinates() { 1107 startTimer(PERF_TAG_CALCULATE_COORDINATES); 1108 1109 sPaint.setTextSize(mCoordinates.dateFontSize); 1110 sPaint.setTypeface(Typeface.DEFAULT); 1111 1112 if (mHeader.infoIcon != null) { 1113 mInfoIconX = mCoordinates.infoIconXEnd - mHeader.infoIcon.getWidth(); 1114 1115 // If we have an info icon, we start drawing the date text: 1116 // At the end of the date TextView minus the width of the date text 1117 mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText( 1118 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 1119 } else { 1120 // If there is no info icon, we start drawing the date text: 1121 // At the end of the info icon ImageView minus the width of the date text 1122 // We use the info icon ImageView for positioning, since we want the date text to be 1123 // at the right, since there is no info icon 1124 mDateX = mCoordinates.infoIconXEnd - (int) sPaint.measureText( 1125 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 1126 } 1127 1128 mPaperclipX = mDateX - ATTACHMENT.getWidth() - mCoordinates.datePaddingLeft; 1129 1130 if (mCoordinates.isWide()) { 1131 // In wide mode, the end of the senders should align with 1132 // the start of the subject and is based on a max width. 1133 mSendersWidth = mCoordinates.sendersWidth; 1134 } else { 1135 // In normal mode, the width is based on where the date/attachment icon start. 1136 final int dateAttachmentStart; 1137 // Have this end near the paperclip or date, not the folders. 1138 if (mHeader.paperclip != null) { 1139 dateAttachmentStart = mPaperclipX - mCoordinates.paperclipPaddingLeft; 1140 } else { 1141 dateAttachmentStart = mDateX - mCoordinates.datePaddingLeft; 1142 } 1143 mSendersWidth = dateAttachmentStart - mCoordinates.sendersX; 1144 } 1145 1146 // Second pass to layout each fragment. 1147 sPaint.setTextSize(mCoordinates.sendersFontSize); 1148 sPaint.setTypeface(Typeface.DEFAULT); 1149 1150 if (mHeader.styledSenders != null) { 1151 ellipsizeStyledSenders(); 1152 layoutSenders(); 1153 } else { 1154 // First pass to calculate width of each fragment. 1155 int totalWidth = 0; 1156 int fixedWidth = 0; 1157 for (SenderFragment senderFragment : mHeader.senderFragments) { 1158 CharacterStyle style = senderFragment.style; 1159 int start = senderFragment.start; 1160 int end = senderFragment.end; 1161 style.updateDrawState(sPaint); 1162 senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end); 1163 boolean isFixed = senderFragment.isFixed; 1164 if (isFixed) { 1165 fixedWidth += senderFragment.width; 1166 } 1167 totalWidth += senderFragment.width; 1168 } 1169 1170 if (mSendersWidth < 0) { 1171 mSendersWidth = 0; 1172 } 1173 totalWidth = ellipsize(fixedWidth); 1174 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 1175 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 1176 } 1177 1178 if (mSendersWidth < 0) { 1179 mSendersWidth = 0; 1180 } 1181 1182 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 1183 } 1184 1185 // The rules for displaying ellipsized senders are as follows: 1186 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 1187 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 1188 // appending new senders 1189 private int ellipsizeStyledSenders() { 1190 SpannableStringBuilder builder = new SpannableStringBuilder(); 1191 float totalWidth = 0; 1192 boolean ellipsize = false; 1193 float width; 1194 SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 1195 if (messageInfoString.length() > 0) { 1196 CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), 1197 CharacterStyle.class); 1198 // There is only 1 character style span; make sure we apply all the 1199 // styles to the paint object before measuring. 1200 if (spans.length > 0) { 1201 spans[0].updateDrawState(sPaint); 1202 } 1203 // Paint the message info string to see if we lose space. 1204 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 1205 totalWidth += messageInfoWidth; 1206 } 1207 SpannableString prevSender = null; 1208 SpannableString ellipsizedText; 1209 for (SpannableString sender : mHeader.styledSenders) { 1210 // There may be null sender strings if there were dupes we had to remove. 1211 if (sender == null) { 1212 continue; 1213 } 1214 // No more width available, we'll only show fixed fragments. 1215 if (ellipsize) { 1216 break; 1217 } 1218 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 1219 // There is only 1 character style span. 1220 if (spans.length > 0) { 1221 spans[0].updateDrawState(sPaint); 1222 } 1223 // If there are already senders present in this string, we need to 1224 // make sure we prepend the dividing token 1225 if (SendersView.sElidedString.equals(sender.toString())) { 1226 prevSender = sender; 1227 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 1228 } else if (builder.length() > 0 1229 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 1230 .toString()))) { 1231 prevSender = sender; 1232 sender = copyStyles(spans, sSendersSplitToken + sender); 1233 } else { 1234 prevSender = sender; 1235 } 1236 if (spans.length > 0) { 1237 spans[0].updateDrawState(sPaint); 1238 } 1239 // Measure the width of the current sender and make sure we have space 1240 width = (int) sPaint.measureText(sender.toString()); 1241 if (width + totalWidth > mSendersWidth) { 1242 // The text is too long, new line won't help. We have to 1243 // ellipsize text. 1244 ellipsize = true; 1245 width = mSendersWidth - totalWidth; // ellipsis width? 1246 ellipsizedText = copyStyles(spans, 1247 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 1248 width = (int) sPaint.measureText(ellipsizedText.toString()); 1249 } else { 1250 ellipsizedText = null; 1251 } 1252 totalWidth += width; 1253 1254 final CharSequence fragmentDisplayText; 1255 if (ellipsizedText != null) { 1256 fragmentDisplayText = ellipsizedText; 1257 } else { 1258 fragmentDisplayText = sender; 1259 } 1260 builder.append(fragmentDisplayText); 1261 } 1262 mHeader.styledMessageInfoStringOffset = builder.length(); 1263 builder.append(messageInfoString); 1264 mHeader.styledSendersString = builder; 1265 return (int)totalWidth; 1266 } 1267 1268 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 1269 SpannableString s = new SpannableString(newText); 1270 if (spans != null && spans.length > 0) { 1271 s.setSpan(spans[0], 0, s.length(), 0); 1272 } 1273 return s; 1274 } 1275 1276 private int ellipsize(int fixedWidth) { 1277 int totalWidth = 0; 1278 int currentLine = 1; 1279 boolean ellipsize = false; 1280 for (SenderFragment senderFragment : mHeader.senderFragments) { 1281 CharacterStyle style = senderFragment.style; 1282 int start = senderFragment.start; 1283 int end = senderFragment.end; 1284 int width = senderFragment.width; 1285 boolean isFixed = senderFragment.isFixed; 1286 style.updateDrawState(sPaint); 1287 1288 // No more width available, we'll only show fixed fragments. 1289 if (ellipsize && !isFixed) { 1290 senderFragment.shouldDisplay = false; 1291 continue; 1292 } 1293 1294 // New line and ellipsize text if needed. 1295 senderFragment.ellipsizedText = null; 1296 if (isFixed) { 1297 fixedWidth -= width; 1298 } 1299 if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) { 1300 // The text is too long, new line won't help. We have to 1301 // ellipsize text. 1302 if (totalWidth == 0) { 1303 ellipsize = true; 1304 } else { 1305 // New line. 1306 if (currentLine < mCoordinates.sendersLineCount) { 1307 currentLine++; 1308 totalWidth = 0; 1309 // The text is still too long, we have to ellipsize 1310 // text. 1311 if (totalWidth + width > mSendersWidth) { 1312 ellipsize = true; 1313 } 1314 } else { 1315 ellipsize = true; 1316 } 1317 } 1318 1319 if (ellipsize) { 1320 width = mSendersWidth - totalWidth; 1321 // No more new line, we have to reserve width for fixed 1322 // fragments. 1323 if (currentLine == mCoordinates.sendersLineCount) { 1324 width -= fixedWidth; 1325 } 1326 senderFragment.ellipsizedText = TextUtils.ellipsize( 1327 mHeader.sendersText.substring(start, end), sPaint, width, 1328 TruncateAt.END).toString(); 1329 width = (int) sPaint.measureText(senderFragment.ellipsizedText); 1330 } 1331 } 1332 senderFragment.shouldDisplay = true; 1333 totalWidth += width; 1334 1335 final CharSequence fragmentDisplayText; 1336 if (senderFragment.ellipsizedText != null) { 1337 fragmentDisplayText = senderFragment.ellipsizedText; 1338 } else { 1339 fragmentDisplayText = mHeader.sendersText.substring(start, end); 1340 } 1341 final int spanStart = mHeader.sendersDisplayText.length(); 1342 mHeader.sendersDisplayText.append(fragmentDisplayText); 1343 mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart, 1344 mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1345 } 1346 return totalWidth; 1347 } 1348 1349 /** 1350 * If the subject contains the tag of a mailing-list (text surrounded with 1351 * []), return the subject with that tag ellipsized, e.g. 1352 * "[android-gmail-team] Hello" -> "[andr...] Hello" 1353 */ 1354 private String filterTag(String subject) { 1355 String result = subject; 1356 String formatString = getContext().getResources().getString(R.string.filtered_tag); 1357 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 1358 int end = subject.indexOf(']'); 1359 if (end > 0) { 1360 String tag = subject.substring(1, end); 1361 result = String.format(formatString, Utils.ellipsize(tag, 7), 1362 subject.substring(end + 1)); 1363 } 1364 } 1365 return result; 1366 } 1367 1368 @Override 1369 public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 1370 int totalItemCount) { 1371 if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) { 1372 if (mHeader == null || mCoordinates == null || !isAttachmentPreviewsEnabled()) { 1373 return; 1374 } 1375 1376 invalidate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY, 1377 mCoordinates.attachmentPreviewsX + mCoordinates.attachmentPreviewsWidth, 1378 mCoordinates.attachmentPreviewsY + mCoordinates.attachmentPreviewsHeight); 1379 } 1380 } 1381 1382 @Override 1383 public void onScrollStateChanged(AbsListView view, int scrollState) { 1384 } 1385 1386 @Override 1387 protected void onDraw(Canvas canvas) { 1388 Utils.traceBeginSection("CIVC.draw"); 1389 1390 // Contact photo 1391 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) { 1392 canvas.save(); 1393 drawContactImageArea(canvas); 1394 canvas.restore(); 1395 } 1396 1397 // Senders. 1398 boolean isUnread = mHeader.unread; 1399 // Old style senders; apply text colors/ sizes/ styling. 1400 canvas.save(); 1401 if (mHeader.sendersDisplayLayout != null) { 1402 sPaint.setTextSize(mCoordinates.sendersFontSize); 1403 sPaint.setTypeface(SendersView.getTypeface(isUnread)); 1404 sPaint.setColor(isUnread ? sSendersTextColorUnread : sSendersTextColorRead); 1405 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY 1406 + mHeader.sendersDisplayLayout.getTopPadding()); 1407 mHeader.sendersDisplayLayout.draw(canvas); 1408 } else { 1409 drawSenders(canvas); 1410 } 1411 canvas.restore(); 1412 1413 1414 // Subject. 1415 sPaint.setTypeface(Typeface.DEFAULT); 1416 canvas.save(); 1417 drawSubject(canvas); 1418 canvas.restore(); 1419 1420 // Folders. 1421 if (mConfig.areFoldersVisible()) { 1422 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates); 1423 } 1424 1425 // If this folder has a color (combined view/Email), show it here 1426 if (mConfig.isColorBlockVisible()) { 1427 sFoldersPaint.setColor(mHeader.conversation.color); 1428 sFoldersPaint.setStyle(Paint.Style.FILL); 1429 canvas.drawRect(mCoordinates.colorBlockX, mCoordinates.colorBlockY, 1430 mCoordinates.colorBlockX + mCoordinates.colorBlockWidth, 1431 mCoordinates.colorBlockY + mCoordinates.colorBlockHeight, sFoldersPaint); 1432 } 1433 1434 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1435 if (mConfig.isReplyStateVisible()) { 1436 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1437 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1438 mCoordinates.replyStateY, null); 1439 } else if (mHeader.hasBeenRepliedTo) { 1440 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1441 mCoordinates.replyStateY, null); 1442 } else if (mHeader.hasBeenForwarded) { 1443 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1444 mCoordinates.replyStateY, null); 1445 } else if (mHeader.isInvite) { 1446 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1447 mCoordinates.replyStateY, null); 1448 } 1449 } 1450 1451 if (mConfig.isPersonalIndicatorVisible()) { 1452 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalIndicatorX, 1453 mCoordinates.personalIndicatorY, null); 1454 } 1455 1456 // Info icon 1457 if (mHeader.infoIcon != null) { 1458 canvas.drawBitmap(mHeader.infoIcon, mInfoIconX, mCoordinates.infoIconY, sPaint); 1459 } 1460 1461 // Date. 1462 sPaint.setTextSize(mCoordinates.dateFontSize); 1463 sPaint.setTypeface(Typeface.DEFAULT); 1464 sPaint.setColor(sDateTextColor); 1465 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateYBaseline, 1466 sPaint); 1467 1468 // Paper clip icon. 1469 if (mHeader.paperclip != null) { 1470 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1471 } 1472 1473 if (mStarEnabled) { 1474 // Star. 1475 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1476 } 1477 1478 // Attachment previews 1479 if (isAttachmentPreviewsEnabled()) { 1480 canvas.save(); 1481 drawAttachmentPreviews(canvas); 1482 canvas.restore(); 1483 } 1484 1485 // right-side edge effect when in tablet conversation mode and the list is not collapsed 1486 if (Utils.getDisplayListRightEdgeEffect(mTabletDevice, mListCollapsible, 1487 mConfig.getViewMode())) { 1488 RIGHT_EDGE_TABLET.setBounds(getWidth() - RIGHT_EDGE_TABLET.getIntrinsicWidth(), 0, 1489 getWidth(), getHeight()); 1490 RIGHT_EDGE_TABLET.draw(canvas); 1491 1492 if (isActivated()) { 1493 // draw caret on the right, centered vertically 1494 final int x = getWidth() - VISIBLE_CONVERSATION_CARET.getWidth(); 1495 final int y = (getHeight() - VISIBLE_CONVERSATION_CARET.getHeight()) / 2; 1496 canvas.drawBitmap(VISIBLE_CONVERSATION_CARET, x, y, null); 1497 } 1498 } 1499 Utils.traceEndSection(); 1500 } 1501 1502 /** 1503 * Draws the contact images or check, in the correct animated state. 1504 */ 1505 private void drawContactImageArea(final Canvas canvas) { 1506 if (isSelected()) { 1507 mLastSelectedId = mHeader.conversation.id; 1508 1509 // Since this is selected, we draw the checkbox if the animation is not running, or if 1510 // it's running, and is past the half-way point 1511 if (mPhotoFlipAnimator.getValue() > 1 || !mPhotoFlipAnimator.isStarted()) { 1512 // Flash in the check 1513 drawCheckbox(canvas); 1514 } else { 1515 // Flip out the contact photo 1516 drawContactImages(canvas); 1517 } 1518 } else { 1519 if ((mConversationListListener.isExitingSelectionMode() 1520 && mLastSelectedId == mHeader.conversation.id) 1521 || mPhotoFlipAnimator.isStarted()) { 1522 // Animate back to the photo 1523 if (!mPhotoFlipAnimator.isStarted()) { 1524 mPhotoFlipAnimator.startAnimation(true /* reverse */); 1525 } 1526 1527 if (mPhotoFlipAnimator.getValue() > 1) { 1528 // Flash out the check 1529 drawCheckbox(canvas); 1530 } else { 1531 // Flip in the contact photo 1532 drawContactImages(canvas); 1533 } 1534 } else { 1535 mLastSelectedId = -1; // We don't care anymore 1536 mPhotoFlipAnimator.stopAnimation(); // It's not running, but we want to reset state 1537 1538 // Contact photos 1539 drawContactImages(canvas); 1540 } 1541 } 1542 } 1543 1544 private void drawContactImages(final Canvas canvas) { 1545 // mPhotoFlipFraction goes from 0 to 1 1546 final float value = mPhotoFlipAnimator.getValue(); 1547 1548 final float scale = 1f - value; 1549 final float xOffset = mContactImagesHolder.getWidth() * value / 2; 1550 1551 mPhotoFlipMatrix.reset(); 1552 mPhotoFlipMatrix.postScale(scale, 1); 1553 1554 final float x = mCoordinates.contactImagesX + xOffset; 1555 final float y = mCoordinates.contactImagesY; 1556 1557 canvas.translate(x, y); 1558 1559 if (mPhotoBitmap == null) { 1560 mContactImagesHolder.draw(canvas, mPhotoFlipMatrix); 1561 } else { 1562 canvas.drawBitmap(mPhotoBitmap, null, mPhotoRect, sPaint); 1563 } 1564 } 1565 1566 private void drawCheckbox(final Canvas canvas) { 1567 // mPhotoFlipFraction goes from 1 to 2 1568 1569 // Draw the background 1570 canvas.save(); 1571 canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY); 1572 canvas.drawRect(0, 0, mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, 1573 sCheckBackgroundPaint); 1574 canvas.restore(); 1575 1576 final int x = mCoordinates.contactImagesX 1577 + (mCoordinates.contactImagesWidth - CHECK.getWidth()) / 2; 1578 final int y = mCoordinates.contactImagesY 1579 + (mCoordinates.contactImagesHeight - CHECK.getHeight()) / 2; 1580 1581 final float value = mPhotoFlipAnimator.getValue(); 1582 final float scale; 1583 1584 if (!mPhotoFlipAnimator.isStarted()) { 1585 // We're not animating 1586 scale = 1; 1587 } else if (value < 1.9) { 1588 // 1.0 to 1.9 will scale 0 to 1 1589 scale = (value - 1f) / 0.9f; 1590 } else if (value < 1.95) { 1591 // 1.9 to 1.95 will scale 1 to 19/18 1592 scale = (value - 1f) / 0.9f; 1593 } else { 1594 // 1.95 to 2.0 will scale 19/18 to 1 1595 scale = (0.95f - (value - 1.95f)) / 0.9f; 1596 } 1597 1598 final float xOffset = CHECK.getWidth() * (1f - scale) / 2f; 1599 final float yOffset = CHECK.getHeight() * (1f - scale) / 2f; 1600 1601 mCheckMatrix.reset(); 1602 mCheckMatrix.postScale(scale, scale); 1603 1604 canvas.translate(x + xOffset, y + yOffset); 1605 1606 canvas.drawBitmap(CHECK, mCheckMatrix, sPaint); 1607 } 1608 1609 private void drawAttachmentPreviews(Canvas canvas) { 1610 canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY); 1611 final float fraction; 1612 if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) { 1613 final View listView = getListView(); 1614 final View listItemView = unwrap(); 1615 if (mParallaxDirectionAlternative) { 1616 fraction = 1 - (float) listItemView.getBottom() 1617 / (listView.getHeight() + listItemView.getHeight()); 1618 } else { 1619 fraction = (float) listItemView.getBottom() 1620 / (listView.getHeight() + listItemView.getHeight()); 1621 } 1622 } else { 1623 // Vertically center the preview crop, which has already been decoded at 1/3. 1624 fraction = 0.5f; 1625 } 1626 mAttachmentsView.setParallaxFraction(fraction); 1627 mAttachmentsView.draw(canvas); 1628 } 1629 1630 private void drawSubject(Canvas canvas) { 1631 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY); 1632 mSubjectTextView.draw(canvas); 1633 } 1634 1635 private void drawSenders(Canvas canvas) { 1636 canvas.translate(mCoordinates.sendersX, mCoordinates.sendersY); 1637 mSendersTextView.draw(canvas); 1638 } 1639 1640 private Bitmap getStarBitmap() { 1641 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1642 } 1643 1644 private static void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1645 canvas.drawText(s, 0, s.length(), x, y, paint); 1646 } 1647 1648 /** 1649 * Set the background for this item based on: 1650 * 1. Read / Unread (unread messages have a lighter background) 1651 * 2. Tablet / Phone 1652 * 3. Checkbox checked / Unchecked (controls CAB color for item) 1653 * 4. Activated / Not activated (controls the blue highlight on tablet) 1654 * @param isUnread 1655 */ 1656 private void updateBackground(boolean isUnread) { 1657 final int background; 1658 if (mBackgroundOverrideResId > 0) { 1659 background = mBackgroundOverrideResId; 1660 } else if (isUnread) { 1661 background = R.drawable.conversation_unread_selector; 1662 } else { 1663 background = R.drawable.conversation_read_selector; 1664 } 1665 setBackgroundResource(background); 1666 } 1667 1668 /** 1669 * Toggle the check mark on this view and update the conversation or begin 1670 * drag, if drag is enabled. 1671 */ 1672 @Override 1673 public boolean toggleSelectedStateOrBeginDrag() { 1674 ViewMode mode = mActivity.getViewMode(); 1675 if (mIsExpansiveTablet && mode.isListMode()) { 1676 return beginDragMode(); 1677 } else { 1678 return toggleSelectedState("long_press"); 1679 } 1680 } 1681 1682 @Override 1683 public boolean toggleSelectedState() { 1684 return toggleSelectedState(null); 1685 } 1686 1687 private boolean toggleSelectedState(String sourceOpt) { 1688 if (mHeader != null && mHeader.conversation != null && mSelectedConversationSet != null) { 1689 mSelected = !mSelected; 1690 setSelected(mSelected); 1691 Conversation conv = mHeader.conversation; 1692 // Set the list position of this item in the conversation 1693 SwipeableListView listView = getListView(); 1694 1695 try { 1696 conv.position = mSelected && listView != null ? listView.getPositionForView(this) 1697 : Conversation.NO_POSITION; 1698 } catch (final NullPointerException e) { 1699 // TODO(skennedy) Remove this if we find the root cause b/9527863 1700 } 1701 1702 if (mSelectedConversationSet.isEmpty()) { 1703 final String source = (sourceOpt != null) ? sourceOpt : "checkbox"; 1704 Analytics.getInstance().sendEvent("enter_cab_mode", source, null, 0); 1705 } 1706 1707 mSelectedConversationSet.toggle(conv); 1708 if (mSelectedConversationSet.isEmpty()) { 1709 listView.commitDestructiveActions(true); 1710 } 1711 1712 final boolean reverse = !mSelected; 1713 mPhotoFlipAnimator.startAnimation(reverse); 1714 mPhotoFlipAnimator.invalidateArea(); 1715 1716 // We update the background after the checked state has changed 1717 // now that we have a selected background asset. Setting the background 1718 // usually waits for a layout pass, but we don't need a full layout, 1719 // just an update to the background. 1720 requestLayout(); 1721 1722 return true; 1723 } 1724 1725 return false; 1726 } 1727 1728 /** 1729 * Toggle the star on this view and update the conversation. 1730 */ 1731 public void toggleStar() { 1732 mHeader.conversation.starred = !mHeader.conversation.starred; 1733 Bitmap starBitmap = getStarBitmap(); 1734 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1735 + starBitmap.getWidth(), 1736 mCoordinates.starY + starBitmap.getHeight()); 1737 ConversationCursor cursor = (ConversationCursor) mAdapter.getCursor(); 1738 if (cursor != null) { 1739 // TODO(skennedy) What about ads? 1740 cursor.updateBoolean(mHeader.conversation, ConversationColumns.STARRED, 1741 mHeader.conversation.starred); 1742 } 1743 } 1744 1745 private boolean isTouchInContactPhoto(float x, float y) { 1746 // Everything before the right edge of contact photo 1747 1748 final int threshold = mCoordinates.contactImagesX + mCoordinates.contactImagesWidth 1749 + sSenderImageTouchSlop; 1750 1751 // Allow touching a little right of the contact photo when we're already in selection mode 1752 final float extra; 1753 if (mSelectedConversationSet == null || mSelectedConversationSet.isEmpty()) { 1754 extra = 0; 1755 } else { 1756 extra = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16, 1757 getResources().getDisplayMetrics()); 1758 } 1759 1760 return mHeader.gadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 1761 && x < (threshold + extra) 1762 && (!isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY); 1763 } 1764 1765 private boolean isTouchInInfoIcon(final float x, final float y) { 1766 if (mHeader.infoIcon == null) { 1767 // We have no info icon 1768 return false; 1769 } 1770 1771 // Regardless of device, we always want to be right of the date's left touch slop 1772 if (x < mDateX - sStarTouchSlop) { 1773 return false; 1774 } 1775 1776 if (mStarEnabled) { 1777 if (mIsExpansiveTablet) { 1778 // Just check that we're left of the star's touch area 1779 if (x >= mCoordinates.starX - sStarTouchSlop) { 1780 return false; 1781 } 1782 } else { 1783 // We're on a phone or non-expansive tablet 1784 1785 // We allow touches all the way to the right edge, so no x check is necessary 1786 1787 // We need to be above the star's touch area, which ends at the top of the subject 1788 // text 1789 return y < mCoordinates.subjectY; 1790 } 1791 } 1792 1793 // With no star below the info icon, we allow touches anywhere from the top edge to the 1794 // bottom edge, or to the top of the attachment previews, whichever is higher 1795 return !isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY; 1796 } 1797 1798 private boolean isTouchInStar(float x, float y) { 1799 if (mHeader.infoIcon != null && !mIsExpansiveTablet) { 1800 // We have an info icon, and it's above the star 1801 // We allow touches everywhere below the top of the subject text 1802 if (y < mCoordinates.subjectY) { 1803 return false; 1804 } 1805 } 1806 1807 // Everything after the star and include a touch slop. 1808 return mStarEnabled 1809 && x > mCoordinates.starX - sStarTouchSlop 1810 && (!isAttachmentPreviewsEnabled() || y < mCoordinates.attachmentPreviewsY); 1811 } 1812 1813 @Override 1814 public boolean canChildBeDismissed() { 1815 return true; 1816 } 1817 1818 @Override 1819 public void dismiss() { 1820 SwipeableListView listView = getListView(); 1821 if (listView != null) { 1822 getListView().dismissChild(this); 1823 } 1824 } 1825 1826 private boolean onTouchEventNoSwipe(MotionEvent event) { 1827 Utils.traceBeginSection("on touch event no swipe"); 1828 boolean handled = false; 1829 1830 int x = (int) event.getX(); 1831 int y = (int) event.getY(); 1832 mLastTouchX = x; 1833 mLastTouchY = y; 1834 switch (event.getAction()) { 1835 case MotionEvent.ACTION_DOWN: 1836 if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) { 1837 mDownEvent = true; 1838 handled = true; 1839 } 1840 break; 1841 1842 case MotionEvent.ACTION_CANCEL: 1843 mDownEvent = false; 1844 break; 1845 1846 case MotionEvent.ACTION_UP: 1847 if (mDownEvent) { 1848 if (isTouchInContactPhoto(x, y)) { 1849 // Touch on the check mark 1850 toggleSelectedState(); 1851 } else if (isTouchInInfoIcon(x, y)) { 1852 if (mConversationItemAreaClickListener != null) { 1853 mConversationItemAreaClickListener.onInfoIconClicked(); 1854 } 1855 } else if (isTouchInStar(x, y)) { 1856 // Touch on the star 1857 if (mConversationItemAreaClickListener == null) { 1858 toggleStar(); 1859 } else { 1860 mConversationItemAreaClickListener.onStarClicked(); 1861 } 1862 } 1863 handled = true; 1864 } 1865 break; 1866 } 1867 1868 if (!handled) { 1869 handled = super.onTouchEvent(event); 1870 } 1871 1872 Utils.traceEndSection(); 1873 return handled; 1874 } 1875 1876 /** 1877 * ConversationItemView is given the first chance to handle touch events. 1878 */ 1879 @Override 1880 public boolean onTouchEvent(MotionEvent event) { 1881 Utils.traceBeginSection("on touch event"); 1882 int x = (int) event.getX(); 1883 int y = (int) event.getY(); 1884 mLastTouchX = x; 1885 mLastTouchY = y; 1886 if (!mSwipeEnabled) { 1887 Utils.traceEndSection(); 1888 return onTouchEventNoSwipe(event); 1889 } 1890 switch (event.getAction()) { 1891 case MotionEvent.ACTION_DOWN: 1892 if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) { 1893 mDownEvent = true; 1894 Utils.traceEndSection(); 1895 return true; 1896 } 1897 break; 1898 case MotionEvent.ACTION_UP: 1899 if (mDownEvent) { 1900 if (isTouchInContactPhoto(x, y)) { 1901 // Touch on the check mark 1902 Utils.traceEndSection(); 1903 mDownEvent = false; 1904 toggleSelectedState(); 1905 Utils.traceEndSection(); 1906 return true; 1907 } else if (isTouchInInfoIcon(x, y)) { 1908 // Touch on the info icon 1909 mDownEvent = false; 1910 if (mConversationItemAreaClickListener != null) { 1911 mConversationItemAreaClickListener.onInfoIconClicked(); 1912 } 1913 Utils.traceEndSection(); 1914 return true; 1915 } else if (isTouchInStar(x, y)) { 1916 // Touch on the star 1917 mDownEvent = false; 1918 if (mConversationItemAreaClickListener == null) { 1919 toggleStar(); 1920 } else { 1921 mConversationItemAreaClickListener.onStarClicked(); 1922 } 1923 Utils.traceEndSection(); 1924 return true; 1925 } 1926 } 1927 break; 1928 } 1929 // Let View try to handle it as well. 1930 boolean handled = super.onTouchEvent(event); 1931 if (event.getAction() == MotionEvent.ACTION_DOWN) { 1932 Utils.traceEndSection(); 1933 return true; 1934 } 1935 Utils.traceEndSection(); 1936 return handled; 1937 } 1938 1939 @Override 1940 public boolean performClick() { 1941 final boolean handled = super.performClick(); 1942 final SwipeableListView list = getListView(); 1943 if (!handled && list != null && list.getAdapter() != null) { 1944 final int pos = list.findConversation(this, mHeader.conversation); 1945 list.performItemClick(this, pos, mHeader.conversation.id); 1946 } 1947 return handled; 1948 } 1949 1950 private View unwrap() { 1951 final ViewParent vp = getParent(); 1952 if (vp == null || !(vp instanceof View)) { 1953 return null; 1954 } 1955 return (View) vp; 1956 } 1957 1958 private SwipeableListView getListView() { 1959 SwipeableListView v = null; 1960 final View wrapper = unwrap(); 1961 if (wrapper != null && wrapper instanceof SwipeableConversationItemView) { 1962 v = (SwipeableListView) ((SwipeableConversationItemView) wrapper).getListView(); 1963 } 1964 if (v == null) { 1965 v = mAdapter.getListView(); 1966 } 1967 return v; 1968 } 1969 1970 /** 1971 * Reset any state associated with this conversation item view so that it 1972 * can be reused. 1973 */ 1974 public void reset() { 1975 Utils.traceBeginSection("reset"); 1976 setAlpha(1f); 1977 setTranslationX(0f); 1978 mAnimatedHeightFraction = 1.0f; 1979 Utils.traceEndSection(); 1980 } 1981 1982 @SuppressWarnings("deprecation") 1983 @Override 1984 public void setTranslationX(float translationX) { 1985 super.setTranslationX(translationX); 1986 1987 // When a list item is being swiped or animated, ensure that the hosting view has a 1988 // background color set. We only enable the background during the X-translation effect to 1989 // reduce overdraw during normal list scrolling. 1990 final View parent = (View) getParent(); 1991 if (parent == null) { 1992 LogUtils.w(LOG_TAG, "CIV.setTranslationX null ConversationItemView parent x=%s", 1993 translationX); 1994 } 1995 1996 if (parent instanceof SwipeableConversationItemView) { 1997 if (translationX != 0f) { 1998 parent.setBackgroundResource(R.color.swiped_bg_color); 1999 } else { 2000 parent.setBackgroundDrawable(null); 2001 } 2002 } 2003 } 2004 2005 /** 2006 * Grow the height of the item and fade it in when bringing a conversation 2007 * back from a destructive action. 2008 */ 2009 public Animator createSwipeUndoAnimation() { 2010 ObjectAnimator undoAnimator = createTranslateXAnimation(true); 2011 return undoAnimator; 2012 } 2013 2014 /** 2015 * Grow the height of the item and fade it in when bringing a conversation 2016 * back from a destructive action. 2017 */ 2018 public Animator createUndoAnimation() { 2019 ObjectAnimator height = createHeightAnimation(true); 2020 Animator fade = ObjectAnimator.ofFloat(this, "alpha", 0, 1.0f); 2021 fade.setDuration(sShrinkAnimationDuration); 2022 fade.setInterpolator(new DecelerateInterpolator(2.0f)); 2023 AnimatorSet transitionSet = new AnimatorSet(); 2024 transitionSet.playTogether(height, fade); 2025 transitionSet.addListener(new HardwareLayerEnabler(this)); 2026 return transitionSet; 2027 } 2028 2029 /** 2030 * Grow the height of the item and fade it in when bringing a conversation 2031 * back from a destructive action. 2032 */ 2033 public Animator createDestroyWithSwipeAnimation() { 2034 ObjectAnimator slide = createTranslateXAnimation(false); 2035 ObjectAnimator height = createHeightAnimation(false); 2036 AnimatorSet transitionSet = new AnimatorSet(); 2037 transitionSet.playSequentially(slide, height); 2038 return transitionSet; 2039 } 2040 2041 private ObjectAnimator createTranslateXAnimation(boolean show) { 2042 SwipeableListView parent = getListView(); 2043 // If we can't get the parent...we have bigger problems. 2044 int width = parent != null ? parent.getMeasuredWidth() : 0; 2045 final float start = show ? width : 0f; 2046 final float end = show ? 0f : width; 2047 ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); 2048 slide.setInterpolator(new DecelerateInterpolator(2.0f)); 2049 slide.setDuration(sSlideAnimationDuration); 2050 return slide; 2051 } 2052 2053 public Animator createDestroyAnimation() { 2054 return createHeightAnimation(false); 2055 } 2056 2057 private ObjectAnimator createHeightAnimation(boolean show) { 2058 final float start = show ? 0f : 1.0f; 2059 final float end = show ? 1.0f : 0f; 2060 ObjectAnimator height = ObjectAnimator.ofFloat(this, "animatedHeightFraction", start, end); 2061 height.setInterpolator(new DecelerateInterpolator(2.0f)); 2062 height.setDuration(sShrinkAnimationDuration); 2063 return height; 2064 } 2065 2066 // Used by animator 2067 public void setAnimatedHeightFraction(float height) { 2068 mAnimatedHeightFraction = height; 2069 requestLayout(); 2070 } 2071 2072 @Override 2073 public SwipeableView getSwipeableView() { 2074 return SwipeableView.from(this); 2075 } 2076 2077 /** 2078 * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag. 2079 */ 2080 private boolean beginDragMode() { 2081 if (mLastTouchX < 0 || mLastTouchY < 0 || mSelectedConversationSet == null) { 2082 return false; 2083 } 2084 // If this is already checked, don't bother unchecking it! 2085 if (!mSelected) { 2086 toggleSelectedState(); 2087 } 2088 2089 // Clip data has form: [conversations_uri, conversationId1, 2090 // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...] 2091 final int count = mSelectedConversationSet.size(); 2092 String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count); 2093 2094 final ClipData data = ClipData.newUri(mContext.getContentResolver(), description, 2095 Conversation.MOVE_CONVERSATIONS_URI); 2096 for (Conversation conversation : mSelectedConversationSet.values()) { 2097 data.addItem(new Item(String.valueOf(conversation.position))); 2098 } 2099 // Protect against non-existent views: only happens for monkeys 2100 final int width = this.getWidth(); 2101 final int height = this.getHeight(); 2102 final boolean isDimensionNegative = (width < 0) || (height < 0); 2103 if (isDimensionNegative) { 2104 LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: " 2105 + "width=%d, height=%d", width, height); 2106 return false; 2107 } 2108 mActivity.startDragMode(); 2109 // Start drag mode 2110 startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0); 2111 2112 return true; 2113 } 2114 2115 /** 2116 * Handles the drag event. 2117 * 2118 * @param event the drag event to be handled 2119 */ 2120 @Override 2121 public boolean onDragEvent(DragEvent event) { 2122 switch (event.getAction()) { 2123 case DragEvent.ACTION_DRAG_ENDED: 2124 mActivity.stopDragMode(); 2125 return true; 2126 } 2127 return false; 2128 } 2129 2130 private class ShadowBuilder extends DragShadowBuilder { 2131 private final Drawable mBackground; 2132 2133 private final View mView; 2134 private final String mDragDesc; 2135 private final int mTouchX; 2136 private final int mTouchY; 2137 private int mDragDescX; 2138 private int mDragDescY; 2139 2140 public ShadowBuilder(View view, int count, int touchX, int touchY) { 2141 super(view); 2142 mView = view; 2143 mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo); 2144 mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count); 2145 mTouchX = touchX; 2146 mTouchY = touchY; 2147 } 2148 2149 @Override 2150 public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) { 2151 final int width = mView.getWidth(); 2152 final int height = mView.getHeight(); 2153 2154 sPaint.setTextSize(mCoordinates.subjectFontSize); 2155 mDragDescX = mCoordinates.sendersX; 2156 mDragDescY = (height - (int) mCoordinates.subjectFontSize) / 2 ; 2157 shadowSize.set(width, height); 2158 shadowTouchPoint.set(mTouchX, mTouchY); 2159 } 2160 2161 @Override 2162 public void onDrawShadow(Canvas canvas) { 2163 mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight()); 2164 mBackground.draw(canvas); 2165 sPaint.setTextSize(mCoordinates.subjectFontSize); 2166 canvas.drawText(mDragDesc, mDragDescX, mDragDescY - sPaint.ascent(), sPaint); 2167 } 2168 } 2169 2170 @Override 2171 public float getMinAllowScrollDistance() { 2172 return sScrollSlop; 2173 } 2174 2175 private abstract class CabAnimator { 2176 private ObjectAnimator mAnimator = null; 2177 2178 private final String mPropertyName; 2179 2180 private float mValue; 2181 2182 private final float mStartValue; 2183 private final float mEndValue; 2184 2185 private final long mDuration; 2186 2187 private boolean mReversing = false; 2188 2189 public CabAnimator(final String propertyName, final float startValue, final float endValue, 2190 final long duration) { 2191 mPropertyName = propertyName; 2192 2193 mStartValue = startValue; 2194 mEndValue = endValue; 2195 2196 mDuration = duration; 2197 } 2198 2199 private ObjectAnimator createAnimator() { 2200 final ObjectAnimator animator = ObjectAnimator.ofFloat(ConversationItemView.this, 2201 mPropertyName, mStartValue, mEndValue); 2202 animator.setDuration(mDuration); 2203 animator.setInterpolator(new LinearInterpolator()); 2204 animator.addListener(new AnimatorListenerAdapter() { 2205 @Override 2206 public void onAnimationEnd(final Animator animation) { 2207 invalidateArea(); 2208 } 2209 }); 2210 animator.addListener(mAnimatorListener); 2211 return animator; 2212 } 2213 2214 private final AnimatorListener mAnimatorListener = new AnimatorListener() { 2215 @Override 2216 public void onAnimationStart(final Animator animation) { 2217 // Do nothing 2218 } 2219 2220 @Override 2221 public void onAnimationEnd(final Animator animation) { 2222 if (mReversing) { 2223 mReversing = false; 2224 // We no longer want to track whether we were last selected, 2225 // since we no longer are selected 2226 mLastSelectedId = -1; 2227 } 2228 } 2229 2230 @Override 2231 public void onAnimationCancel(final Animator animation) { 2232 // Do nothing 2233 } 2234 2235 @Override 2236 public void onAnimationRepeat(final Animator animation) { 2237 // Do nothing 2238 } 2239 }; 2240 2241 public abstract void invalidateArea(); 2242 2243 public void setValue(final float fraction) { 2244 if (mValue == fraction) { 2245 return; 2246 } 2247 mValue = fraction; 2248 invalidateArea(); 2249 } 2250 2251 public float getValue() { 2252 return mValue; 2253 } 2254 2255 /** 2256 * @param reverse <code>true</code> to animate in reverse 2257 */ 2258 public void startAnimation(final boolean reverse) { 2259 if (mAnimator != null) { 2260 mAnimator.cancel(); 2261 } 2262 2263 mAnimator = createAnimator(); 2264 mReversing = reverse; 2265 2266 if (reverse) { 2267 mAnimator.reverse(); 2268 } else { 2269 mAnimator.start(); 2270 } 2271 } 2272 2273 public void stopAnimation() { 2274 if (mAnimator != null) { 2275 mAnimator.cancel(); 2276 mAnimator = null; 2277 } 2278 2279 mReversing = false; 2280 2281 setValue(0); 2282 } 2283 2284 public boolean isStarted() { 2285 return mAnimator != null && mAnimator.isStarted(); 2286 } 2287 } 2288 2289 public void setPhotoFlipFraction(final float fraction) { 2290 mPhotoFlipAnimator.setValue(fraction); 2291 } 2292 2293 public String getAccount() { 2294 return mAccount; 2295 } 2296 } 2297