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