ConversationItemView.java revision ac9687c359d06ffd26a270906325dacef531ef53
1/*
2 * Copyright (C) 2012 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.browse;
19
20import android.animation.Animator;
21import android.animation.Animator.AnimatorListener;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.content.ClipData;
25import android.content.ClipData.Item;
26import android.content.Context;
27import android.content.res.Resources;
28import android.database.Cursor;
29import android.graphics.Bitmap;
30import android.graphics.BitmapFactory;
31import android.graphics.Canvas;
32import android.graphics.Color;
33import android.graphics.LinearGradient;
34import android.graphics.Paint;
35import android.graphics.Point;
36import android.graphics.Rect;
37import android.graphics.Shader;
38import android.graphics.Typeface;
39import android.graphics.drawable.Drawable;
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.CharacterStyle;
50import android.text.style.ForegroundColorSpan;
51import android.text.style.StyleSpan;
52import android.util.SparseArray;
53import android.view.HapticFeedbackConstants;
54import android.view.MotionEvent;
55import android.view.View;
56import android.view.ViewConfiguration;
57import android.view.animation.DecelerateInterpolator;
58import android.widget.ListView;
59
60import com.android.mail.R;
61import com.android.mail.browse.ConversationItemViewModel.SenderFragment;
62import com.android.mail.perf.Timer;
63import com.android.mail.providers.Conversation;
64import com.android.mail.providers.Folder;
65import com.android.mail.providers.UIProvider;
66import com.android.mail.providers.UIProvider.ConversationColumns;
67import com.android.mail.ui.AnimatedAdapter;
68import com.android.mail.ui.ConversationSelectionSet;
69import com.android.mail.ui.FolderDisplayer;
70import com.android.mail.ui.SwipeableItemView;
71import com.android.mail.ui.ViewMode;
72import com.android.mail.utils.LogTag;
73import com.android.mail.utils.LogUtils;
74import com.android.mail.utils.Utils;
75import com.google.common.annotations.VisibleForTesting;
76
77public class ConversationItemView extends View implements SwipeableItemView {
78    // Timer.
79    private static int sLayoutCount = 0;
80    private static Timer sTimer; // Create the sTimer here if you need to do
81                                 // perf analysis.
82    private static final int PERF_LAYOUT_ITERATIONS = 50;
83    private static final String PERF_TAG_LAYOUT = "CCHV.layout";
84    private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps";
85    private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj";
86    private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders";
87    private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates";
88    private static final String LOG_TAG = LogTag.getLogTag();
89
90    // Static bitmaps.
91    private static Bitmap CHECKMARK_OFF;
92    private static Bitmap CHECKMARK_ON;
93    private static Bitmap STAR_OFF;
94    private static Bitmap STAR_ON;
95    private static Bitmap ATTACHMENT;
96    private static Bitmap ONLY_TO_ME;
97    private static Bitmap TO_ME_AND_OTHERS;
98    private static Bitmap IMPORTANT_ONLY_TO_ME;
99    private static Bitmap IMPORTANT_TO_ME_AND_OTHERS;
100    private static Bitmap IMPORTANT_TO_OTHERS;
101    private static Bitmap DATE_BACKGROUND;
102    private static Bitmap STATE_REPLIED;
103    private static Bitmap STATE_FORWARDED;
104    private static Bitmap STATE_REPLIED_AND_FORWARDED;
105    private static Bitmap STATE_CALENDAR_INVITE;
106
107    private static String sSendersSplitToken;
108    private static String sElidedPaddingToken;
109    private static String sEllipsis;
110
111    // Static colors.
112    private static int sDefaultTextColor;
113    private static int sActivatedTextColor;
114    private static int sSubjectTextColorRead;
115    private static int sSubjectTextColorUnead;
116    private static int sSnippetTextColorRead;
117    private static int sSnippetTextColorUnread;
118    private static int sSendersTextColorRead;
119    private static int sSendersTextColorUnread;
120    private static int sDateTextColor;
121    private static int sDateBackgroundPaddingLeft;
122    private static int sTouchSlop;
123    private static int sDateBackgroundHeight;
124    private static int sStandardScaledDimen;
125    private static int sUndoAnimationDuration;
126
127    // Static paints.
128    private static TextPaint sPaint = new TextPaint();
129    private static TextPaint sFoldersPaint = new TextPaint();
130
131    // Backgrounds for different states.
132    private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>();
133
134    // Dimensions and coordinates.
135    private int mViewWidth = -1;
136    private int mMode = -1;
137    private int mDateX;
138    private int mPaperclipX;
139    private int mFoldersXEnd;
140    private int mSendersWidth;
141
142    /** Whether we're running under test mode. */
143    private boolean mTesting = false;
144    /** Whether we are on a tablet device or not */
145    private final boolean mTabletDevice;
146
147    @VisibleForTesting
148    ConversationItemViewCoordinates mCoordinates;
149
150    private final Context mContext;
151
152    public ConversationItemViewModel mHeader;
153    private ViewMode mViewMode;
154    private boolean mDownEvent;
155    private boolean mChecked = false;
156    private static int sFadedActivatedColor = -1;
157    private ConversationSelectionSet mSelectedConversationSet;
158    private Folder mDisplayedFolder;
159    private boolean mPriorityMarkersEnabled;
160    private boolean mCheckboxesEnabled;
161    private CheckForTap mPendingCheckForTap;
162    private CheckForLongPress mPendingCheckForLongPress;
163    private boolean mSwipeEnabled;
164    private int mLastTouchX;
165    private int mLastTouchY;
166    private AnimatedAdapter mAdapter;
167    private int mAnimatedHeight = -1;
168    private String mAccount;
169    private static Bitmap sDateBackgroundAttachment;
170    private static Bitmap sDateBackgroundNoAttachment;
171    private static int sUndoAnimationOffset;
172    private static Bitmap MORE_FOLDERS;
173
174    static {
175        sPaint.setAntiAlias(true);
176        sFoldersPaint.setAntiAlias(true);
177    }
178
179    /**
180     * Handles displaying folders in a conversation header view.
181     */
182    static class ConversationItemFolderDisplayer extends FolderDisplayer {
183        // Maximum number of folders to be displayed.
184        private static final int MAX_DISPLAYED_FOLDERS_COUNT = 4;
185
186        private int mFoldersCount;
187        private boolean mHasMoreFolders;
188
189        public ConversationItemFolderDisplayer(Context context) {
190            super(context);
191        }
192
193        @Override
194        public void loadConversationFolders(Conversation conv, Folder ignoreFolder) {
195            super.loadConversationFolders(conv, ignoreFolder);
196
197            mFoldersCount = mFoldersSortedSet.size();
198            mHasMoreFolders = mFoldersCount > MAX_DISPLAYED_FOLDERS_COUNT;
199            mFoldersCount = Math.min(mFoldersCount, MAX_DISPLAYED_FOLDERS_COUNT);
200        }
201
202        public boolean hasVisibleFolders() {
203            return mFoldersCount > 0;
204        }
205
206        private int measureFolders(int mode) {
207            int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode);
208            int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode,
209                    mFoldersCount);
210
211            int totalWidth = 0;
212            for (Folder f : mFoldersSortedSet) {
213                final String folderString = f.name;
214                int width = (int) sFoldersPaint.measureText(folderString) + cellSize;
215                if (width % cellSize != 0) {
216                    width += cellSize - (width % cellSize);
217                }
218                totalWidth += width;
219                if (totalWidth > availableSpace) {
220                    break;
221                }
222            }
223
224            return totalWidth;
225        }
226
227        public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates,
228                int foldersXEnd, int mode) {
229            if (mFoldersCount == 0) {
230                return;
231            }
232
233            int xEnd = foldersXEnd;
234            int y = coordinates.foldersY - coordinates.foldersAscent;
235            int height = coordinates.foldersHeight;
236            int topPadding = coordinates.foldersTopPadding;
237            int ascent = coordinates.foldersAscent;
238            sFoldersPaint.setTextSize(coordinates.foldersFontSize);
239
240            // Initialize space and cell size based on the current mode.
241            int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode);
242            int averageWidth = availableSpace / mFoldersCount;
243            int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode,
244                    mFoldersCount);
245
246            // First pass to calculate the starting point.
247            int totalWidth = measureFolders(mode);
248            int xStart = xEnd - Math.min(availableSpace, totalWidth);
249
250            // Second pass to draw folders.
251            for (Folder f : mFoldersSortedSet) {
252                final String folderString = f.name;
253                final int fgColor = f.getForegroundColor(mDefaultFgColor);
254                final int bgColor = f.getBackgroundColor(mDefaultBgColor);
255                int width = cellSize;
256                boolean labelTooLong = false;
257                width = (int) sFoldersPaint.measureText(folderString) + cellSize;
258                if (width % cellSize != 0) {
259                    width += cellSize - (width % cellSize);
260                }
261                if (totalWidth > availableSpace && width > averageWidth) {
262                    width = averageWidth;
263                    labelTooLong = true;
264                }
265
266                // TODO (mindyp): how to we get this?
267                final boolean isMuted = false;
268                // labelValues.folderId ==
269                // sGmail.getFolderMap(mAccount).getFolderIdIgnored();
270
271                // Draw the box.
272                sFoldersPaint.setColor(bgColor);
273                sFoldersPaint.setStyle(isMuted ? Paint.Style.STROKE : Paint.Style.FILL_AND_STROKE);
274                canvas.drawRect(xStart, y + ascent, xStart + width, y + ascent + height,
275                        sFoldersPaint);
276
277                // Draw the text.
278                int padding = getPadding(width, (int) sFoldersPaint.measureText(folderString));
279                if (labelTooLong) {
280                    TextPaint shortPaint = new TextPaint();
281                    shortPaint.setColor(fgColor);
282                    shortPaint.setTextSize(coordinates.foldersFontSize);
283                    padding = cellSize / 2;
284                    int rightBorder = xStart + width - padding;
285                    Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, y,
286                            fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP);
287                    shortPaint.setShader(shader);
288                    canvas.drawText(folderString, xStart + padding, y + topPadding, shortPaint);
289                } else {
290                    sFoldersPaint.setColor(fgColor);
291                    canvas.drawText(folderString, xStart + padding, y + topPadding, sFoldersPaint);
292                }
293
294                availableSpace -= width;
295                xStart += width;
296                if (availableSpace <= 0 && mHasMoreFolders) {
297                    canvas.drawBitmap(MORE_FOLDERS, xEnd, y + ascent, sFoldersPaint);
298                    return;
299                }
300            }
301        }
302    }
303
304    /**
305     * Helpers function to align an element in the center of a space.
306     */
307    private static int getPadding(int space, int length) {
308        return (space - length) / 2;
309    }
310
311    public ConversationItemView(Context context, String account) {
312        super(context);
313        mContext = context.getApplicationContext();
314        mTabletDevice = Utils.useTabletUI(mContext);
315        mAccount = account;
316        Resources res = mContext.getResources();
317
318        if (CHECKMARK_OFF == null) {
319            // Initialize static bitmaps.
320            CHECKMARK_OFF = BitmapFactory.decodeResource(res,
321                    R.drawable.btn_check_off_normal_holo_light);
322            CHECKMARK_ON = BitmapFactory.decodeResource(res,
323                    R.drawable.btn_check_on_normal_holo_light);
324            STAR_OFF = BitmapFactory.decodeResource(res,
325                    R.drawable.btn_star_off_normal_email_holo_light);
326            STAR_ON = BitmapFactory.decodeResource(res,
327                    R.drawable.btn_star_on_normal_email_holo_light);
328            ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double);
329            TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single);
330            IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res,
331                    R.drawable.ic_email_caret_double_important_unread);
332            IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res,
333                    R.drawable.ic_email_caret_single_important_unread);
334            IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res,
335                    R.drawable.ic_email_caret_none_important_unread);
336            ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light);
337            MORE_FOLDERS = BitmapFactory.decodeResource(res, R.drawable.ic_folders_more);
338            DATE_BACKGROUND = BitmapFactory.decodeResource(res, R.drawable.folder_bg_holo_light);
339            STATE_REPLIED =
340                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light);
341            STATE_FORWARDED =
342                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light);
343            STATE_REPLIED_AND_FORWARDED =
344                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light);
345            STATE_CALENDAR_INVITE =
346                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light);
347
348            // Initialize colors.
349            sDefaultTextColor = res.getColor(R.color.default_text_color);
350            sActivatedTextColor = res.getColor(android.R.color.white);
351            sSubjectTextColorRead = res.getColor(R.color.subject_text_color_read);
352            sSubjectTextColorUnead = res.getColor(R.color.subject_text_color_unread);
353            sSnippetTextColorRead = res.getColor(R.color.snippet_text_color_read);
354            sSnippetTextColorUnread = res.getColor(R.color.snippet_text_color_unread);
355            sSendersTextColorRead = res.getColor(R.color.senders_text_color_read);
356            sSendersTextColorUnread = res.getColor(R.color.senders_text_color_unread);
357            sDateTextColor = res.getColor(R.color.date_text_color);
358            sDateBackgroundPaddingLeft = res
359                    .getDimensionPixelSize(R.dimen.date_background_padding_left);
360            sTouchSlop = res.getDimensionPixelSize(R.dimen.touch_slop);
361            sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height);
362            sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen);
363            sUndoAnimationDuration = res.getInteger(R.integer.undo_animation_duration);
364            sUndoAnimationOffset = res.getDimensionPixelOffset(R.dimen.undo_animation_offset);
365            // Initialize static color.
366            sSendersSplitToken = res.getString(R.string.senders_split_token);
367            sElidedPaddingToken = res.getString(R.string.elided_padding_token);
368            sEllipsis = res.getString(R.string.ellipsis);
369        }
370    }
371
372    public void bind(Cursor cursor, ViewMode viewMode, ConversationSelectionSet set, Folder folder,
373            boolean checkboxesDisabled, boolean swipeEnabled, boolean priorityArrowEnabled,
374            AnimatedAdapter adapter) {
375        bind(ConversationItemViewModel.forCursor(mAccount, cursor), viewMode, set, folder,
376                checkboxesDisabled, swipeEnabled, priorityArrowEnabled, adapter);
377    }
378
379    public void bind(Conversation conversation, ViewMode viewMode, ConversationSelectionSet set,
380            Folder folder, boolean checkboxesDisabled, boolean swipeEnabled,
381            boolean priorityArrowEnabled, AnimatedAdapter adapter) {
382        bind(ConversationItemViewModel.forConversation(mAccount, conversation), viewMode, set,
383                folder, checkboxesDisabled, swipeEnabled, priorityArrowEnabled, adapter);
384    }
385
386    private void bind(ConversationItemViewModel header, ViewMode viewMode,
387            ConversationSelectionSet set, Folder folder, boolean checkboxesDisabled,
388            boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) {
389        mViewMode = viewMode;
390        mHeader = header;
391        mSelectedConversationSet = set;
392        mDisplayedFolder = folder;
393        mCheckboxesEnabled = !checkboxesDisabled;
394        mSwipeEnabled = swipeEnabled;
395        mPriorityMarkersEnabled = priorityArrowEnabled;
396        mAdapter = adapter;
397        setContentDescription(mHeader.getContentDescription(mContext));
398        requestLayout();
399    }
400
401    /**
402     * Get the Conversation object associated with this view.
403     */
404    public Conversation getConversation() {
405        return mHeader.conversation;
406    }
407
408    /**
409     * Sets the mode. Only used for testing.
410     */
411    @VisibleForTesting
412    void setMode(int mode) {
413        mMode = mode;
414        mTesting = true;
415    }
416
417    private static void startTimer(String tag) {
418        if (sTimer != null) {
419            sTimer.start(tag);
420        }
421    }
422
423    private static void pauseTimer(String tag) {
424        if (sTimer != null) {
425            sTimer.pause(tag);
426        }
427    }
428
429    @Override
430    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
431        startTimer(PERF_TAG_LAYOUT);
432
433        super.onLayout(changed, left, top, right, bottom);
434
435        int width = right - left;
436        if (width != mViewWidth) {
437            mViewWidth = width;
438            if (!mTesting) {
439                mMode = ConversationItemViewCoordinates.getMode(mContext, mViewMode);
440            }
441        }
442        mHeader.viewWidth = mViewWidth;
443        Resources res = getResources();
444        mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen);
445        if (mHeader.standardScaledDimen != sStandardScaledDimen) {
446            // Large Text has been toggle on/off. Update the static dimens.
447            sStandardScaledDimen = mHeader.standardScaledDimen;
448            ConversationItemViewCoordinates.refreshConversationHeights(mContext);
449            sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height);
450        }
451        mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode,
452                mHeader.standardScaledDimen);
453        calculateTextsAndBitmaps();
454        calculateCoordinates();
455        mHeader.validate(mContext);
456
457        pauseTimer(PERF_TAG_LAYOUT);
458        if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) {
459            sTimer.dumpResults();
460            sTimer = new Timer();
461            sLayoutCount = 0;
462        }
463    }
464
465    @Override
466    public void setBackgroundResource(int resourceId) {
467        Drawable drawable = mBackgrounds.get(resourceId);
468        if (drawable == null) {
469            drawable = getResources().getDrawable(resourceId);
470            mBackgrounds.put(resourceId, drawable);
471        }
472        if (getBackground() != drawable) {
473            super.setBackgroundDrawable(drawable);
474        }
475    }
476
477    private void calculateTextsAndBitmaps() {
478        startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
479        if (mSelectedConversationSet != null) {
480            mChecked = mSelectedConversationSet.contains(mHeader.conversation);
481        }
482        // Update font color.
483        int fontColor = getFontColor(sDefaultTextColor);
484        boolean fontChanged = false;
485        if (mHeader.fontColor != fontColor) {
486            fontChanged = true;
487            mHeader.fontColor = fontColor;
488        }
489
490        boolean isUnread = mHeader.unread;
491
492        final boolean checkboxEnabled = mCheckboxesEnabled;
493        if (mHeader.checkboxVisible != checkboxEnabled) {
494            mHeader.checkboxVisible = checkboxEnabled;
495        }
496
497        // Update background.
498        updateBackground(isUnread);
499
500        if (mHeader.isLayoutValid(mContext)) {
501            // Relayout subject if font color has changed.
502            if (fontChanged) {
503                layoutSubjectSpans(isUnread);
504                layoutSubject();
505            }
506            pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
507            return;
508        }
509
510        startTimer(PERF_TAG_CALCULATE_FOLDERS);
511
512        // Initialize folder displayer.
513        if (mCoordinates.showFolders) {
514            mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext);
515            mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation, mDisplayedFolder);
516        }
517
518        pauseTimer(PERF_TAG_CALCULATE_FOLDERS);
519
520        mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext,
521                mHeader.conversation.dateMs).toString();
522
523        // Paper clip icon.
524        mHeader.paperclip = null;
525        if (mHeader.conversation.hasAttachments) {
526            mHeader.paperclip = ATTACHMENT;
527        }
528        // Personal level.
529        mHeader.personalLevelBitmap = null;
530        if (mCoordinates.showPersonalLevel) {
531            final int personalLevel = mHeader.personalLevel;
532            final boolean isImportant =
533                    mHeader.priority == UIProvider.ConversationPriority.IMPORTANT;
534            final boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled;
535
536            if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) {
537                mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME
538                        : ONLY_TO_ME;
539            } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) {
540                mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS
541                        : TO_ME_AND_OTHERS;
542            } else if (useImportantMarkers) {
543                mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS;
544            }
545        }
546
547        startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
548
549        // Subject.
550        layoutSubjectSpans(isUnread);
551
552        mHeader.sendersDisplayText = new SpannableStringBuilder();
553        mHeader.styledSendersString = new SpannableStringBuilder();
554
555        // Parse senders fragments.
556        if (mHeader.conversation.conversationInfo != null) {
557            Context context = getContext();
558            mHeader.messageInfoString = SendersView
559                    .createMessageInfo(context, mHeader.conversation);
560            int maxChars = ConversationItemViewCoordinates.getSubjectLength(context,
561                    ConversationItemViewCoordinates.getMode(context, mViewMode),
562                    mHeader.folderDisplayer != null && mHeader.folderDisplayer.mFoldersCount > 0,
563                    mHeader.conversation.hasAttachments);
564            mHeader.styledSenders = SendersView.format(context,
565                    mHeader.conversation.conversationInfo, mHeader.messageInfoString.toString(),
566                    maxChars);
567        } else {
568            mCoordinates.sendersView.formatSenders(mHeader, isUnread, mMode, getContext());
569        }
570
571        pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
572        pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
573    }
574
575    private void layoutSubjectSpans(boolean isUnread) {
576        if (showActivatedText()) {
577            mHeader.subjectTextActivated = createSubject(isUnread, true);
578        }
579        mHeader.subjectText = createSubject(isUnread, false);
580    }
581
582    private SpannableStringBuilder createSubject(boolean isUnread, boolean activated) {
583        final String subject = filterTag(mHeader.conversation.subject);
584        final String snippet = mHeader.conversation.getSnippet();
585        int subjectColor = activated ? sActivatedTextColor : isUnread ? sSubjectTextColorUnead
586                : sSubjectTextColorRead;
587        int snippetColor = activated ? sActivatedTextColor : isUnread ? sSnippetTextColorUnread
588                : sSnippetTextColorRead;
589        SpannableStringBuilder subjectText = new SpannableStringBuilder(
590                (snippet != null) ? mContext.getString(R.string.subject_and_snippet, subject,
591                        snippet) : subject);
592        if (isUnread) {
593            subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(),
594                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
595        }
596        subjectText.setSpan(new ForegroundColorSpan(subjectColor), 0, subject.length(),
597                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
598        if (snippet != null) {
599            subjectText.setSpan(new ForegroundColorSpan(snippetColor), subject.length() + 1,
600                    subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
601        }
602        return subjectText;
603    }
604
605    private int getFontColor(int defaultColor) {
606        return isActivated() && mTabletDevice ? sActivatedTextColor
607                : defaultColor;
608    }
609
610    private boolean showActivatedText() {
611        return mTabletDevice;
612    }
613
614    private void layoutSubject() {
615        if (showActivatedText()) {
616            mHeader.subjectLayoutActivated =
617                    createSubjectLayout(true, mHeader.subjectTextActivated);
618        }
619        mHeader.subjectLayout = createSubjectLayout(false, mHeader.subjectText);
620    }
621
622    private StaticLayout createSubjectLayout(boolean activated,
623            SpannableStringBuilder subjectText) {
624        sPaint.setTextSize(mCoordinates.subjectFontSize);
625        sPaint.setColor(activated ? sActivatedTextColor
626                : mHeader.unread ? sSubjectTextColorUnead : sSubjectTextColorRead);
627        StaticLayout subjectLayout = new StaticLayout(subjectText, sPaint,
628                mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
629        int lineCount = subjectLayout.getLineCount();
630        if (mCoordinates.subjectLineCount < lineCount) {
631            int end = subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
632            subjectLayout = new StaticLayout(subjectText.subSequence(0, end), sPaint,
633                    mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
634        }
635        return subjectLayout;
636    }
637
638    private boolean canFitFragment(int width, int line, int fixedWidth) {
639        if (line == mCoordinates.sendersLineCount) {
640            return width + fixedWidth <= mSendersWidth;
641        } else {
642            return width <= mSendersWidth;
643        }
644    }
645
646    private void calculateCoordinates() {
647        startTimer(PERF_TAG_CALCULATE_COORDINATES);
648
649        sPaint.setTextSize(mCoordinates.dateFontSize);
650        sPaint.setTypeface(Typeface.DEFAULT);
651        mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText);
652
653        mPaperclipX = mDateX - ATTACHMENT.getWidth();
654
655        int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width);
656
657        if (ConversationItemViewCoordinates.isWideMode(mMode)) {
658            // Folders are displayed above the date.
659            mFoldersXEnd = mCoordinates.dateXEnd;
660            // In wide mode, the end of the senders should align with
661            // the start of the subject and is based on a max width.
662            mSendersWidth = mCoordinates.sendersWidth;
663        } else {
664            // In normal mode, the width is based on where the folders or date
665            // (or attachment icon) start.
666            if (mCoordinates.showFolders) {
667                if (mHeader.paperclip != null) {
668                    mFoldersXEnd = mPaperclipX;
669                } else {
670                    mFoldersXEnd = mDateX - cellWidth / 2;
671                }
672                mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth;
673                if (mHeader.folderDisplayer.hasVisibleFolders()) {
674                    mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext,
675                            mMode);
676                }
677            } else {
678                int dateAttachmentStart = 0;
679                // Have this end near the paperclip or date, not the folders.
680                if (mHeader.paperclip != null) {
681                    dateAttachmentStart = mPaperclipX;
682                } else {
683                    dateAttachmentStart = mDateX;
684                }
685                mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth;
686            }
687        }
688
689        if (mHeader.isLayoutValid(mContext)) {
690            pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
691            return;
692        }
693
694        // Layout subject.
695        layoutSubject();
696
697        // Second pass to layout each fragment.
698        int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent;
699
700        if (mHeader.styledSenders != null) {
701            ellipsizeStyledSenders();
702            mHeader.sendersDisplayLayout = new StaticLayout(mHeader.styledSendersString, sPaint,
703                    mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
704        } else {
705            // First pass to calculate width of each fragment.
706            int totalWidth = 0;
707            int fixedWidth = 0;
708            sPaint.setTextSize(mCoordinates.sendersFontSize);
709            sPaint.setTypeface(Typeface.DEFAULT);
710            for (SenderFragment senderFragment : mHeader.senderFragments) {
711                CharacterStyle style = senderFragment.style;
712                int start = senderFragment.start;
713                int end = senderFragment.end;
714                style.updateDrawState(sPaint);
715                senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end);
716                boolean isFixed = senderFragment.isFixed;
717                if (isFixed) {
718                    fixedWidth += senderFragment.width;
719                }
720                totalWidth += senderFragment.width;
721            }
722
723            if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) {
724                sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0;
725            }
726            totalWidth = ellipsize(fixedWidth, sendersY);
727            mHeader.sendersDisplayLayout = new StaticLayout(mHeader.sendersDisplayText, sPaint,
728                    mSendersWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
729        }
730
731        sPaint.setTextSize(mCoordinates.sendersFontSize);
732        sPaint.setTypeface(Typeface.DEFAULT);
733        if (mSendersWidth < 0) {
734            mSendersWidth = 0;
735        }
736
737        pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
738    }
739
740    // The rules for displaying ellipsized senders are as follows:
741    // 1) If there is message info (either a COUNT or DRAFT info to display), it MUST be shown
742    // 2) If senders do not fit, ellipsize the last one that does fit, and stop
743    // appending new senders
744    private int ellipsizeStyledSenders() {
745        SpannableStringBuilder builder = new SpannableStringBuilder();
746        float totalWidth = 0;
747        boolean ellipsize = false;
748        SpannableString ellipsizedText;
749        float width;
750        SpannableStringBuilder messageInfoString = mHeader.messageInfoString;
751        if (messageInfoString.length() > 0) {
752            CharacterStyle[] spans = messageInfoString.getSpans(0, messageInfoString.length(),
753                    CharacterStyle.class);
754            // There is only 1 character style span; make sure we apply all the
755            // styles to the paint object before measuring.
756            if (spans.length > 0) {
757                spans[0].updateDrawState(sPaint);
758            }
759            // Paint the message info string to see if we lose space.
760            float messageInfoWidth = sPaint.measureText(messageInfoString.toString());
761            totalWidth += messageInfoWidth;
762        }
763        SpannableString prevSender = null;
764        for (SpannableString sender : mHeader.styledSenders) {
765            // No more width available, we'll only show fixed fragments.
766            if (ellipsize) {
767                break;
768            }
769            CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
770            // There is only 1 character style span.
771            if (spans.length > 0) {
772                spans[0].updateDrawState(sPaint);
773            }
774            // If there are already senders present in this string, we need to
775            // make sure we prepend the dividing token
776            if (SendersView.sElidedString.equals(sender.toString())) {
777                prevSender = sender;
778                sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
779            } else if (builder.length() > 0
780                    && (prevSender == null || !SendersView.sElidedString.equals(prevSender
781                            .toString()))) {
782                prevSender = sender;
783                sender = copyStyles(spans, sSendersSplitToken + sender);
784            } else {
785                prevSender = sender;
786            }
787            // Measure the width of the current sender and make sure we have space
788            width = (int) sPaint.measureText(sender.toString());
789            if (width + totalWidth > mSendersWidth) {
790                // The text is too long, new line won't help. We have to
791                // ellipsize text.
792                ellipsize = true;
793                width = mSendersWidth - totalWidth - getEllipsisWidth(); // ellipsis width?
794                ellipsizedText = copyStyles(spans,
795                        TextUtils.ellipsize(sender, sPaint, width, TruncateAt.END));
796                width = (int) sPaint.measureText(ellipsizedText.toString());
797            } else {
798                ellipsizedText = null;
799            }
800            totalWidth += width;
801
802            final CharSequence fragmentDisplayText;
803            if (ellipsizedText != null) {
804                fragmentDisplayText = ellipsizedText;
805            } else {
806                fragmentDisplayText = sender;
807            }
808            builder.append(fragmentDisplayText);
809        }
810        if (messageInfoString.length() > 0) {
811            builder.append(messageInfoString);
812        }
813        mHeader.styledSendersString = builder;
814        return (int)totalWidth;
815    }
816
817    private float getEllipsisWidth() {
818        return sPaint.measureText(sEllipsis);
819    }
820
821    private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
822        SpannableString s = new SpannableString(newText);
823        if (spans != null && spans.length > 0) {
824            s.setSpan(spans[0], 0, s.length(), 0);
825        }
826        return s;
827    }
828
829    private int ellipsize(int fixedWidth, int sendersY) {
830        int totalWidth = 0;
831        int currentLine = 1;
832        boolean ellipsize = false;
833        for (SenderFragment senderFragment : mHeader.senderFragments) {
834            CharacterStyle style = senderFragment.style;
835            int start = senderFragment.start;
836            int end = senderFragment.end;
837            int width = senderFragment.width;
838            boolean isFixed = senderFragment.isFixed;
839            style.updateDrawState(sPaint);
840
841            // No more width available, we'll only show fixed fragments.
842            if (ellipsize && !isFixed) {
843                senderFragment.shouldDisplay = false;
844                continue;
845            }
846
847            // New line and ellipsize text if needed.
848            senderFragment.ellipsizedText = null;
849            if (isFixed) {
850                fixedWidth -= width;
851            }
852            if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) {
853                // The text is too long, new line won't help. We have to
854                // ellipsize text.
855                if (totalWidth == 0) {
856                    ellipsize = true;
857                } else {
858                    // New line.
859                    if (currentLine < mCoordinates.sendersLineCount) {
860                        currentLine++;
861                        sendersY += mCoordinates.sendersLineHeight;
862                        totalWidth = 0;
863                        // The text is still too long, we have to ellipsize
864                        // text.
865                        if (totalWidth + width > mSendersWidth) {
866                            ellipsize = true;
867                        }
868                    } else {
869                        ellipsize = true;
870                    }
871                }
872
873                if (ellipsize) {
874                    width = mSendersWidth - totalWidth;
875                    // No more new line, we have to reserve width for fixed
876                    // fragments.
877                    if (currentLine == mCoordinates.sendersLineCount) {
878                        width -= fixedWidth;
879                    }
880                    senderFragment.ellipsizedText = TextUtils.ellipsize(
881                            mHeader.sendersText.substring(start, end), sPaint, width,
882                            TruncateAt.END).toString();
883                    width = (int) sPaint.measureText(senderFragment.ellipsizedText);
884                }
885            }
886            senderFragment.shouldDisplay = true;
887            totalWidth += width;
888
889            final CharSequence fragmentDisplayText;
890            if (senderFragment.ellipsizedText != null) {
891                fragmentDisplayText = senderFragment.ellipsizedText;
892            } else {
893                fragmentDisplayText = mHeader.sendersText.substring(start, end);
894            }
895            final int spanStart = mHeader.sendersDisplayText.length();
896            mHeader.sendersDisplayText.append(fragmentDisplayText);
897            mHeader.sendersDisplayText.setSpan(senderFragment.style, spanStart,
898                    mHeader.sendersDisplayText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
899        }
900        return totalWidth;
901    }
902
903    /**
904     * If the subject contains the tag of a mailing-list (text surrounded with
905     * []), return the subject with that tag ellipsized, e.g.
906     * "[android-gmail-team] Hello" -> "[andr...] Hello"
907     */
908    private String filterTag(String subject) {
909        String result = subject;
910        String formatString = getContext().getResources().getString(R.string.filtered_tag);
911        if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') {
912            int end = subject.indexOf(']');
913            if (end > 0) {
914                String tag = subject.substring(1, end);
915                result = String.format(formatString, Utils.ellipsize(tag, 7),
916                        subject.substring(end + 1));
917            }
918        }
919        return result;
920    }
921
922    @Override
923    protected void onDraw(Canvas canvas) {
924        // Check mark.
925        if (mHeader.checkboxVisible) {
926            Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF;
927            canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint);
928        }
929
930        // Personal Level.
931        if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) {
932            canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX,
933                    mCoordinates.personalLevelY, sPaint);
934        }
935
936        // Senders.
937        boolean isUnread = mHeader.unread;
938        // Old style senders; apply text colors/ sizes/ styling.
939        if (mHeader.senderFragments.size() > 0) {
940            sPaint.setTextSize(mCoordinates.sendersFontSize);
941            sPaint.setTypeface(mCoordinates.sendersView.getTypeface(isUnread));
942            int sendersColor = getFontColor(isUnread ? sSendersTextColorUnread
943                    : sSendersTextColorRead);
944            sPaint.setColor(sendersColor);
945        }
946        canvas.save();
947        canvas.translate(mCoordinates.sendersX,
948                mCoordinates.sendersY + mHeader.sendersDisplayLayout.getTopPadding());
949        mHeader.sendersDisplayLayout.draw(canvas);
950        canvas.restore();
951
952        // Subject.
953        sPaint.setTextSize(mCoordinates.subjectFontSize);
954        sPaint.setTypeface(Typeface.DEFAULT);
955        canvas.save();
956        if (isActivated() && showActivatedText()) {
957            if (mHeader.subjectLayoutActivated != null) {
958                canvas.translate(mCoordinates.subjectX, mCoordinates.subjectY
959                        + mHeader.subjectLayoutActivated.getTopPadding());
960                mHeader.subjectLayoutActivated.draw(canvas);
961            }
962        } else if (mHeader.subjectLayout != null) {
963            canvas.translate(mCoordinates.subjectX,
964                    mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding());
965            mHeader.subjectLayout.draw(canvas);
966        }
967        canvas.restore();
968
969        // Folders.
970        if (mCoordinates.showFolders) {
971            mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode);
972        }
973
974        // If this folder has a color (combined view/Email), show it here
975        if (mHeader.conversation.color != 0) {
976            sFoldersPaint.setColor(mHeader.conversation.color);
977            sFoldersPaint.setStyle(Paint.Style.FILL);
978            int width = ConversationItemViewCoordinates.getColorBlockWidth(mContext);
979            int height = ConversationItemViewCoordinates.getColorBlockHeight(mContext);
980            canvas.drawRect(mCoordinates.dateXEnd - width, 0, mCoordinates.dateXEnd,
981                    height, sFoldersPaint);
982        }
983
984        // Date background: shown when there is an attachment or a visible
985        // folder.
986        if (!isActivated()
987                && (mHeader.conversation.hasAttachments ||
988                        (mHeader.folderDisplayer != null
989                            && mHeader.folderDisplayer.hasVisibleFolders()))
990                && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) {
991            int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX)
992                    - sDateBackgroundPaddingLeft;
993            int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY;
994            mHeader.dateBackground = getDateBackground(mHeader.conversation.hasAttachments);
995            canvas.drawBitmap(mHeader.dateBackground, leftOffset, top, sPaint);
996        } else {
997            mHeader.dateBackground = null;
998        }
999
1000        // Draw the reply state. Draw nothing if neither replied nor forwarded.
1001        if (mCoordinates.showReplyState) {
1002            if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) {
1003                canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX,
1004                        mCoordinates.replyStateY, null);
1005            } else if (mHeader.hasBeenRepliedTo) {
1006                canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX,
1007                        mCoordinates.replyStateY, null);
1008            } else if (mHeader.hasBeenForwarded) {
1009                canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX,
1010                        mCoordinates.replyStateY, null);
1011            } else if (mHeader.isInvite) {
1012                canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX,
1013                        mCoordinates.replyStateY, null);
1014            }
1015        }
1016
1017        // Date.
1018        sPaint.setTextSize(mCoordinates.dateFontSize);
1019        sPaint.setTypeface(Typeface.DEFAULT);
1020        sPaint.setColor(sDateTextColor);
1021        drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent,
1022                sPaint);
1023
1024        // Paper clip icon.
1025        if (mHeader.paperclip != null) {
1026            canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint);
1027        }
1028
1029        if (mHeader.faded) {
1030            int fadedColor = -1;
1031            if (sFadedActivatedColor == -1) {
1032                sFadedActivatedColor = mContext.getResources().getColor(
1033                        R.color.faded_activated_conversation_header);
1034            }
1035            fadedColor = sFadedActivatedColor;
1036            int restoreState = canvas.save();
1037            Rect bounds = canvas.getClipBounds();
1038            canvas.clipRect(bounds.left, bounds.top, bounds.right
1039                    - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width),
1040                    bounds.bottom);
1041            canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor),
1042                    Color.green(fadedColor), Color.blue(fadedColor));
1043            canvas.restoreToCount(restoreState);
1044        }
1045
1046        // Star.
1047        canvas.drawBitmap(getStarBitmap(), mCoordinates.starX, mCoordinates.starY, sPaint);
1048    }
1049
1050    private Bitmap getStarBitmap() {
1051        return mHeader.conversation.starred ? STAR_ON : STAR_OFF;
1052    }
1053
1054    private Bitmap getDateBackground(boolean hasAttachments) {
1055        int leftOffset = (hasAttachments ? mPaperclipX : mDateX) - sDateBackgroundPaddingLeft;
1056        if (hasAttachments) {
1057            if (sDateBackgroundAttachment == null) {
1058                sDateBackgroundAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth
1059                        - leftOffset, sDateBackgroundHeight, false);
1060            }
1061            return sDateBackgroundAttachment;
1062        } else {
1063            if (sDateBackgroundNoAttachment == null) {
1064                sDateBackgroundNoAttachment = Bitmap.createScaledBitmap(DATE_BACKGROUND, mViewWidth
1065                        - leftOffset, sDateBackgroundHeight, false);
1066            }
1067            return sDateBackgroundNoAttachment;
1068        }
1069    }
1070
1071    private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) {
1072        canvas.drawText(s, 0, s.length(), x, y, paint);
1073    }
1074
1075    private void updateBackground(boolean isUnread) {
1076        if (isUnread) {
1077            if (mTabletDevice && mViewMode.isListMode()) {
1078                if (mChecked) {
1079                    setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo);
1080                } else {
1081                    setBackgroundResource(R.drawable.conversation_wide_unread_selector);
1082                }
1083            } else {
1084                if (mChecked) {
1085                    setCheckedActivatedBackground();
1086                } else {
1087                    setBackgroundResource(R.drawable.conversation_unread_selector);
1088                }
1089            }
1090        } else {
1091            if (mTabletDevice && mViewMode.isListMode()) {
1092                if (mChecked) {
1093                    setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo);
1094                } else {
1095                    setBackgroundResource(R.drawable.conversation_wide_read_selector);
1096                }
1097            } else {
1098                if (mChecked) {
1099                    setCheckedActivatedBackground();
1100                } else {
1101                    setBackgroundResource(R.drawable.conversation_read_selector);
1102                }
1103            }
1104        }
1105    }
1106
1107    private void setCheckedActivatedBackground() {
1108        if (isActivated() && mTabletDevice) {
1109            setBackgroundResource(R.drawable.list_arrow_selected_holo);
1110        } else {
1111            setBackgroundResource(R.drawable.list_selected_holo);
1112        }
1113    }
1114
1115    /**
1116     * Toggle the check mark on this view and update the conversation
1117     */
1118    public void toggleCheckMark() {
1119        if (mHeader != null && mHeader.conversation != null) {
1120            mChecked = !mChecked;
1121            Conversation conv = mHeader.conversation;
1122            // Set the list position of this item in the conversation
1123            ListView listView = getListView();
1124            conv.position = mChecked && listView != null ? listView.getPositionForView(this)
1125                    : Conversation.NO_POSITION;
1126            if (mSelectedConversationSet != null) {
1127                mSelectedConversationSet.toggle(this, conv);
1128            }
1129            // We update the background after the checked state has changed now
1130            // that
1131            // we have a selected background asset. Setting the background
1132            // usually
1133            // waits for a layout pass, but we don't need a full layout, just an
1134            // update to the background.
1135            requestLayout();
1136        }
1137    }
1138
1139    /**
1140     * Return if the checkbox for this item is checked.
1141     */
1142    public boolean isChecked() {
1143        return mChecked;
1144    }
1145
1146    /**
1147     * Toggle the star on this view and update the conversation.
1148     */
1149    public void toggleStar() {
1150        mHeader.conversation.starred = !mHeader.conversation.starred;
1151        Bitmap starBitmap = getStarBitmap();
1152        postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX
1153                + starBitmap.getWidth(),
1154                mCoordinates.starY + starBitmap.getHeight());
1155        ConversationCursor cursor = (ConversationCursor)mAdapter.getCursor();
1156        cursor.updateBoolean(mContext, mHeader.conversation, ConversationColumns.STARRED,
1157                mHeader.conversation.starred);
1158    }
1159
1160    private boolean isTouchInCheckmark(float x, float y) {
1161        // Everything before senders and include a touch slop.
1162        return mHeader.checkboxVisible && x < mCoordinates.sendersX + sTouchSlop;
1163    }
1164
1165    private boolean isTouchInStar(float x, float y) {
1166        // Everything after the star and include a touch slop.
1167        return x > mCoordinates.starX - sTouchSlop;
1168    }
1169
1170    /**
1171     * Cancel any potential tap handling on this view.
1172     */
1173    @Override
1174    public void cancelTap() {
1175        removeCallbacks(mPendingCheckForTap);
1176        removeCallbacks(mPendingCheckForLongPress);
1177    }
1178
1179    /**
1180     * ConversationItemView is given the first chance to handle touch events.
1181     */
1182    @Override
1183    public boolean onTouchEvent(MotionEvent event) {
1184        mLastTouchX = (int) event.getX();
1185        mLastTouchY = (int) event.getY();
1186        if (!mSwipeEnabled) {
1187            return onTouchEventNoSwipe(event);
1188        }
1189        boolean handled = true;
1190
1191        int x = mLastTouchX;
1192        int y = mLastTouchY;
1193        switch (event.getAction()) {
1194            case MotionEvent.ACTION_DOWN:
1195                mDownEvent = true;
1196                // In order to allow the down event and subsequent move events
1197                // to bubble to the swipe handler, we need to return that all
1198                // down events are handled.
1199                handled = true;
1200                // TODO (mindyp) Debounce
1201                if (mPendingCheckForTap == null) {
1202                    mPendingCheckForTap = new CheckForTap();
1203                }
1204                postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
1205                break;
1206            case MotionEvent.ACTION_CANCEL:
1207                mDownEvent = false;
1208                break;
1209            case MotionEvent.ACTION_UP:
1210                cancelTap();
1211                if (mDownEvent) {
1212                    // ConversationItemView gets the first chance to handle up
1213                    // events if there was a down event and there was no move
1214                    // event in between. In this case, ConversationItemView
1215                    // received the down event, and then an up event in the
1216                    // same location (+/- slop). Treat this as a click on the
1217                    // view or on a specific part of the view.
1218                    if (isTouchInCheckmark(x, y)) {
1219                        // Touch on the check mark
1220                        toggleCheckMark();
1221                        handled = true;
1222                    } else if (isTouchInStar(x, y)) {
1223                        // Touch on the star
1224                        toggleStar();
1225                        handled = true;
1226                    } else {
1227                        ListView list = getListView();
1228                        if (list != null) {
1229                            int pos = list.getPositionForView(this);
1230                            list.performItemClick(this, pos, mHeader.conversation.id);
1231                        }
1232                    }
1233                    handled = true;
1234                } else {
1235                    // There was no down event that this view was made aware of,
1236                    // therefore it cannot handle it.
1237                    handled = false;
1238                }
1239                break;
1240        }
1241
1242        // Let View try to handle it as well.
1243        return handled || super.onTouchEvent(event);
1244    }
1245
1246    private ListView getListView() {
1247        return ((SwipeableConversationItemView) getParent()).getListView();
1248    }
1249
1250    private boolean onTouchEventNoSwipe(MotionEvent event) {
1251        boolean handled = true;
1252
1253        int x = (int) event.getX();
1254        int y = (int) event.getY();
1255        switch (event.getAction()) {
1256            case MotionEvent.ACTION_DOWN:
1257                mDownEvent = true;
1258                // In order to allow the down event and subsequent move events
1259                // to bubble to the swipe handler, we need to return that all
1260                // down events are handled.
1261                handled = isTouchInCheckmark(x, y) || isTouchInStar(x, y);
1262                break;
1263            case MotionEvent.ACTION_CANCEL:
1264                mDownEvent = false;
1265                break;
1266            case MotionEvent.ACTION_UP:
1267                if (mDownEvent) {
1268                    // ConversationItemView gets the first chance to handle up
1269                    // events if there was a down event and there was no move
1270                    // event in between. In this case, ConversationItemView
1271                    // received the down event, and then an up event in the
1272                    // same location (+/- slop). Treat this as a click on the
1273                    // view or on a specific part of the view.
1274                    if (isTouchInCheckmark(x, y)) {
1275                        // Touch on the check mark
1276                        toggleCheckMark();
1277                    } else if (isTouchInStar(x, y)) {
1278                        // Touch on the star
1279                        toggleStar();
1280                    }
1281                    handled = true;
1282                } else {
1283                    // There was no down event that this view was made aware of,
1284                    // therefore it cannot handle it.
1285                    handled = false;
1286                }
1287                break;
1288        }
1289
1290        // Let View try to handle it as well.
1291        return handled || super.onTouchEvent(event);
1292    }
1293
1294    /**
1295     * Return if this item should respond to long clicks.
1296     */
1297    @Override
1298    public boolean isLongClickable() {
1299        return true;
1300    }
1301
1302    final class CheckForTap implements Runnable {
1303        @Override
1304        public void run() {
1305            // refreshDrawableState();
1306            final int longPressTimeout = ViewConfiguration.getLongPressTimeout();
1307            final boolean longClickable = isLongClickable();
1308
1309            if (longClickable) {
1310                if (mPendingCheckForLongPress == null) {
1311                    mPendingCheckForLongPress = new CheckForLongPress();
1312                }
1313                postDelayed(mPendingCheckForLongPress, longPressTimeout);
1314            }
1315        }
1316    }
1317
1318    private class CheckForLongPress implements Runnable {
1319        @Override
1320        public void run() {
1321            handleLongClick();
1322            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
1323        }
1324    }
1325
1326    /**
1327     * Grow the height of the item and fade it in when bringing a conversation
1328     * back from a destructive action.
1329     * @param listener
1330     */
1331    public void startSwipeUndoAnimation(ViewMode viewMode, final AnimatorListener listener) {
1332        final int start = sUndoAnimationOffset;
1333        final int end = 0;
1334        ObjectAnimator undoAnimator = ObjectAnimator.ofFloat(this, "translationX", start, end);
1335        undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
1336        undoAnimator.addListener(listener);
1337        undoAnimator.setDuration(sUndoAnimationDuration);
1338        undoAnimator.start();
1339    }
1340
1341    /**
1342     * Grow the height of the item and fade it in when bringing a conversation
1343     * back from a destructive action.
1344     * @param listener
1345     */
1346    public void startUndoAnimation(ViewMode viewMode, final AnimatorListener listener) {
1347        int minHeight = ConversationItemViewCoordinates.getMinHeight(mContext, viewMode);
1348        setMinimumHeight(minHeight);
1349        final int start = 0;
1350        final int end = minHeight;
1351        ObjectAnimator undoAnimator = ObjectAnimator.ofInt(this, "animatedHeight", start, end);
1352        Animator fadeAnimator = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f);
1353        mAnimatedHeight = start;
1354        undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
1355        undoAnimator.addListener(listener);
1356        undoAnimator.setDuration(sUndoAnimationDuration);
1357        AnimatorSet transitionSet = new AnimatorSet();
1358        transitionSet.playTogether(undoAnimator, fadeAnimator);
1359        transitionSet.start();
1360    }
1361
1362    // Used by animator
1363    @SuppressWarnings("unused")
1364    public void setItemAlpha(float alpha) {
1365        setAlpha(alpha);
1366        invalidate();
1367    }
1368
1369    // Used by animator
1370    @SuppressWarnings("unused")
1371    public void setAnimatedHeight(int height) {
1372        mAnimatedHeight = height;
1373        requestLayout();
1374    }
1375
1376    @Override
1377    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
1378        if (mAnimatedHeight == -1) {
1379            int height = measureHeight(heightMeasureSpec,
1380                    ConversationItemViewCoordinates.getMode(mContext, mViewMode));
1381            setMeasuredDimension(widthMeasureSpec, height);
1382        } else {
1383            setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight);
1384        }
1385    }
1386
1387    /**
1388     * Determine the height of this view.
1389     * @param measureSpec A measureSpec packed into an int
1390     * @param mode The current mode of this view
1391     * @return The height of the view, honoring constraints from measureSpec
1392     */
1393    private int measureHeight(int measureSpec, int mode) {
1394        int result = 0;
1395        int specMode = MeasureSpec.getMode(measureSpec);
1396        int specSize = MeasureSpec.getSize(measureSpec);
1397
1398        if (specMode == MeasureSpec.EXACTLY) {
1399            // We were told how big to be
1400            result = specSize;
1401        } else {
1402            // Measure the text
1403            result = ConversationItemViewCoordinates.getHeight(mContext, mode);
1404            if (specMode == MeasureSpec.AT_MOST) {
1405                // Respect AT_MOST value if that was what is called for by
1406                // measureSpec
1407                result = Math.min(result, specSize);
1408            }
1409        }
1410        return result;
1411    }
1412
1413    /**
1414     * Get the current position of this conversation item in the list.
1415     */
1416    public int getPosition() {
1417        return mHeader != null && mHeader.conversation != null ?
1418                mHeader.conversation.position : -1;
1419    }
1420
1421    /**
1422     * Select the current conversation.
1423     */
1424    private void selectConversation() {
1425        if (!mSelectedConversationSet.containsKey(mHeader.conversation.id)) {
1426            toggleCheckMark();
1427        }
1428    }
1429
1430    @Override
1431    public View getView() {
1432        return this;
1433    }
1434
1435    /**
1436     * With two pane mode and mailboxes in one pane (tablet), add the
1437     * conversation to the selected set and start drag mode. In two pane mode
1438     * when viewing conversations (tablet), toggle selection. In one pane mode
1439     * (phone, and portrait mode on tablet), toggle selection.
1440     */
1441    private void handleLongClick() {
1442        mDownEvent = false;
1443        // If we are in one pane mode, or we are looking at conversations, drag and drop is
1444        // meaningless. Allow the list's long click handler to do the right thing.
1445        if (!Utils.useTabletUI(mContext) || !mViewMode.isListMode()) {
1446            performLongClick();
1447        } else {
1448            beginDragMode();
1449        }
1450    }
1451
1452    /**
1453     * Begin drag mode. Keep the conversation selected (NOT toggle selection) and start drag.
1454     */
1455    private void beginDragMode() {
1456        selectConversation();
1457
1458        // Clip data has form: [conversations_uri, conversationId1,
1459        // maxMessageId1, label1, conversationId2, maxMessageId2, label2, ...]
1460        final int count = mSelectedConversationSet.size();
1461        String description = Utils.formatPlural(mContext, R.plurals.move_conversation, count);
1462
1463        final ClipData data = ClipData.newUri(mContext.getContentResolver(), description,
1464                Conversation.MOVE_CONVERSATIONS_URI);
1465        for (Conversation conversation : mSelectedConversationSet.values()) {
1466            data.addItem(new Item(String.valueOf(conversation.position)));
1467        }
1468        // Protect against non-existent views: only happens for monkeys
1469        final int width = this.getWidth();
1470        final int height = this.getHeight();
1471        final boolean isDimensionNegative = (width < 0) || (height < 0);
1472        if (isDimensionNegative) {
1473            LogUtils.e(LOG_TAG, "ConversationItemView: dimension is negative: "
1474                        + "width=%d, height=%d", width, height);
1475            return;
1476        }
1477        // Start drag mode
1478        startDrag(data, new ShadowBuilder(this, count, mLastTouchX, mLastTouchY), null, 0);
1479    }
1480
1481    private class ShadowBuilder extends DragShadowBuilder {
1482        private final Drawable mBackground;
1483
1484        private final View mView;
1485        private final String mDragDesc;
1486        private final int mTouchX;
1487        private final int mTouchY;
1488        private int mDragDescX;
1489        private int mDragDescY;
1490
1491        public ShadowBuilder(View view, int count, int touchX, int touchY) {
1492            super(view);
1493            mView = view;
1494            mBackground = mView.getResources().getDrawable(R.drawable.list_pressed_holo);
1495            mDragDesc = Utils.formatPlural(mView.getContext(), R.plurals.move_conversation, count);
1496            mTouchX = touchX;
1497            mTouchY = touchY;
1498        }
1499
1500        @Override
1501        public void onProvideShadowMetrics(Point shadowSize, Point shadowTouchPoint) {
1502            int width = mView.getWidth();
1503            int height = mView.getHeight();
1504            mDragDescX = mCoordinates.sendersX;
1505            mDragDescY = getPadding(height, mCoordinates.subjectFontSize)
1506                    - mCoordinates.subjectAscent;
1507            shadowSize.set(width, height);
1508            shadowTouchPoint.set(mTouchX, mTouchY);
1509        }
1510
1511        @Override
1512        public void onDrawShadow(Canvas canvas) {
1513            mBackground.setBounds(0, 0, mView.getWidth(), mView.getHeight());
1514            mBackground.draw(canvas);
1515            sPaint.setTextSize(mCoordinates.subjectFontSize);
1516            canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint);
1517        }
1518    }
1519}
1520