ConversationItemView.java revision c9e00fcb05c5311633dc3a414b9bf685816b4350
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.browse; 19 20import android.animation.Animator; 21import android.animation.AnimatorSet; 22import android.animation.ObjectAnimator; 23import android.content.BroadcastReceiver; 24import android.content.Context; 25import android.content.Intent; 26import android.content.IntentFilter; 27import android.content.res.Resources; 28import android.graphics.Bitmap; 29import android.graphics.BitmapFactory; 30import android.graphics.Canvas; 31import android.graphics.Color; 32import android.graphics.Paint; 33import android.graphics.Rect; 34import android.graphics.Typeface; 35import android.graphics.drawable.Drawable; 36import android.graphics.drawable.InsetDrawable; 37import android.support.v4.text.BidiFormatter; 38import android.support.v4.text.TextUtilsCompat; 39import android.support.v4.view.ViewCompat; 40import android.text.Layout.Alignment; 41import android.text.Spannable; 42import android.text.SpannableString; 43import android.text.SpannableStringBuilder; 44import android.text.StaticLayout; 45import android.text.TextPaint; 46import android.text.TextUtils; 47import android.text.TextUtils.TruncateAt; 48import android.text.format.DateUtils; 49import android.text.style.BackgroundColorSpan; 50import android.text.style.CharacterStyle; 51import android.text.style.ForegroundColorSpan; 52import android.text.style.TextAppearanceSpan; 53import android.util.SparseArray; 54import android.util.TypedValue; 55import android.view.MotionEvent; 56import android.view.View; 57import android.view.ViewGroup; 58import android.view.ViewParent; 59import android.view.animation.DecelerateInterpolator; 60import android.widget.TextView; 61 62import com.android.mail.R; 63import com.android.mail.analytics.Analytics; 64import com.android.mail.bitmap.CheckableContactFlipDrawable; 65import com.android.mail.bitmap.ContactDrawable; 66import com.android.mail.perf.Timer; 67import com.android.mail.providers.Account; 68import com.android.mail.providers.Conversation; 69import com.android.mail.providers.Folder; 70import com.android.mail.providers.UIProvider; 71import com.android.mail.providers.UIProvider.ConversationColumns; 72import com.android.mail.providers.UIProvider.ConversationListIcon; 73import com.android.mail.providers.UIProvider.FolderType; 74import com.android.mail.ui.AnimatedAdapter; 75import com.android.mail.ui.ControllableActivity; 76import com.android.mail.ui.ConversationCheckedSet; 77import com.android.mail.ui.ConversationSetObserver; 78import com.android.mail.ui.FolderDisplayer; 79import com.android.mail.ui.SwipeableItemView; 80import com.android.mail.ui.SwipeableListView; 81import com.android.mail.utils.FolderUri; 82import com.android.mail.utils.HardwareLayerEnabler; 83import com.android.mail.utils.LogTag; 84import com.android.mail.utils.LogUtils; 85import com.android.mail.utils.Utils; 86import com.android.mail.utils.ViewUtils; 87import com.google.common.annotations.VisibleForTesting; 88 89import java.util.List; 90import java.util.Locale; 91 92public class ConversationItemView extends View 93 implements SwipeableItemView, ToggleableItem, ConversationSetObserver, 94 BadgeSpan.BadgeSpanDimensions { 95 96 // Timer. 97 private static int sLayoutCount = 0; 98 private static Timer sTimer; // Create the sTimer here if you need to do 99 // perf analysis. 100 private static final int PERF_LAYOUT_ITERATIONS = 50; 101 private static final String PERF_TAG_LAYOUT = "CCHV.layout"; 102 private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps"; 103 private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj"; 104 private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders"; 105 private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates"; 106 private static final String LOG_TAG = LogTag.getLogTag(); 107 108 private static final Typeface SANS_SERIF_BOLD = Typeface.create("sans-serif", Typeface.BOLD); 109 110 private static final Typeface SANS_SERIF_LIGHT = Typeface.create("sans-serif-light", 111 Typeface.NORMAL); 112 113 private static final int[] CHECKED_STATE = new int[] { android.R.attr.state_checked }; 114 115 // Static bitmaps. 116 private static Bitmap STAR_OFF; 117 private static Bitmap STAR_ON; 118 private static Bitmap ATTACHMENT; 119 private static Bitmap ONLY_TO_ME; 120 private static Bitmap TO_ME_AND_OTHERS; 121 private static Bitmap IMPORTANT_ONLY_TO_ME; 122 private static Bitmap IMPORTANT_TO_ME_AND_OTHERS; 123 private static Bitmap IMPORTANT; 124 private static Bitmap STATE_REPLIED; 125 private static Bitmap STATE_FORWARDED; 126 private static Bitmap STATE_REPLIED_AND_FORWARDED; 127 private static Bitmap STATE_CALENDAR_INVITE; 128 private static Drawable VISIBLE_CONVERSATION_HIGHLIGHT; 129 130 private static String sSendersSplitToken; 131 private static String sElidedPaddingToken; 132 133 // Static colors. 134 private static int sSendersTextColor; 135 private static int sDateTextColorRead; 136 private static int sDateTextColorUnread; 137 private static int sStarTouchSlop; 138 private static int sSenderImageTouchSlop; 139 private static int sShrinkAnimationDuration; 140 private static int sSlideAnimationDuration; 141 private static int sCabAnimationDuration; 142 private static int sBadgePaddingExtraWidth; 143 private static int sBadgeRoundedCornerRadius; 144 145 // Static paints. 146 private static final TextPaint sPaint = new TextPaint(); 147 private static final TextPaint sFoldersPaint = new TextPaint(); 148 private static final Paint sCheckBackgroundPaint = new Paint(); 149 private static final Paint sDividerPaint = new Paint(); 150 151 private static int sDividerHeight; 152 153 private static BroadcastReceiver sConfigurationChangedReceiver; 154 155 // Backgrounds for different states. 156 private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>(); 157 158 // Dimensions and coordinates. 159 private int mViewWidth = -1; 160 /** The view mode at which we calculated mViewWidth previously. */ 161 private int mPreviousMode; 162 163 private int mInfoIconX; 164 private int mDateX; 165 private int mDateWidth; 166 private int mPaperclipX; 167 private int mSendersX; 168 private int mSendersWidth; 169 170 /** Whether we are on a tablet device or not */ 171 private final boolean mTabletDevice; 172 /** When in conversation mode, true if the list is hidden */ 173 private final boolean mListCollapsible; 174 175 @VisibleForTesting 176 ConversationItemViewCoordinates mCoordinates; 177 178 private ConversationItemViewCoordinates.Config mConfig; 179 180 private final Context mContext; 181 182 private ConversationItemViewModel mHeader; 183 private boolean mDownEvent; 184 private boolean mChecked = false; 185 private ConversationCheckedSet mCheckedConversationSet; 186 private Folder mDisplayedFolder; 187 private boolean mStarEnabled; 188 private boolean mSwipeEnabled; 189 private boolean mDividerEnabled; 190 private AnimatedAdapter mAdapter; 191 private float mAnimatedHeightFraction = 1.0f; 192 private final Account mAccount; 193 private ControllableActivity mActivity; 194 private final TextView mSendersTextView; 195 private final TextView mSubjectTextView; 196 private final TextView mSnippetTextView; 197 private int mGadgetMode; 198 199 private static int sFoldersMaxCount; 200 private static TextAppearanceSpan sSubjectTextUnreadSpan; 201 private static TextAppearanceSpan sSubjectTextReadSpan; 202 private static TextAppearanceSpan sBadgeTextSpan; 203 private static BackgroundColorSpan sBadgeBackgroundSpan; 204 private static int sScrollSlop; 205 private static CharacterStyle sActivatedTextSpan; 206 207 private final CheckableContactFlipDrawable mSendersImageView; 208 209 /** The resource id of the color to use to override the background. */ 210 private int mBackgroundOverrideResId = -1; 211 /** The bitmap to use, or <code>null</code> for the default */ 212 private Bitmap mPhotoBitmap = null; 213 private Rect mPhotoRect = null; 214 215 /** 216 * A listener for clicks on the various areas of a conversation item. 217 */ 218 public interface ConversationItemAreaClickListener { 219 /** Called when the info icon is clicked. */ 220 void onInfoIconClicked(); 221 222 /** Called when the star is clicked. */ 223 void onStarClicked(); 224 } 225 226 /** If set, it will steal all clicks for which the interface has a click method. */ 227 private ConversationItemAreaClickListener mConversationItemAreaClickListener = null; 228 229 static { 230 sPaint.setAntiAlias(true); 231 sFoldersPaint.setAntiAlias(true); 232 233 sCheckBackgroundPaint.setColor(Color.GRAY); 234 } 235 236 /** 237 * Handles displaying folders in a conversation header view. 238 */ 239 static class ConversationItemFolderDisplayer extends FolderDisplayer { 240 private final BidiFormatter mFormatter; 241 private int mFoldersCount; 242 243 public ConversationItemFolderDisplayer(Context context, BidiFormatter formatter) { 244 super(context); 245 mFormatter = formatter; 246 } 247 248 @Override 249 protected void initializeDrawableResources() { 250 super.initializeDrawableResources(); 251 final Resources res = mContext.getResources(); 252 mFolderDrawableResources.overflowGradientPadding = 253 res.getDimensionPixelOffset(R.dimen.folder_tl_gradient_padding); 254 mFolderDrawableResources.folderHorizontalPadding = 255 res.getDimensionPixelOffset(R.dimen.folder_tl_cell_content_padding); 256 mFolderDrawableResources.folderVerticalPadding = 257 res.getDimensionPixelOffset(R.dimen.folder_tl_top_bottom_padding); 258 mFolderDrawableResources.folderFontSize = 259 res.getDimensionPixelOffset(R.dimen.folder_tl_font_size); 260 } 261 262 @Override 263 public void loadConversationFolders(Conversation conv, final FolderUri ignoreFolderUri, 264 final int ignoreFolderType) { 265 super.loadConversationFolders(conv, ignoreFolderUri, ignoreFolderType); 266 mFoldersCount = mFoldersSortedSet.size(); 267 } 268 269 @Override 270 public void reset() { 271 super.reset(); 272 mFoldersCount = 0; 273 } 274 275 public boolean hasVisibleFolders() { 276 return mFoldersCount > 0; 277 } 278 279 /** 280 * @return how much total space the folders list requires. 281 */ 282 private int measureFolders(ConversationItemViewCoordinates coordinates) { 283 final int[] measurements = measureFolderDimen( 284 mFoldersSortedSet, coordinates.folderCellWidth, coordinates.folderLayoutWidth, 285 mFolderDrawableResources.folderInBetweenPadding, 286 mFolderDrawableResources.folderHorizontalPadding, sFoldersMaxCount, 287 sFoldersPaint); 288 return sumWidth(measurements); 289 } 290 291 private int sumWidth(int[] arr) { 292 int sum = 0; 293 for (int i : arr) { 294 sum += i; 295 } 296 return sum + (arr.length - 1) * mFolderDrawableResources.folderInBetweenPadding; 297 } 298 299 public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates, 300 boolean isRtl) { 301 if (mFoldersCount == 0) { 302 return; 303 } 304 305 final int[] measurements = measureFolderDimen( 306 mFoldersSortedSet, coordinates.folderCellWidth, coordinates.folderLayoutWidth, 307 mFolderDrawableResources.folderInBetweenPadding, 308 mFolderDrawableResources.folderHorizontalPadding, sFoldersMaxCount, 309 sFoldersPaint); 310 311 final int right = coordinates.foldersRight; 312 final int y = coordinates.foldersY; 313 314 sFoldersPaint.setTextSize(coordinates.foldersFontSize); 315 sFoldersPaint.setTypeface(coordinates.foldersTypeface); 316 317 // Initialize space and cell size based on the current mode. 318 final int foldersCount = measurements.length; 319 final int width = sumWidth(measurements); 320 int xStart = (isRtl) ? coordinates.snippetX + width : right - width; 321 322 int index = 0; 323 for (Folder folder : mFoldersSortedSet) { 324 if (index > foldersCount - 1) { 325 break; 326 } 327 328 final int actualStart = isRtl ? xStart - measurements[index] : xStart; 329 final int height = (int) coordinates.foldersFontSize + 330 2 * mFolderDrawableResources.folderVerticalPadding; 331 drawFolder(canvas, actualStart, y, measurements[index], height, folder, 332 mFolderDrawableResources, mFormatter, sFoldersPaint); 333 334 // Increment the starting position accordingly for the next item 335 final int usedWidth = measurements[index++] + 336 mFolderDrawableResources.folderInBetweenPadding; 337 xStart += (isRtl) ? -usedWidth : usedWidth; 338 } 339 } 340 } 341 342 public ConversationItemView(Context context, Account account) { 343 super(context); 344 Utils.traceBeginSection("CIVC constructor"); 345 setClickable(true); 346 setLongClickable(true); 347 mContext = context.getApplicationContext(); 348 final Resources res = mContext.getResources(); 349 mTabletDevice = Utils.useTabletUI(res); 350 mListCollapsible = !res.getBoolean(R.bool.is_tablet_landscape); 351 mAccount = account; 352 353 getItemViewResources(mContext); 354 355 final int layoutDir = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()); 356 357 mSendersTextView = new TextView(mContext); 358 mSendersTextView.setIncludeFontPadding(false); 359 360 mSubjectTextView = new TextView(mContext); 361 mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END); 362 mSubjectTextView.setIncludeFontPadding(false); 363 ViewCompat.setLayoutDirection(mSubjectTextView, layoutDir); 364 ViewUtils.setTextAlignment(mSubjectTextView, View.TEXT_ALIGNMENT_VIEW_START); 365 366 mSnippetTextView = new TextView(mContext); 367 mSnippetTextView.setEllipsize(TextUtils.TruncateAt.END); 368 mSnippetTextView.setIncludeFontPadding(false); 369 mSnippetTextView.setTypeface(SANS_SERIF_LIGHT); 370 mSnippetTextView.setTextColor(getResources().getColor(R.color.snippet_text_color)); 371 ViewCompat.setLayoutDirection(mSnippetTextView, layoutDir); 372 ViewUtils.setTextAlignment(mSnippetTextView, View.TEXT_ALIGNMENT_VIEW_START); 373 374 // hack for b/16345519. Root cause is b/17280038. 375 if (layoutDir == LAYOUT_DIRECTION_RTL) { 376 mSubjectTextView.setMaxLines(1); 377 mSnippetTextView.setMaxLines(1); 378 } else { 379 mSubjectTextView.setSingleLine(); 380 mSnippetTextView.setSingleLine(); 381 } 382 383 mSendersImageView = new CheckableContactFlipDrawable(res, sCabAnimationDuration); 384 mSendersImageView.setCallback(this); 385 386 Utils.traceEndSection(); 387 } 388 389 private static synchronized void getItemViewResources(Context context) { 390 if (sConfigurationChangedReceiver == null) { 391 sConfigurationChangedReceiver = new BroadcastReceiver() { 392 @Override 393 public void onReceive(Context context, Intent intent) { 394 STAR_OFF = null; 395 getItemViewResources(context); 396 } 397 }; 398 context.registerReceiver(sConfigurationChangedReceiver, new IntentFilter( 399 Intent.ACTION_CONFIGURATION_CHANGED)); 400 } 401 if (STAR_OFF == null) { 402 final Resources res = context.getResources(); 403 // Initialize static bitmaps. 404 STAR_OFF = BitmapFactory.decodeResource(res, R.drawable.ic_star_outline_20dp); 405 STAR_ON = BitmapFactory.decodeResource(res, R.drawable.ic_star_20dp); 406 ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attach_file_20dp); 407 ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double); 408 TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single); 409 IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res, 410 R.drawable.ic_email_caret_double_important_unread); 411 IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, 412 R.drawable.ic_email_caret_single_important_unread); 413 IMPORTANT = BitmapFactory.decodeResource(res, 414 R.drawable.ic_email_caret_none_important_unread); 415 STATE_REPLIED = 416 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light); 417 STATE_FORWARDED = 418 BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light); 419 STATE_REPLIED_AND_FORWARDED = 420 BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light); 421 STATE_CALENDAR_INVITE = 422 BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light); 423 VISIBLE_CONVERSATION_HIGHLIGHT = res.getDrawable( 424 R.drawable.visible_conversation_highlight); 425 426 // Initialize colors. 427 sActivatedTextSpan = CharacterStyle.wrap(new ForegroundColorSpan( 428 res.getColor(R.color.senders_text_color))); 429 sSendersTextColor = res.getColor(R.color.senders_text_color); 430 sSubjectTextUnreadSpan = new TextAppearanceSpan(context, 431 R.style.SubjectAppearanceUnreadStyle); 432 sSubjectTextReadSpan = new TextAppearanceSpan( 433 context, R.style.SubjectAppearanceReadStyle); 434 435 sBadgeTextSpan = new TextAppearanceSpan(context, R.style.BadgeTextStyle); 436 sBadgeBackgroundSpan = new BackgroundColorSpan( 437 res.getColor(R.color.badge_background_color)); 438 sDateTextColorRead = res.getColor(R.color.date_text_color_read); 439 sDateTextColorUnread = res.getColor(R.color.date_text_color_unread); 440 sStarTouchSlop = res.getDimensionPixelSize(R.dimen.star_touch_slop); 441 sSenderImageTouchSlop = res.getDimensionPixelSize(R.dimen.sender_image_touch_slop); 442 sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration); 443 sSlideAnimationDuration = res.getInteger(R.integer.slide_animation_duration); 444 // Initialize static color. 445 sSendersSplitToken = res.getString(R.string.senders_split_token); 446 sElidedPaddingToken = res.getString(R.string.elided_padding_token); 447 sScrollSlop = res.getInteger(R.integer.swipeScrollSlop); 448 sFoldersMaxCount = res.getInteger(R.integer.conversation_list_max_folder_count); 449 sCabAnimationDuration = res.getInteger(R.integer.conv_item_view_cab_anim_duration); 450 sBadgePaddingExtraWidth = res.getDimensionPixelSize(R.dimen.badge_padding_extra_width); 451 sBadgeRoundedCornerRadius = 452 res.getDimensionPixelSize(R.dimen.badge_rounded_corner_radius); 453 sDividerPaint.setColor(res.getColor(R.color.divider_color)); 454 sDividerHeight = res.getDimensionPixelSize(R.dimen.divider_height); 455 } 456 } 457 458 public void bind(final Conversation conversation, final ControllableActivity activity, 459 final ConversationCheckedSet set, final Folder folder, 460 final int checkboxOrSenderImage, 461 final boolean swipeEnabled, final boolean importanceMarkersEnabled, 462 final boolean showChevronsEnabled, final AnimatedAdapter adapter) { 463 Utils.traceBeginSection("CIVC.bind"); 464 bind(ConversationItemViewModel.forConversation(mAccount.getEmailAddress(), conversation), 465 activity, null /* conversationItemAreaClickListener */, 466 set, folder, checkboxOrSenderImage, swipeEnabled, importanceMarkersEnabled, 467 showChevronsEnabled, adapter, -1 /* backgroundOverrideResId */, 468 null /* photoBitmap */, false /* useFullMargins */, true /* mDividerEnabled */); 469 Utils.traceEndSection(); 470 } 471 472 public void bindAd(final ConversationItemViewModel conversationItemViewModel, 473 final ControllableActivity activity, 474 final ConversationItemAreaClickListener conversationItemAreaClickListener, 475 final Folder folder, final int checkboxOrSenderImage, final AnimatedAdapter adapter, 476 final int backgroundOverrideResId, final Bitmap photoBitmap) { 477 Utils.traceBeginSection("CIVC.bindAd"); 478 bind(conversationItemViewModel, activity, conversationItemAreaClickListener, null /* set */, 479 folder, checkboxOrSenderImage, true /* swipeEnabled */, 480 false /* importanceMarkersEnabled */, false /* showChevronsEnabled */, 481 adapter, backgroundOverrideResId, photoBitmap, true /* useFullMargins */, 482 false /* mDividerEnabled */); 483 Utils.traceEndSection(); 484 } 485 486 private void bind(final ConversationItemViewModel header, final ControllableActivity activity, 487 final ConversationItemAreaClickListener conversationItemAreaClickListener, 488 final ConversationCheckedSet set, final Folder folder, 489 final int checkboxOrSenderImage, 490 boolean swipeEnabled, final boolean importanceMarkersEnabled, 491 final boolean showChevronsEnabled, final AnimatedAdapter adapter, 492 final int backgroundOverrideResId, final Bitmap photoBitmap, 493 final boolean useFullMargins, final boolean dividerEnabled) { 494 mBackgroundOverrideResId = backgroundOverrideResId; 495 mPhotoBitmap = photoBitmap; 496 mConversationItemAreaClickListener = conversationItemAreaClickListener; 497 mDividerEnabled = dividerEnabled; 498 499 if (mHeader != null) { 500 Utils.traceBeginSection("unbind"); 501 final boolean newlyBound = header.conversation.id != mHeader.conversation.id; 502 // If this was previously bound to a different conversation, remove any contact photo 503 // manager requests. 504 if (newlyBound || (!mHeader.displayableNames.equals(header.displayableNames))) { 505 mSendersImageView.getContactDrawable().unbind(); 506 } 507 508 if (newlyBound) { 509 // Stop the photo flip animation 510 final boolean showSenders = !mChecked; 511 mSendersImageView.reset(showSenders); 512 } 513 Utils.traceEndSection(); 514 } 515 mCoordinates = null; 516 mHeader = header; 517 mActivity = activity; 518 mCheckedConversationSet = set; 519 if (mCheckedConversationSet != null) { 520 mCheckedConversationSet.addObserver(this); 521 } 522 mDisplayedFolder = folder; 523 mStarEnabled = folder != null && !folder.isTrash(); 524 mSwipeEnabled = swipeEnabled; 525 mAdapter = adapter; 526 527 Utils.traceBeginSection("drawables"); 528 mSendersImageView.getContactDrawable().setBitmapCache(mAdapter.getSendersImagesCache()); 529 mSendersImageView.getContactDrawable().setContactResolver(mAdapter.getContactResolver()); 530 Utils.traceEndSection(); 531 532 if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) { 533 mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO; 534 } else { 535 mGadgetMode = ConversationItemViewCoordinates.GADGET_NONE; 536 } 537 538 Utils.traceBeginSection("folder displayer"); 539 // Initialize folder displayer. 540 if (mHeader.folderDisplayer == null) { 541 mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext, 542 mAdapter.getBidiFormatter()); 543 } else { 544 mHeader.folderDisplayer.reset(); 545 } 546 Utils.traceEndSection(); 547 548 final int ignoreFolderType; 549 if (mDisplayedFolder.isInbox()) { 550 ignoreFolderType = FolderType.INBOX; 551 } else { 552 ignoreFolderType = -1; 553 } 554 555 Utils.traceBeginSection("load folders"); 556 mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, 557 mDisplayedFolder.folderUri, ignoreFolderType); 558 Utils.traceEndSection(); 559 560 if (mHeader.showDateText) { 561 Utils.traceBeginSection("relative time"); 562 mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext, 563 mHeader.conversation.dateMs); 564 Utils.traceEndSection(); 565 } else { 566 mHeader.dateText = ""; 567 } 568 569 Utils.traceBeginSection("config setup"); 570 mConfig = new ConversationItemViewCoordinates.Config() 571 .withGadget(mGadgetMode) 572 .setUseFullMargins(useFullMargins); 573 if (header.folderDisplayer.hasVisibleFolders()) { 574 mConfig.showFolders(); 575 } 576 if (header.hasBeenForwarded || header.hasBeenRepliedTo || header.isInvite) { 577 mConfig.showReplyState(); 578 } 579 if (mHeader.conversation.color != 0) { 580 mConfig.showColorBlock(); 581 } 582 583 // Importance markers and chevrons (personal level indicators). 584 mHeader.personalLevelBitmap = null; 585 final int personalLevel = mHeader.conversation.personalLevel; 586 final boolean isImportant = 587 mHeader.conversation.priority == UIProvider.ConversationPriority.IMPORTANT; 588 final boolean useImportantMarkers = isImportant && importanceMarkersEnabled; 589 if (showChevronsEnabled && 590 personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) { 591 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME 592 : ONLY_TO_ME; 593 } else if (showChevronsEnabled && 594 personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) { 595 mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS 596 : TO_ME_AND_OTHERS; 597 } else if (useImportantMarkers) { 598 mHeader.personalLevelBitmap = IMPORTANT; 599 } 600 if (mHeader.personalLevelBitmap != null) { 601 mConfig.showPersonalIndicator(); 602 } 603 Utils.traceEndSection(); 604 605 Utils.traceBeginSection("content description"); 606 setContentDescription(); 607 Utils.traceEndSection(); 608 requestLayout(); 609 } 610 611 @Override 612 protected void onDetachedFromWindow() { 613 super.onDetachedFromWindow(); 614 615 if (mCheckedConversationSet != null) { 616 mCheckedConversationSet.removeObserver(this); 617 } 618 } 619 620 @Override 621 public void invalidateDrawable(final Drawable who) { 622 boolean handled = false; 623 if (mCoordinates != null) { 624 if (mSendersImageView.equals(who)) { 625 final Rect r = new Rect(who.getBounds()); 626 r.offset(mCoordinates.contactImagesX, mCoordinates.contactImagesY); 627 ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom); 628 handled = true; 629 } 630 } 631 if (!handled) { 632 super.invalidateDrawable(who); 633 } 634 } 635 636 /** 637 * Get the Conversation object associated with this view. 638 */ 639 public Conversation getConversation() { 640 return mHeader.conversation; 641 } 642 643 private static void startTimer(String tag) { 644 if (sTimer != null) { 645 sTimer.start(tag); 646 } 647 } 648 649 private static void pauseTimer(String tag) { 650 if (sTimer != null) { 651 sTimer.pause(tag); 652 } 653 } 654 655 @Override 656 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 657 Utils.traceBeginSection("CIVC.measure"); 658 final int wSize = MeasureSpec.getSize(widthMeasureSpec); 659 660 final int currentMode = mActivity.getViewMode().getMode(); 661 if (wSize != mViewWidth || mPreviousMode != currentMode) { 662 mViewWidth = wSize; 663 mPreviousMode = currentMode; 664 } 665 mHeader.viewWidth = mViewWidth; 666 667 mConfig.updateWidth(wSize).setViewMode(currentMode) 668 .setLayoutDirection(ViewCompat.getLayoutDirection(this)); 669 670 Resources res = getResources(); 671 mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen); 672 673 mCoordinates = ConversationItemViewCoordinates.forConfig(mContext, mConfig, 674 mAdapter.getCoordinatesCache()); 675 676 if (mPhotoBitmap != null) { 677 mPhotoRect = new Rect(0, 0, mCoordinates.contactImagesWidth, 678 mCoordinates.contactImagesHeight); 679 } 680 681 final int h = (mAnimatedHeightFraction != 1.0f) ? 682 Math.round(mAnimatedHeightFraction * mCoordinates.height) : mCoordinates.height; 683 setMeasuredDimension(mConfig.getWidth(), h); 684 Utils.traceEndSection(); 685 } 686 687 @Override 688 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 689 startTimer(PERF_TAG_LAYOUT); 690 Utils.traceBeginSection("CIVC.layout"); 691 692 super.onLayout(changed, left, top, right, bottom); 693 694 Utils.traceBeginSection("text and bitmaps"); 695 calculateTextsAndBitmaps(); 696 Utils.traceEndSection(); 697 698 Utils.traceBeginSection("coordinates"); 699 calculateCoordinates(); 700 Utils.traceEndSection(); 701 702 // Subject. 703 Utils.traceBeginSection("subject"); 704 createSubject(mHeader.unread); 705 706 createSnippet(); 707 708 if (!mHeader.isLayoutValid()) { 709 setContentDescription(); 710 } 711 mHeader.validate(); 712 Utils.traceEndSection(); 713 714 pauseTimer(PERF_TAG_LAYOUT); 715 if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) { 716 sTimer.dumpResults(); 717 sTimer = new Timer(); 718 sLayoutCount = 0; 719 } 720 Utils.traceEndSection(); 721 } 722 723 private void setContentDescription() { 724 if (mActivity.isAccessibilityEnabled()) { 725 mHeader.resetContentDescription(); 726 setContentDescription( 727 mHeader.getContentDescription(mContext, mDisplayedFolder.shouldShowRecipients())); 728 } 729 } 730 731 @Override 732 public void setBackgroundResource(int resourceId) { 733 Utils.traceBeginSection("set background resource"); 734 Drawable drawable = mBackgrounds.get(resourceId); 735 if (drawable == null) { 736 drawable = getResources().getDrawable(resourceId); 737 final int insetPadding = mHeader.insetPadding; 738 if (insetPadding > 0) { 739 drawable = new InsetDrawable(drawable, insetPadding); 740 } 741 mBackgrounds.put(resourceId, drawable); 742 } 743 if (getBackground() != drawable) { 744 super.setBackground(drawable); 745 } 746 Utils.traceEndSection(); 747 } 748 749 private void calculateTextsAndBitmaps() { 750 startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 751 752 if (mCheckedConversationSet != null) { 753 setChecked(mCheckedConversationSet.contains(mHeader.conversation)); 754 } 755 mHeader.gadgetMode = mGadgetMode; 756 757 updateBackground(); 758 759 mHeader.sendersDisplayText = new SpannableStringBuilder(); 760 761 mHeader.hasDraftMessage = mHeader.conversation.numDrafts() > 0; 762 763 // Parse senders fragments. 764 if (mHeader.preserveSendersText) { 765 // This is a special view that doesn't need special sender formatting 766 mHeader.sendersDisplayText = new SpannableStringBuilder(mHeader.sendersText); 767 loadImages(); 768 } else if (mHeader.conversation.conversationInfo != null) { 769 Context context = getContext(); 770 mHeader.messageInfoString = SendersView 771 .createMessageInfo(context, mHeader.conversation, true); 772 int maxChars = ConversationItemViewCoordinates.getSendersLength(context, 773 mCoordinates.getMode(), mHeader.conversation.hasAttachments); 774 775 mHeader.mSenderAvatarModel.clear(); 776 mHeader.displayableNames.clear(); 777 mHeader.styledNames.clear(); 778 779 SendersView.format(context, mHeader.conversation.conversationInfo, 780 mHeader.messageInfoString.toString(), maxChars, mHeader.styledNames, 781 mHeader.displayableNames, mHeader.mSenderAvatarModel, 782 mAccount, mDisplayedFolder.shouldShowRecipients(), true); 783 784 // If we have displayable senders, load their thumbnails 785 loadImages(); 786 } else { 787 LogUtils.wtf(LOG_TAG, "Null conversationInfo"); 788 } 789 790 if (mHeader.isLayoutValid()) { 791 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 792 return; 793 } 794 startTimer(PERF_TAG_CALCULATE_FOLDERS); 795 796 797 pauseTimer(PERF_TAG_CALCULATE_FOLDERS); 798 799 // Paper clip icon. 800 mHeader.paperclip = null; 801 if (mHeader.conversation.hasAttachments) { 802 mHeader.paperclip = ATTACHMENT; 803 } 804 805 startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 806 807 pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT); 808 pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS); 809 } 810 811 // FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which 812 // is immutable. 813 private void loadImages() { 814 if (mGadgetMode != ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 815 || mHeader.mSenderAvatarModel.isNotPopulated()) { 816 return; 817 } 818 if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) { 819 LogUtils.w(LOG_TAG, 820 "Contact image width(%d) or height(%d) is 0 for mode: (%d).", 821 mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight, 822 mCoordinates.getMode()); 823 return; 824 } 825 826 mSendersImageView 827 .setBounds(0, 0, mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight); 828 829 Utils.traceBeginSection("load sender image"); 830 final ContactDrawable drawable = mSendersImageView.getContactDrawable(); 831 drawable.setDecodeDimensions(mCoordinates.contactImagesWidth, 832 mCoordinates.contactImagesHeight); 833 drawable.bind(mHeader.mSenderAvatarModel.getName(), 834 mHeader.mSenderAvatarModel.getEmailAddress()); 835 Utils.traceEndSection(); 836 } 837 838 private static int makeExactSpecForSize(int size) { 839 return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY); 840 } 841 842 private static void layoutViewExactly(View v, int w, int h) { 843 v.measure(makeExactSpecForSize(w), makeExactSpecForSize(h)); 844 v.layout(0, 0, w, h); 845 } 846 847 private void layoutParticipantText(SpannableStringBuilder participantText) { 848 if (participantText != null) { 849 if (isActivated() && showActivatedText()) { 850 participantText.setSpan(sActivatedTextSpan, 0, 851 mHeader.styledMessageInfoStringOffset, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 852 } else { 853 participantText.removeSpan(sActivatedTextSpan); 854 } 855 856 final int w = mSendersWidth; 857 final int h = mCoordinates.sendersHeight; 858 mSendersTextView.setLayoutParams(new ViewGroup.LayoutParams(w, h)); 859 mSendersTextView.setMaxLines(mCoordinates.sendersLineCount); 860 mSendersTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.sendersFontSize); 861 layoutViewExactly(mSendersTextView, w, h); 862 863 mSendersTextView.setText(participantText); 864 } 865 } 866 867 private void createSubject(final boolean isUnread) { 868 final String badgeText = mHeader.badgeText == null ? "" : mHeader.badgeText; 869 String subject = filterTag(getContext(), mHeader.conversation.subject); 870 subject = Conversation.getSubjectForDisplay(mContext, badgeText, subject); 871 final Spannable displayedStringBuilder = new SpannableString(subject); 872 873 // since spans affect text metrics, add spans to the string before measure/layout or fancy 874 // ellipsizing 875 876 final int badgeTextLength = formatBadgeText(displayedStringBuilder, badgeText); 877 878 if (!TextUtils.isEmpty(subject)) { 879 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap( 880 isUnread ? sSubjectTextUnreadSpan : sSubjectTextReadSpan), 881 badgeTextLength, subject.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 882 } 883 if (isActivated() && showActivatedText()) { 884 displayedStringBuilder.setSpan(sActivatedTextSpan, badgeTextLength, 885 displayedStringBuilder.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); 886 } 887 888 final int subjectWidth = mCoordinates.subjectWidth; 889 final int subjectHeight = mCoordinates.subjectHeight; 890 mSubjectTextView.setLayoutParams(new ViewGroup.LayoutParams(subjectWidth, subjectHeight)); 891 mSubjectTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.subjectFontSize); 892 layoutViewExactly(mSubjectTextView, subjectWidth, subjectHeight); 893 894 mSubjectTextView.setText(displayedStringBuilder); 895 } 896 897 private void createSnippet() { 898 final String snippet = mHeader.conversation.getSnippet(); 899 final Spannable displayedStringBuilder = new SpannableString(snippet); 900 901 // measure the width of the folders which overlap the snippet view 902 final int folderWidth = mHeader.folderDisplayer.measureFolders(mCoordinates); 903 904 // size the snippet view by subtracting the folder width from the maximum snippet width 905 final int snippetWidth = mCoordinates.maxSnippetWidth - folderWidth; 906 final int snippetHeight = mCoordinates.snippetHeight; 907 mSnippetTextView.setLayoutParams(new ViewGroup.LayoutParams(snippetWidth, snippetHeight)); 908 mSnippetTextView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mCoordinates.snippetFontSize); 909 layoutViewExactly(mSnippetTextView, snippetWidth, snippetHeight); 910 911 mSnippetTextView.setText(displayedStringBuilder); 912 } 913 914 private int formatBadgeText(Spannable displayedStringBuilder, String badgeText) { 915 final int badgeTextLength = (badgeText != null) ? badgeText.length() : 0; 916 if (!TextUtils.isEmpty(badgeText)) { 917 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap(sBadgeTextSpan), 918 0, badgeTextLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 919 displayedStringBuilder.setSpan(TextAppearanceSpan.wrap(sBadgeBackgroundSpan), 920 0, badgeTextLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 921 displayedStringBuilder.setSpan(new BadgeSpan(displayedStringBuilder, this), 922 0, badgeTextLength, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 923 } 924 925 return badgeTextLength; 926 } 927 928 // START BadgeSpan.BadgeSpanDimensions override 929 930 @Override 931 public int getHorizontalPadding() { 932 return sBadgePaddingExtraWidth; 933 } 934 935 @Override 936 public float getRoundedCornerRadius() { 937 return sBadgeRoundedCornerRadius; 938 } 939 940 // END BadgeSpan.BadgeSpanDimensions override 941 942 private boolean showActivatedText() { 943 // For activated elements in tablet in conversation mode, we show an activated color, since 944 // the background is dark blue for activated versus gray for non-activated. 945 return mTabletDevice && !mListCollapsible; 946 } 947 948 private void calculateCoordinates() { 949 startTimer(PERF_TAG_CALCULATE_COORDINATES); 950 951 sPaint.setTextSize(mCoordinates.dateFontSize); 952 sPaint.setTypeface(Typeface.DEFAULT); 953 954 final boolean isRtl = ViewUtils.isViewRtl(this); 955 956 mDateWidth = (int) sPaint.measureText( 957 mHeader.dateText != null ? mHeader.dateText.toString() : ""); 958 if (mHeader.infoIcon != null) { 959 mInfoIconX = (isRtl) ? mCoordinates.infoIconX : 960 mCoordinates.infoIconXRight - mHeader.infoIcon.getWidth(); 961 962 // If we have an info icon, we start drawing the date text: 963 // At the end of the date TextView minus the width of the date text 964 // In RTL mode, we just use dateX 965 mDateX = (isRtl) ? mCoordinates.dateX : mCoordinates.dateXRight - mDateWidth; 966 } else { 967 // If there is no info icon, we start drawing the date text: 968 // At the end of the info icon ImageView minus the width of the date text 969 // We use the info icon ImageView for positioning, since we want the date text to be 970 // at the right, since there is no info icon 971 // In RTL, we just use infoIconX 972 mDateX = (isRtl) ? mCoordinates.infoIconX : mCoordinates.infoIconXRight - mDateWidth; 973 } 974 975 // The paperclip is drawn starting at the start of the date text minus 976 // the width of the paperclip and the date padding. 977 // In RTL mode, it is at the end of the date (mDateX + mDateWidth) plus the 978 // start date padding. 979 mPaperclipX = (isRtl) ? mDateX + mDateWidth + mCoordinates.datePaddingStart : 980 mDateX - ATTACHMENT.getWidth() - mCoordinates.datePaddingStart; 981 982 // In normal mode, the senders x and width is based 983 // on where the date/attachment icon start. 984 final int dateAttachmentStart; 985 // Have this end near the paperclip or date, not the folders. 986 if (mHeader.paperclip != null) { 987 // If there is a paperclip, the date/attachment start is at the start 988 // of the paperclip minus the paperclip padding. 989 // In RTL, it is at the end of the paperclip plus the paperclip padding. 990 dateAttachmentStart = (isRtl) ? 991 mPaperclipX + ATTACHMENT.getWidth() + mCoordinates.paperclipPaddingStart 992 : mPaperclipX - mCoordinates.paperclipPaddingStart; 993 } else { 994 // If no paperclip, just use the start of the date minus the date padding start. 995 // In RTL mode, this is just the paperclipX. 996 dateAttachmentStart = (isRtl) ? 997 mPaperclipX : mDateX - mCoordinates.datePaddingStart; 998 } 999 // Senders width is the dateAttachmentStart - sendersX. 1000 // In RTL, it is sendersWidth + sendersX - dateAttachmentStart. 1001 mSendersWidth = (isRtl) ? 1002 mCoordinates.sendersWidth + mCoordinates.sendersX - dateAttachmentStart 1003 : dateAttachmentStart - mCoordinates.sendersX; 1004 mSendersX = (isRtl) ? dateAttachmentStart : mCoordinates.sendersX; 1005 1006 // Second pass to layout each fragment. 1007 sPaint.setTextSize(mCoordinates.sendersFontSize); 1008 sPaint.setTypeface(Typeface.DEFAULT); 1009 1010 if (mHeader.styledNames != null) { 1011 final SpannableStringBuilder participantText = elideParticipants(mHeader.styledNames); 1012 layoutParticipantText(participantText); 1013 } else { 1014 // First pass to calculate width of each fragment. 1015 if (mSendersWidth < 0) { 1016 mSendersWidth = 0; 1017 } 1018 1019 mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint, 1020 mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true); 1021 } 1022 1023 if (mSendersWidth < 0) { 1024 mSendersWidth = 0; 1025 } 1026 1027 pauseTimer(PERF_TAG_CALCULATE_COORDINATES); 1028 } 1029 1030 // The rules for displaying elided participants are as follows: 1031 // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown 1032 // 2) If senders do not fit, ellipsize the last one that does fit, and stop 1033 // appending new senders 1034 SpannableStringBuilder elideParticipants(List<SpannableString> parts) { 1035 final SpannableStringBuilder builder = new SpannableStringBuilder(); 1036 float totalWidth = 0; 1037 boolean ellipsize = false; 1038 float width; 1039 boolean skipToHeader = false; 1040 1041 // start with "To: " if we're showing recipients 1042 if (mDisplayedFolder.shouldShowRecipients() && !parts.isEmpty()) { 1043 final SpannableString toHeader = SendersView.getFormattedToHeader(); 1044 CharacterStyle[] spans = toHeader.getSpans(0, toHeader.length(), 1045 CharacterStyle.class); 1046 // There is only 1 character style span; make sure we apply all the 1047 // styles to the paint object before measuring. 1048 if (spans.length > 0) { 1049 spans[0].updateDrawState(sPaint); 1050 } 1051 totalWidth += sPaint.measureText(toHeader.toString()); 1052 builder.append(toHeader); 1053 skipToHeader = true; 1054 } 1055 1056 final SpannableStringBuilder messageInfoString = mHeader.messageInfoString; 1057 if (messageInfoString.length() > 0) { 1058 CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(), 1059 CharacterStyle.class); 1060 // There is only 1 character style span; make sure we apply all the 1061 // styles to the paint object before measuring. 1062 if (spans.length > 0) { 1063 spans[0].updateDrawState(sPaint); 1064 } 1065 // Paint the message info string to see if we lose space. 1066 float messageInfoWidth = sPaint.measureText(messageInfoString.toString()); 1067 totalWidth += messageInfoWidth; 1068 } 1069 SpannableString prevSender = null; 1070 SpannableString ellipsizedText; 1071 for (SpannableString sender : parts) { 1072 // There may be null sender strings if there were dupes we had to remove. 1073 if (sender == null) { 1074 continue; 1075 } 1076 // No more width available, we'll only show fixed fragments. 1077 if (ellipsize) { 1078 break; 1079 } 1080 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 1081 // There is only 1 character style span. 1082 if (spans.length > 0) { 1083 spans[0].updateDrawState(sPaint); 1084 } 1085 // If there are already senders present in this string, we need to 1086 // make sure we prepend the dividing token 1087 if (SendersView.sElidedString.equals(sender.toString())) { 1088 prevSender = sender; 1089 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 1090 } else if (!skipToHeader && builder.length() > 0 1091 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 1092 .toString()))) { 1093 prevSender = sender; 1094 sender = copyStyles(spans, sSendersSplitToken + sender); 1095 } else { 1096 prevSender = sender; 1097 skipToHeader = false; 1098 } 1099 if (spans.length > 0) { 1100 spans[0].updateDrawState(sPaint); 1101 } 1102 // Measure the width of the current sender and make sure we have space 1103 width = (int) sPaint.measureText(sender.toString()); 1104 if (width + totalWidth > mSendersWidth) { 1105 // The text is too long, new line won't help. We have to 1106 // ellipsize text. 1107 ellipsize = true; 1108 width = mSendersWidth - totalWidth; // ellipsis width? 1109 ellipsizedText = copyStyles(spans, 1110 TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END)); 1111 width = (int) sPaint.measureText(ellipsizedText.toString()); 1112 } else { 1113 ellipsizedText = null; 1114 } 1115 totalWidth += width; 1116 1117 final CharSequence fragmentDisplayText; 1118 if (ellipsizedText != null) { 1119 fragmentDisplayText = ellipsizedText; 1120 } else { 1121 fragmentDisplayText = sender; 1122 } 1123 builder.append(fragmentDisplayText); 1124 } 1125 mHeader.styledMessageInfoStringOffset = builder.length(); 1126 builder.append(messageInfoString); 1127 return builder; 1128 } 1129 1130 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 1131 SpannableString s = new SpannableString(newText); 1132 if (spans != null && spans.length > 0) { 1133 s.setSpan(spans[0], 0, s.length(), 0); 1134 } 1135 return s; 1136 } 1137 1138 /** 1139 * If the subject contains the tag of a mailing-list (text surrounded with 1140 * []), return the subject with that tag ellipsized, e.g. 1141 * "[android-gmail-team] Hello" -> "[andr...] Hello" 1142 */ 1143 public static String filterTag(Context context, String subject) { 1144 String result = subject; 1145 String formatString = context.getResources().getString(R.string.filtered_tag); 1146 if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') { 1147 int end = subject.indexOf(']'); 1148 if (end > 0) { 1149 String tag = subject.substring(1, end); 1150 result = String.format(formatString, Utils.ellipsize(tag, 7), 1151 subject.substring(end + 1)); 1152 } 1153 } 1154 return result; 1155 } 1156 1157 @Override 1158 protected void onDraw(Canvas canvas) { 1159 if (mCoordinates == null) { 1160 LogUtils.e(LOG_TAG, "null coordinates in ConversationItemView#onDraw"); 1161 return; 1162 } 1163 1164 Utils.traceBeginSection("CIVC.draw"); 1165 1166 // Contact photo 1167 if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) { 1168 canvas.save(); 1169 Utils.traceBeginSection("draw senders image"); 1170 drawSendersImage(canvas); 1171 Utils.traceEndSection(); 1172 canvas.restore(); 1173 } 1174 1175 // Senders. 1176 boolean isUnread = mHeader.unread; 1177 // Old style senders; apply text colors/ sizes/ styling. 1178 canvas.save(); 1179 if (mHeader.sendersDisplayLayout != null) { 1180 sPaint.setTextSize(mCoordinates.sendersFontSize); 1181 sPaint.setTypeface(SendersView.getTypeface(isUnread)); 1182 sPaint.setColor(sSendersTextColor); 1183 canvas.translate(mSendersX, mCoordinates.sendersY 1184 + mHeader.sendersDisplayLayout.getTopPadding()); 1185 mHeader.sendersDisplayLayout.draw(canvas); 1186 } else { 1187 drawSenders(canvas); 1188 } 1189 canvas.restore(); 1190 1191 1192 // Subject. 1193 sPaint.setTypeface(Typeface.DEFAULT); 1194 canvas.save(); 1195 drawSubject(canvas); 1196 canvas.restore(); 1197 1198 canvas.save(); 1199 drawSnippet(canvas); 1200 canvas.restore(); 1201 1202 // Folders. 1203 if (mConfig.areFoldersVisible()) { 1204 mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, ViewUtils.isViewRtl(this)); 1205 } 1206 1207 // If this folder has a color (combined view/Email), show it here 1208 if (mConfig.isColorBlockVisible()) { 1209 sFoldersPaint.setColor(mHeader.conversation.color); 1210 sFoldersPaint.setStyle(Paint.Style.FILL); 1211 canvas.drawRect(mCoordinates.colorBlockX, mCoordinates.colorBlockY, 1212 mCoordinates.colorBlockX + mCoordinates.colorBlockWidth, 1213 mCoordinates.colorBlockY + mCoordinates.colorBlockHeight, sFoldersPaint); 1214 } 1215 1216 // Draw the reply state. Draw nothing if neither replied nor forwarded. 1217 if (mConfig.isReplyStateVisible()) { 1218 if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) { 1219 canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX, 1220 mCoordinates.replyStateY, null); 1221 } else if (mHeader.hasBeenRepliedTo) { 1222 canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX, 1223 mCoordinates.replyStateY, null); 1224 } else if (mHeader.hasBeenForwarded) { 1225 canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX, 1226 mCoordinates.replyStateY, null); 1227 } else if (mHeader.isInvite) { 1228 canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX, 1229 mCoordinates.replyStateY, null); 1230 } 1231 } 1232 1233 if (mConfig.isPersonalIndicatorVisible()) { 1234 canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalIndicatorX, 1235 mCoordinates.personalIndicatorY, null); 1236 } 1237 1238 // Info icon 1239 if (mHeader.infoIcon != null) { 1240 canvas.drawBitmap(mHeader.infoIcon, mInfoIconX, mCoordinates.infoIconY, sPaint); 1241 } 1242 1243 // Date. 1244 sPaint.setTextSize(mCoordinates.dateFontSize); 1245 sPaint.setTypeface(isUnread ? SANS_SERIF_BOLD : SANS_SERIF_LIGHT); 1246 sPaint.setColor(isUnread ? sDateTextColorUnread : sDateTextColorRead); 1247 drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateYBaseline, sPaint); 1248 1249 // Paper clip icon. 1250 if (mHeader.paperclip != null) { 1251 canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint); 1252 } 1253 1254 // Star. 1255 if (mStarEnabled) { 1256 canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint); 1257 } 1258 1259 // Divider. 1260 if (mDividerEnabled) { 1261 final int dividerBottomY = getHeight(); 1262 final int dividerTopY = dividerBottomY - sDividerHeight; 1263 canvas.drawRect(0, dividerTopY, getWidth(), dividerBottomY, sDividerPaint); 1264 } 1265 1266 // The focused bar 1267 if (isSelected() || isActivated()) { 1268 final int w = VISIBLE_CONVERSATION_HIGHLIGHT.getIntrinsicWidth(); 1269 final boolean isRtl = ViewUtils.isViewRtl(this); 1270 // This bar is on the right side of the conv list if it's RTL 1271 VISIBLE_CONVERSATION_HIGHLIGHT.setBounds( 1272 (isRtl) ? getWidth() - w : 0, 0, 1273 (isRtl) ? getWidth() : w, getHeight()); 1274 VISIBLE_CONVERSATION_HIGHLIGHT.draw(canvas); 1275 } 1276 1277 Utils.traceEndSection(); 1278 } 1279 1280 private void drawSendersImage(final Canvas canvas) { 1281 if (!mSendersImageView.isFlipping()) { 1282 final boolean showSenders = !mChecked; 1283 mSendersImageView.reset(showSenders); 1284 } 1285 canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY); 1286 if (mPhotoBitmap == null) { 1287 mSendersImageView.draw(canvas); 1288 } else { 1289 canvas.drawBitmap(mPhotoBitmap, null, mPhotoRect, sPaint); 1290 } 1291 } 1292 1293 private void drawSubject(Canvas canvas) { 1294 canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY); 1295 mSubjectTextView.draw(canvas); 1296 } 1297 1298 private void drawSnippet(Canvas canvas) { 1299 // if folders exist, their width will be the max width - actual width 1300 final int folderWidth = mCoordinates.maxSnippetWidth - mSnippetTextView.getWidth(); 1301 1302 // in RTL layouts we move the snippet to the right so it doesn't overlap the folders 1303 final int x = mCoordinates.snippetX + (ViewUtils.isViewRtl(this) ? folderWidth : 0); 1304 canvas.translate(x, mCoordinates.snippetY); 1305 mSnippetTextView.draw(canvas); 1306 } 1307 1308 private void drawSenders(Canvas canvas) { 1309 canvas.translate(mSendersX, mCoordinates.sendersY); 1310 mSendersTextView.draw(canvas); 1311 } 1312 1313 private Bitmap getStarBitmap() { 1314 return mHeader.conversation.starred ? STAR_ON : STAR_OFF; 1315 } 1316 1317 private static void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) { 1318 canvas.drawText(s, 0, s.length(), x, y, paint); 1319 } 1320 1321 /** 1322 * Set the background for this item based on: 1323 * 1. Read / Unread (unread messages have a lighter background) 1324 * 2. Tablet / Phone 1325 * 3. Checkbox checked / Unchecked (controls CAB color for item) 1326 * 4. Activated / Not activated (controls the blue highlight on tablet) 1327 */ 1328 private void updateBackground() { 1329 final int background; 1330 if (mBackgroundOverrideResId > 0) { 1331 background = mBackgroundOverrideResId; 1332 } else { 1333 background = R.drawable.conversation_item_background; 1334 } 1335 setBackgroundResource(background); 1336 } 1337 1338 @Override 1339 protected int[] onCreateDrawableState(int extraSpace) { 1340 final int[] curr = super.onCreateDrawableState(extraSpace + 1); 1341 if (mChecked) { 1342 mergeDrawableStates(curr, CHECKED_STATE); 1343 } 1344 return curr; 1345 } 1346 1347 private void setChecked(boolean checked) { 1348 mChecked = checked; 1349 refreshDrawableState(); 1350 } 1351 1352 @Override 1353 public boolean toggleCheckedState() { 1354 return toggleCheckedState(null); 1355 } 1356 1357 @Override 1358 public boolean toggleCheckedState(final String sourceOpt) { 1359 if (mHeader != null && mHeader.conversation != null && mCheckedConversationSet != null) { 1360 setChecked(!mChecked); 1361 final Conversation conv = mHeader.conversation; 1362 // Set the list position of this item in the conversation 1363 final SwipeableListView listView = getListView(); 1364 1365 try { 1366 conv.position = mChecked && listView != null ? listView.getPositionForView(this) 1367 : Conversation.NO_POSITION; 1368 } catch (final NullPointerException e) { 1369 // TODO(skennedy) Remove this if we find the root cause b/9527863 1370 } 1371 1372 if (mCheckedConversationSet.isEmpty()) { 1373 final String source = (sourceOpt != null) ? sourceOpt : "checkbox"; 1374 Analytics.getInstance().sendEvent("enter_cab_mode", source, null, 0); 1375 } 1376 1377 mCheckedConversationSet.toggle(conv); 1378 if (mCheckedConversationSet.isEmpty()) { 1379 listView.commitDestructiveActions(true); 1380 } 1381 1382 final boolean front = !mChecked; 1383 mSendersImageView.flipTo(front); 1384 1385 // We update the background after the checked state has changed 1386 // now that we have a selected background asset. Setting the background 1387 // usually waits for a layout pass, but we don't need a full layout, 1388 // just an update to the background. 1389 requestLayout(); 1390 1391 return true; 1392 } 1393 1394 return false; 1395 } 1396 1397 @Override 1398 public void onSetEmpty() { 1399 mSendersImageView.flipTo(true); 1400 } 1401 1402 @Override 1403 public void onSetPopulated(final ConversationCheckedSet set) { } 1404 1405 @Override 1406 public void onSetChanged(final ConversationCheckedSet set) { } 1407 1408 /** 1409 * Toggle the star on this view and update the conversation. 1410 */ 1411 public void toggleStar() { 1412 mHeader.conversation.starred = !mHeader.conversation.starred; 1413 Bitmap starBitmap = getStarBitmap(); 1414 postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX 1415 + starBitmap.getWidth(), 1416 mCoordinates.starY + starBitmap.getHeight()); 1417 ConversationCursor cursor = (ConversationCursor) mAdapter.getCursor(); 1418 if (cursor != null) { 1419 // TODO(skennedy) What about ads? 1420 cursor.updateBoolean(mHeader.conversation, ConversationColumns.STARRED, 1421 mHeader.conversation.starred); 1422 } 1423 } 1424 1425 private boolean isTouchInContactPhoto(float x, float y) { 1426 // Everything before the end edge of contact photo 1427 1428 final boolean isRtl = ViewUtils.isViewRtl(this); 1429 final int threshold = (isRtl) ? mCoordinates.contactImagesX - sSenderImageTouchSlop : 1430 mCoordinates.contactImagesX + mCoordinates.contactImagesWidth 1431 + sSenderImageTouchSlop; 1432 1433 // Allow touching a little right of the contact photo when we're already in selection mode 1434 final float extra; 1435 if (mCheckedConversationSet == null || mCheckedConversationSet.isEmpty()) { 1436 extra = 0; 1437 } else { 1438 extra = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16, 1439 getResources().getDisplayMetrics()); 1440 } 1441 1442 return mHeader.gadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO 1443 && ((isRtl) ? x > (threshold - extra) : x < (threshold + extra)); 1444 } 1445 1446 private boolean isTouchInInfoIcon(final float x, final float y) { 1447 if (mHeader.infoIcon == null) { 1448 // We have no info icon 1449 return false; 1450 } 1451 1452 final boolean isRtl = ViewUtils.isViewRtl(this); 1453 // Regardless of device, we always want to be end of the date's start touch slop 1454 if (((isRtl) ? x > mDateX + mDateWidth + sStarTouchSlop : x < mDateX - sStarTouchSlop)) { 1455 return false; 1456 } 1457 1458 if (mStarEnabled) { 1459 // We allow touches all the way to the right edge, so no x check is necessary 1460 1461 // We need to be above the star's touch area, which ends at the top of the subject 1462 // text 1463 return y < mCoordinates.subjectY; 1464 } 1465 1466 // With no star below the info icon, we allow touches anywhere from the top edge to the 1467 // bottom edge 1468 return true; 1469 } 1470 1471 private boolean isTouchInStar(float x, float y) { 1472 if (mHeader.infoIcon != null) { 1473 // We have an info icon, and it's above the star 1474 // We allow touches everywhere below the top of the subject text 1475 if (y < mCoordinates.subjectY) { 1476 return false; 1477 } 1478 } 1479 1480 // Everything after the star and include a touch slop. 1481 return mStarEnabled && isTouchInStarTargetX(ViewUtils.isViewRtl(this), x); 1482 } 1483 1484 private boolean isTouchInStarTargetX(boolean isRtl, float x) { 1485 return (isRtl) ? x < mCoordinates.starX + mCoordinates.starWidth + sStarTouchSlop 1486 : x >= mCoordinates.starX - sStarTouchSlop; 1487 } 1488 1489 @Override 1490 public boolean canChildBeDismissed() { 1491 return mSwipeEnabled; 1492 } 1493 1494 @Override 1495 public void dismiss() { 1496 SwipeableListView listView = getListView(); 1497 if (listView != null) { 1498 listView.dismissChild(this); 1499 } 1500 } 1501 1502 private boolean onTouchEventNoSwipe(MotionEvent event) { 1503 Utils.traceBeginSection("on touch event no swipe"); 1504 boolean handled = false; 1505 1506 int x = (int) event.getX(); 1507 int y = (int) event.getY(); 1508 switch (event.getAction()) { 1509 case MotionEvent.ACTION_DOWN: 1510 if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) { 1511 mDownEvent = true; 1512 handled = true; 1513 } 1514 break; 1515 1516 case MotionEvent.ACTION_CANCEL: 1517 mDownEvent = false; 1518 break; 1519 1520 case MotionEvent.ACTION_UP: 1521 if (mDownEvent) { 1522 if (isTouchInContactPhoto(x, y)) { 1523 // Touch on the check mark 1524 toggleCheckedState(); 1525 } else if (isTouchInInfoIcon(x, y)) { 1526 if (mConversationItemAreaClickListener != null) { 1527 mConversationItemAreaClickListener.onInfoIconClicked(); 1528 } 1529 } else if (isTouchInStar(x, y)) { 1530 // Touch on the star 1531 if (mConversationItemAreaClickListener == null) { 1532 toggleStar(); 1533 } else { 1534 mConversationItemAreaClickListener.onStarClicked(); 1535 } 1536 } 1537 handled = true; 1538 } 1539 break; 1540 } 1541 1542 if (!handled) { 1543 handled = super.onTouchEvent(event); 1544 } 1545 1546 Utils.traceEndSection(); 1547 return handled; 1548 } 1549 1550 /** 1551 * ConversationItemView is given the first chance to handle touch events. 1552 */ 1553 @Override 1554 public boolean onTouchEvent(MotionEvent event) { 1555 Utils.traceBeginSection("on touch event"); 1556 int x = (int) event.getX(); 1557 int y = (int) event.getY(); 1558 if (!mSwipeEnabled) { 1559 Utils.traceEndSection(); 1560 return onTouchEventNoSwipe(event); 1561 } 1562 switch (event.getAction()) { 1563 case MotionEvent.ACTION_DOWN: 1564 if (isTouchInContactPhoto(x, y) || isTouchInInfoIcon(x, y) || isTouchInStar(x, y)) { 1565 mDownEvent = true; 1566 Utils.traceEndSection(); 1567 return true; 1568 } 1569 break; 1570 case MotionEvent.ACTION_UP: 1571 if (mDownEvent) { 1572 if (isTouchInContactPhoto(x, y)) { 1573 // Touch on the check mark 1574 Utils.traceEndSection(); 1575 mDownEvent = false; 1576 toggleCheckedState(); 1577 Utils.traceEndSection(); 1578 return true; 1579 } else if (isTouchInInfoIcon(x, y)) { 1580 // Touch on the info icon 1581 mDownEvent = false; 1582 if (mConversationItemAreaClickListener != null) { 1583 mConversationItemAreaClickListener.onInfoIconClicked(); 1584 } 1585 Utils.traceEndSection(); 1586 return true; 1587 } else if (isTouchInStar(x, y)) { 1588 // Touch on the star 1589 mDownEvent = false; 1590 if (mConversationItemAreaClickListener == null) { 1591 toggleStar(); 1592 } else { 1593 mConversationItemAreaClickListener.onStarClicked(); 1594 } 1595 Utils.traceEndSection(); 1596 return true; 1597 } 1598 } 1599 break; 1600 } 1601 // Let View try to handle it as well. 1602 boolean handled = super.onTouchEvent(event); 1603 if (event.getAction() == MotionEvent.ACTION_DOWN) { 1604 Utils.traceEndSection(); 1605 return true; 1606 } 1607 Utils.traceEndSection(); 1608 return handled; 1609 } 1610 1611 @Override 1612 public boolean performClick() { 1613 final boolean handled = super.performClick(); 1614 final SwipeableListView list = getListView(); 1615 if (!handled && list != null && list.getAdapter() != null) { 1616 final int pos = list.findConversation(this, mHeader.conversation); 1617 list.performItemClick(this, pos, mHeader.conversation.id); 1618 } 1619 return handled; 1620 } 1621 1622 private View unwrap() { 1623 final ViewParent vp = getParent(); 1624 if (vp == null || !(vp instanceof View)) { 1625 return null; 1626 } 1627 return (View) vp; 1628 } 1629 1630 private SwipeableListView getListView() { 1631 SwipeableListView v = null; 1632 final View wrapper = unwrap(); 1633 if (wrapper != null && wrapper instanceof SwipeableConversationItemView) { 1634 v = (SwipeableListView) ((SwipeableConversationItemView) wrapper).getListView(); 1635 } 1636 if (v == null) { 1637 v = mAdapter.getListView(); 1638 } 1639 return v; 1640 } 1641 1642 /** 1643 * Reset any state associated with this conversation item view so that it 1644 * can be reused. 1645 */ 1646 public void reset() { 1647 Utils.traceBeginSection("reset"); 1648 setAlpha(1f); 1649 setTranslationX(0f); 1650 mAnimatedHeightFraction = 1.0f; 1651 Utils.traceEndSection(); 1652 } 1653 1654 @SuppressWarnings("deprecation") 1655 @Override 1656 public void setTranslationX(float translationX) { 1657 super.setTranslationX(translationX); 1658 1659 // When a list item is being swiped or animated, ensure that the hosting view has a 1660 // background color set. We only enable the background during the X-translation effect to 1661 // reduce overdraw during normal list scrolling. 1662 final View parent = (View) getParent(); 1663 if (parent == null) { 1664 LogUtils.w(LOG_TAG, "CIV.setTranslationX null ConversationItemView parent x=%s", 1665 translationX); 1666 } 1667 1668 if (parent instanceof SwipeableConversationItemView) { 1669 if (translationX != 0f) { 1670 parent.setBackgroundResource(R.color.swiped_bg_color); 1671 } else { 1672 parent.setBackgroundDrawable(null); 1673 } 1674 } 1675 } 1676 1677 /** 1678 * Grow the height of the item and fade it in when bringing a conversation 1679 * back from a destructive action. 1680 */ 1681 public Animator createSwipeUndoAnimation() { 1682 ObjectAnimator undoAnimator = createTranslateXAnimation(true); 1683 return undoAnimator; 1684 } 1685 1686 /** 1687 * Grow the height of the item and fade it in when bringing a conversation 1688 * back from a destructive action. 1689 */ 1690 public Animator createUndoAnimation() { 1691 ObjectAnimator height = createHeightAnimation(true); 1692 Animator fade = ObjectAnimator.ofFloat(this, "alpha", 0, 1.0f); 1693 fade.setDuration(sShrinkAnimationDuration); 1694 fade.setInterpolator(new DecelerateInterpolator(2.0f)); 1695 AnimatorSet transitionSet = new AnimatorSet(); 1696 transitionSet.playTogether(height, fade); 1697 transitionSet.addListener(new HardwareLayerEnabler(this)); 1698 return transitionSet; 1699 } 1700 1701 /** 1702 * Grow the height of the item and fade it in when bringing a conversation 1703 * back from a destructive action. 1704 */ 1705 public Animator createDestroyWithSwipeAnimation() { 1706 ObjectAnimator slide = createTranslateXAnimation(false); 1707 ObjectAnimator height = createHeightAnimation(false); 1708 AnimatorSet transitionSet = new AnimatorSet(); 1709 transitionSet.playSequentially(slide, height); 1710 return transitionSet; 1711 } 1712 1713 private ObjectAnimator createTranslateXAnimation(boolean show) { 1714 SwipeableListView parent = getListView(); 1715 // If we can't get the parent...we have bigger problems. 1716 int width = parent != null ? parent.getMeasuredWidth() : 0; 1717 final float start = show ? width : 0f; 1718 final float end = show ? 0f : width; 1719 ObjectAnimator slide = ObjectAnimator.ofFloat(this, "translationX", start, end); 1720 slide.setInterpolator(new DecelerateInterpolator(2.0f)); 1721 slide.setDuration(sSlideAnimationDuration); 1722 return slide; 1723 } 1724 1725 public Animator createDestroyAnimation() { 1726 return createHeightAnimation(false); 1727 } 1728 1729 private ObjectAnimator createHeightAnimation(boolean show) { 1730 final float start = show ? 0f : 1.0f; 1731 final float end = show ? 1.0f : 0f; 1732 ObjectAnimator height = ObjectAnimator.ofFloat(this, "animatedHeightFraction", start, end); 1733 height.setInterpolator(new DecelerateInterpolator(2.0f)); 1734 height.setDuration(sShrinkAnimationDuration); 1735 return height; 1736 } 1737 1738 // Used by animator 1739 public void setAnimatedHeightFraction(float height) { 1740 mAnimatedHeightFraction = height; 1741 requestLayout(); 1742 } 1743 1744 @Override 1745 public SwipeableView getSwipeableView() { 1746 return SwipeableView.from(this); 1747 } 1748 1749 @Override 1750 public float getMinAllowScrollDistance() { 1751 return sScrollSlop; 1752 } 1753 1754 public String getAccountEmailAddress() { 1755 return mAccount.getEmailAddress(); 1756 } 1757} 1758