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