ConversationItemView.java revision 866d319dd23ec8b7b7d5476c65f7f83469d55d2d
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 com.google.common.annotations.VisibleForTesting;
21
22import android.animation.Animator;
23import android.animation.AnimatorSet;
24import android.animation.ObjectAnimator;
25import android.animation.Animator.AnimatorListener;
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.Rect;
36import android.graphics.Shader;
37import android.graphics.Typeface;
38import android.graphics.drawable.Drawable;
39import android.text.Layout.Alignment;
40import android.text.Spannable;
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.StyleSpan;
50import android.text.util.Rfc822Token;
51import android.text.util.Rfc822Tokenizer;
52import android.util.SparseArray;
53import android.view.MotionEvent;
54import android.view.View;
55import android.view.View.MeasureSpec;
56import android.view.animation.DecelerateInterpolator;
57import android.widget.Checkable;
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.Address;
64import com.android.mail.providers.Conversation;
65import com.android.mail.providers.Folder;
66import com.android.mail.providers.UIProvider;
67import com.android.mail.providers.UIProvider.ConversationColumns;
68import com.android.mail.ui.ConversationSelectionSet;
69import com.android.mail.ui.FolderDisplayer;
70import com.android.mail.ui.ViewMode;
71import com.android.mail.utils.Utils;
72
73public class ConversationItemView extends View {
74    // Timer.
75    private static int sLayoutCount = 0;
76    private static Timer sTimer; // Create the sTimer here if you need to do perf analysis.
77    private static final int PERF_LAYOUT_ITERATIONS = 50;
78    private static final String PERF_TAG_LAYOUT = "CCHV.layout";
79    private static final String PERF_TAG_CALCULATE_TEXTS_BITMAPS = "CCHV.txtsbmps";
80    private static final String PERF_TAG_CALCULATE_SENDER_SUBJECT = "CCHV.sendersubj";
81    private static final String PERF_TAG_CALCULATE_FOLDERS = "CCHV.folders";
82    private static final String PERF_TAG_CALCULATE_COORDINATES = "CCHV.coordinates";
83
84    // Static bitmaps.
85    private static Bitmap CHECKMARK_OFF;
86    private static Bitmap CHECKMARK_ON;
87    private static Bitmap STAR_OFF;
88    private static Bitmap STAR_ON;
89    private static Bitmap ATTACHMENT;
90    private static Bitmap ONLY_TO_ME;
91    private static Bitmap TO_ME_AND_OTHERS;
92    private static Bitmap IMPORTANT_ONLY_TO_ME;
93    private static Bitmap IMPORTANT_TO_ME_AND_OTHERS;
94    private static Bitmap IMPORTANT_TO_OTHERS;
95    private static Bitmap DATE_BACKGROUND;
96    private static Bitmap STATE_REPLIED;
97    private static Bitmap STATE_FORWARDED;
98    private static Bitmap STATE_REPLIED_AND_FORWARDED;
99    private static Bitmap STATE_CALENDAR_INVITE;
100
101    // Static colors.
102    private static int DEFAULT_TEXT_COLOR;
103    private static int ACTIVATED_TEXT_COLOR;
104    private static int LIGHT_TEXT_COLOR;
105    private static int DRAFT_TEXT_COLOR;
106    private static int SUBJECT_TEXT_COLOR_READ;
107    private static int SUBJECT_TEXT_COLOR_UNREAD;
108    private static int SNIPPET_TEXT_COLOR_READ;
109    private static int SNIPPET_TEXT_COLOR_UNREAD;
110    private static int SENDERS_TEXT_COLOR_READ;
111    private static int SENDERS_TEXT_COLOR_UNREAD;
112    private static int DATE_TEXT_COLOR_READ;
113    private static int DATE_TEXT_COLOR_UNREAD;
114    private static int DATE_BACKGROUND_PADDING_LEFT;
115    private static int TOUCH_SLOP;
116    private static int sDateBackgroundHeight;
117    private static int sStandardScaledDimen;
118    private static int sUndoAnimationDuration;
119    private static CharacterStyle sLightTextStyle;
120    private static CharacterStyle sNormalTextStyle;
121
122    // Static paints.
123    private static TextPaint sPaint = new TextPaint();
124    private static TextPaint sFoldersPaint = new TextPaint();
125
126    // Backgrounds for different states.
127    private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>();
128
129    // Dimensions and coordinates.
130    private int mViewWidth = -1;
131    private int mMode = -1;
132    private int mDateX;
133    private int mPaperclipX;
134    private int mFoldersXEnd;
135    private int mSendersWidth;
136
137    /** Whether we're running under test mode. */
138    private boolean mTesting = false;
139    /** Whether we are on a tablet device or not */
140    private final boolean mTabletDevice;
141
142    @VisibleForTesting
143    ConversationItemViewCoordinates mCoordinates;
144
145    private final Context mContext;
146
147    public ConversationItemViewModel mHeader;
148    private ViewMode mViewMode;
149    private boolean mDownEvent;
150    private boolean mChecked = false;
151    private static int sFadedActivatedColor = -1;
152    private ConversationSelectionSet mSelectedConversationSet;
153    private Folder mDisplayedFolder;
154    private boolean mPriorityMarkersEnabled;
155    private int mAnimatedHeight = -1;
156    private static Bitmap MORE_FOLDERS;
157
158    static {
159        sPaint.setAntiAlias(true);
160        sFoldersPaint.setAntiAlias(true);
161    }
162
163
164    /**
165     * Handles displaying folders in a conversation header view.
166     */
167    static class ConversationItemFolderDisplayer extends FolderDisplayer {
168        // Maximum number of folders to be displayed.
169        private static final int MAX_DISPLAYED_FOLDERS_COUNT = 4;
170
171        private int mFoldersCount;
172        private boolean mHasMoreFolders;
173
174        public ConversationItemFolderDisplayer(Context context) {
175            super(context);
176        }
177
178        @Override
179        public void loadConversationFolders(String rawFolders, Folder ignoreFolder) {
180            super.loadConversationFolders(rawFolders, ignoreFolder);
181
182            mFoldersCount = mFoldersSortedSet.size();
183            mHasMoreFolders = mFoldersCount > MAX_DISPLAYED_FOLDERS_COUNT;
184            mFoldersCount = Math.min(mFoldersCount, MAX_DISPLAYED_FOLDERS_COUNT);
185        }
186
187        public boolean hasVisibleFolders() {
188            return mFoldersCount > 0;
189        }
190
191        private int measureFolders(int mode) {
192            int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode);
193            int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode,
194                    mFoldersCount);
195
196            int totalWidth = 0;
197            for (Folder f : mFoldersSortedSet) {
198                final String folderString = f.name;
199                int width = (int) sFoldersPaint.measureText(folderString) + cellSize;
200                if (width % cellSize != 0) {
201                    width += cellSize - (width % cellSize);
202                }
203                totalWidth += width;
204                if (totalWidth > availableSpace) {
205                    break;
206                }
207            }
208
209            return totalWidth;
210        }
211
212        public void drawFolders(Canvas canvas, ConversationItemViewCoordinates coordinates,
213                int foldersXEnd, int mode) {
214            if (mFoldersCount == 0) {
215                return;
216            }
217
218            int xEnd = foldersXEnd;
219            int y = coordinates.foldersY - coordinates.foldersAscent;
220            int height = coordinates.foldersHeight;
221            int topPadding = coordinates.foldersTopPadding;
222            int ascent = coordinates.foldersAscent;
223            sFoldersPaint.setTextSize(coordinates.foldersFontSize);
224
225            // Initialize space and cell size based on the current mode.
226            int availableSpace = ConversationItemViewCoordinates.getFoldersWidth(mContext, mode);
227            int averageWidth = availableSpace / mFoldersCount;
228            int cellSize = ConversationItemViewCoordinates.getFolderCellWidth(mContext, mode,
229                    mFoldersCount);
230
231            // First pass to calculate the starting point.
232            int totalWidth = measureFolders(mode);
233            int xStart = xEnd - Math.min(availableSpace, totalWidth);
234
235            // Second pass to draw folders.
236            for (Folder f : mFoldersSortedSet) {
237                final String folderString = f.name;
238                final int fgColor = f.getForegroundColor(mDefaultFgColor);
239                final int bgColor = f.getBackgroundColor(mDefaultBgColor);
240                int width = cellSize;
241                boolean labelTooLong = false;
242                width = (int) sFoldersPaint.measureText(folderString) + cellSize;
243                if (width % cellSize != 0) {
244                    width += cellSize - (width % cellSize);
245                }
246                if (totalWidth > availableSpace && width > averageWidth) {
247                    width = averageWidth;
248                    labelTooLong = true;
249                }
250
251                // TODO (mindyp): how to we get this?
252                final boolean isMuted = false;
253                     //   labelValues.folderId == sGmail.getFolderMap(mAccount).getFolderIdIgnored();
254
255                // Draw the box.
256                sFoldersPaint.setColor(bgColor);
257                sFoldersPaint.setStyle(isMuted ? Paint.Style.STROKE : Paint.Style.FILL_AND_STROKE);
258                canvas.drawRect(xStart, y + ascent, xStart + width, y + ascent + height,
259                        sFoldersPaint);
260
261                // Draw the text.
262                int padding = getPadding(width, (int) sFoldersPaint.measureText(folderString));
263                if (labelTooLong) {
264                    TextPaint shortPaint = new TextPaint();
265                    shortPaint.setColor(fgColor);
266                    shortPaint.setTextSize(coordinates.foldersFontSize);
267                    padding = cellSize / 2;
268                    int rightBorder = xStart + width - padding;
269                    Shader shader = new LinearGradient(rightBorder - padding, y, rightBorder, y,
270                            fgColor, Utils.getTransparentColor(fgColor), Shader.TileMode.CLAMP);
271                    shortPaint.setShader(shader);
272                    canvas.drawText(folderString, xStart + padding, y + topPadding, shortPaint);
273                } else {
274                    sFoldersPaint.setColor(fgColor);
275                    canvas.drawText(folderString, xStart + padding, y + topPadding, sFoldersPaint);
276                }
277
278                availableSpace -= width;
279                xStart += width;
280                if (availableSpace <= 0 && mHasMoreFolders) {
281                    canvas.drawBitmap(MORE_FOLDERS, xEnd, y + ascent, sFoldersPaint);
282                    return;
283                }
284            }
285        }
286
287        /**
288         * Helpers function to align an element in the center of a space.
289         */
290        private static int getPadding(int space, int length) {
291            return (space - length) / 2;
292        }
293    }
294
295    public ConversationItemView(Context context, String account) {
296        super(context);
297        mContext = context.getApplicationContext();
298        mTabletDevice = Utils.useTabletUI(mContext);
299
300        Resources res = mContext.getResources();
301
302        if (CHECKMARK_OFF == null) {
303            // Initialize static bitmaps.
304            CHECKMARK_OFF = BitmapFactory.decodeResource(res,
305                    R.drawable.btn_check_off_normal_holo_light);
306            CHECKMARK_ON = BitmapFactory.decodeResource(res,
307                    R.drawable.btn_check_on_normal_holo_light);
308            STAR_OFF = BitmapFactory.decodeResource(res,
309                    R.drawable.btn_star_off_normal_email_holo_light);
310            STAR_ON = BitmapFactory.decodeResource(res,
311                    R.drawable.btn_star_on_normal_email_holo_light);
312            ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double);
313            TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single);
314            IMPORTANT_ONLY_TO_ME = BitmapFactory.decodeResource(res,
315                    R.drawable.ic_email_caret_double_important_unread);
316            IMPORTANT_TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res,
317                    R.drawable.ic_email_caret_single_important_unread);
318            IMPORTANT_TO_OTHERS = BitmapFactory.decodeResource(res,
319                    R.drawable.ic_email_caret_none_important_unread);
320            ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light);
321            MORE_FOLDERS = BitmapFactory.decodeResource(res, R.drawable.ic_folders_more);
322            DATE_BACKGROUND = BitmapFactory.decodeResource(res, R.drawable.folder_bg_holo_light);
323            STATE_REPLIED =
324                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_holo_light);
325            STATE_FORWARDED =
326                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_forward_holo_light);
327            STATE_REPLIED_AND_FORWARDED =
328                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_reply_forward_holo_light);
329            STATE_CALENDAR_INVITE =
330                    BitmapFactory.decodeResource(res, R.drawable.ic_badge_invite_holo_light);
331
332            // Initialize colors.
333            DEFAULT_TEXT_COLOR = res.getColor(R.color.default_text_color);
334            ACTIVATED_TEXT_COLOR = res.getColor(android.R.color.white);
335            LIGHT_TEXT_COLOR = res.getColor(R.color.light_text_color);
336            DRAFT_TEXT_COLOR = res.getColor(R.color.drafts);
337            SUBJECT_TEXT_COLOR_READ = res.getColor(R.color.subject_text_color_read);
338            SUBJECT_TEXT_COLOR_UNREAD = res.getColor(R.color.subject_text_color_unread);
339            SNIPPET_TEXT_COLOR_READ = res.getColor(R.color.snippet_text_color_read);
340            SNIPPET_TEXT_COLOR_UNREAD = res.getColor(R.color.snippet_text_color_unread);
341            SENDERS_TEXT_COLOR_READ = res.getColor(R.color.senders_text_color_read);
342            SENDERS_TEXT_COLOR_UNREAD = res.getColor(R.color.senders_text_color_unread);
343            DATE_TEXT_COLOR_READ = res.getColor(R.color.date_text_color_read);
344            DATE_TEXT_COLOR_UNREAD = res.getColor(R.color.date_text_color_unread);
345            DATE_BACKGROUND_PADDING_LEFT = res
346                    .getDimensionPixelSize(R.dimen.date_background_padding_left);
347            TOUCH_SLOP = res.getDimensionPixelSize(R.dimen.touch_slop);
348            sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height);
349            sStandardScaledDimen = res.getDimensionPixelSize(R.dimen.standard_scaled_dimen);
350            sUndoAnimationDuration = res.getInteger(R.integer.undo_animation_duration);
351
352            // Initialize static color.
353            sNormalTextStyle = new StyleSpan(Typeface.NORMAL);
354            sLightTextStyle = new ForegroundColorSpan(LIGHT_TEXT_COLOR);
355        }
356    }
357
358    public void bind(Cursor cursor, ViewMode viewMode, ConversationSelectionSet set,
359            Folder folder) {
360        mViewMode = viewMode;
361        mHeader = ConversationItemViewModel.forCursor(cursor);
362        mSelectedConversationSet = set;
363        mDisplayedFolder = folder;
364        setContentDescription(mHeader.getContentDescription(mContext));
365        requestLayout();
366    }
367
368    /**
369     * Get the Conversation object associated with this view.
370     */
371    public Conversation getConversation() {
372        return mHeader.conversation;
373    }
374
375    /**
376     * Sets the mode. Only used for testing.
377     */
378    @VisibleForTesting
379    void setMode(int mode) {
380        mMode = mode;
381        mTesting = true;
382    }
383
384    private static void startTimer(String tag) {
385        if (sTimer != null) {
386            sTimer.start(tag);
387        }
388    }
389
390    private static void pauseTimer(String tag) {
391        if (sTimer != null) {
392            sTimer.pause(tag);
393        }
394    }
395
396    @Override
397    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
398        startTimer(PERF_TAG_LAYOUT);
399
400        super.onLayout(changed, left, top, right, bottom);
401
402        int width = right - left;
403        if (width != mViewWidth) {
404            mViewWidth = width;
405            if (!mTesting) {
406                mMode = ConversationItemViewCoordinates.getMode(mContext, mViewMode);
407            }
408        }
409        mHeader.viewWidth = mViewWidth;
410        Resources res = getResources();
411        mHeader.standardScaledDimen = res.getDimensionPixelOffset(R.dimen.standard_scaled_dimen);
412        if (mHeader.standardScaledDimen != sStandardScaledDimen) {
413            // Large Text has been toggle on/off. Update the static dimens.
414            sStandardScaledDimen = mHeader.standardScaledDimen;
415            ConversationItemViewCoordinates.refreshConversationHeights(mContext);
416            sDateBackgroundHeight = res.getDimensionPixelSize(R.dimen.date_background_height);
417        }
418        mCoordinates = ConversationItemViewCoordinates.forWidth(mContext, mViewWidth, mMode,
419                mHeader.standardScaledDimen);
420        calculateTextsAndBitmaps();
421        calculateCoordinates();
422        mHeader.validate(mContext);
423
424        pauseTimer(PERF_TAG_LAYOUT);
425        if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) {
426            sTimer.dumpResults();
427            sTimer = new Timer();
428            sLayoutCount = 0;
429        }
430    }
431
432    @Override
433    public void setBackgroundResource(int resourceId) {
434        Drawable drawable = mBackgrounds.get(resourceId);
435        if (drawable == null) {
436            drawable = getResources().getDrawable(resourceId);
437            mBackgrounds.put(resourceId, drawable);
438        }
439        if (getBackground() != drawable) {
440            super.setBackgroundDrawable(drawable);
441        }
442    }
443
444    private void calculateTextsAndBitmaps() {
445        startTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
446        if (mSelectedConversationSet != null) {
447            mChecked = mSelectedConversationSet.contains(mHeader.conversation);
448        }
449        // Update font color.
450        int fontColor = getFontColor(DEFAULT_TEXT_COLOR);
451        boolean fontChanged = false;
452        if (mHeader.fontColor != fontColor) {
453            fontChanged = true;
454            mHeader.fontColor = fontColor;
455        }
456
457        boolean isUnread = mHeader.unread;
458
459        final boolean checkboxEnabled = true;
460        if (mHeader.checkboxVisible != checkboxEnabled) {
461            mHeader.checkboxVisible = checkboxEnabled;
462        }
463
464        // Update background.
465        updateBackground(isUnread);
466
467        if (mHeader.isLayoutValid(mContext)) {
468            // Relayout subject if font color has changed.
469            if (fontChanged) {
470                createSubjectSpans(isUnread);
471                layoutSubject();
472            }
473            pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
474            return;
475        }
476
477        startTimer(PERF_TAG_CALCULATE_FOLDERS);
478
479        // Initialize folder displayer.
480        if (mCoordinates.showFolders) {
481            mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext);
482            mHeader.folderDisplayer.loadConversationFolders(mHeader.rawFolders, mDisplayedFolder);
483        }
484
485        pauseTimer(PERF_TAG_CALCULATE_FOLDERS);
486
487        // Star.
488        mHeader.starBitmap = mHeader.starred ? STAR_ON : STAR_OFF;
489
490        // Date.
491        mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext,
492                mHeader.conversation.dateMs).toString();
493
494        // Paper clip icon.
495        mHeader.paperclip = null;
496        if (mHeader.conversation.hasAttachments) {
497            mHeader.paperclip = ATTACHMENT;
498        }
499        // Personal level.
500        mHeader.personalLevelBitmap = null;
501        if (mCoordinates.showPersonalLevel) {
502            int personalLevel = mHeader.personalLevel;
503            final boolean isImportant =
504                    mHeader.priority == UIProvider.ConversationPriority.IMPORTANT;
505            // TODO(mindyp): get whether importance indicators are enabled
506            // mPriorityMarkersEnabled =
507            // persistence.getPriorityInboxArrowsEnabled(mContext, mAccount);
508            mPriorityMarkersEnabled = true;
509            boolean useImportantMarkers = isImportant && mPriorityMarkersEnabled;
510
511            if (personalLevel == UIProvider.ConversationPersonalLevel.ONLY_TO_ME) {
512                mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_ONLY_TO_ME
513                        : ONLY_TO_ME;
514            } else if (personalLevel == UIProvider.ConversationPersonalLevel.TO_ME_AND_OTHERS) {
515                mHeader.personalLevelBitmap = useImportantMarkers ? IMPORTANT_TO_ME_AND_OTHERS
516                        : TO_ME_AND_OTHERS;
517            } else if (useImportantMarkers) {
518                mHeader.personalLevelBitmap = IMPORTANT_TO_OTHERS;
519            }
520        }
521
522        startTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
523
524        // Subject.
525        createSubjectSpans(isUnread);
526
527        // Parse senders fragments.
528        parseSendersFragments(isUnread);
529
530        pauseTimer(PERF_TAG_CALCULATE_SENDER_SUBJECT);
531        pauseTimer(PERF_TAG_CALCULATE_TEXTS_BITMAPS);
532    }
533
534    private void createSubjectSpans(boolean isUnread) {
535        final String subject = filterTag(mHeader.conversation.subject);
536        final String snippet = mHeader.conversation.snippet;
537        int subjectColor = isUnread ? SUBJECT_TEXT_COLOR_UNREAD : SUBJECT_TEXT_COLOR_READ;
538        int snippetColor = isUnread ? SNIPPET_TEXT_COLOR_UNREAD : SNIPPET_TEXT_COLOR_READ;
539        mHeader.subjectText = new SpannableStringBuilder(mContext.getString(
540                R.string.subject_and_snippet, subject, snippet));
541        if (isUnread) {
542            mHeader.subjectText.setSpan(new StyleSpan(Typeface.BOLD), 0, subject.length(),
543                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
544        }
545        int fontColor = getFontColor(subjectColor);
546        mHeader.subjectText.setSpan(new ForegroundColorSpan(fontColor), 0,
547                subject.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
548        fontColor = getFontColor(snippetColor);
549        mHeader.subjectText.setSpan(new ForegroundColorSpan(fontColor), subject.length() + 1,
550                mHeader.subjectText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
551    }
552
553    private int getFontColor(int defaultColor) {
554        return isActivated() && mTabletDevice ? ACTIVATED_TEXT_COLOR
555                : defaultColor;
556    }
557
558    private void layoutSubject() {
559        sPaint.setTextSize(mCoordinates.subjectFontSize);
560        sPaint.setColor(mHeader.fontColor);
561        mHeader.subjectLayout = new StaticLayout(mHeader.subjectText, sPaint,
562                mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
563        if (mCoordinates.subjectLineCount < mHeader.subjectLayout.getLineCount()) {
564            int end = mHeader.subjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
565            mHeader.subjectLayout = new StaticLayout(mHeader.subjectText.subSequence(0, end),
566                    sPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
567        }
568    }
569
570    /**
571     * Parses senders text into small fragments.
572     */
573    private void parseSendersFragments(boolean isUnread) {
574        if (TextUtils.isEmpty(mHeader.conversation.senders)) {
575            return;
576        }
577        mHeader.sendersText = formatSenders(mHeader.conversation.senders);
578        mHeader.addSenderFragment(0, mHeader.sendersText.length(), sNormalTextStyle, true);
579    }
580
581    private String formatSenders(String sendersString) {
582        String[] senders = TextUtils.split(sendersString, Address.ADDRESS_DELIMETER);
583        String[] namesOnly = new String[senders.length];
584        Rfc822Token[] senderTokens;
585        String display;
586        for (int i = 0; i < senders.length; i++) {
587            senderTokens = Rfc822Tokenizer.tokenize(senders[i]);
588            if (senderTokens != null && senderTokens.length > 0) {
589                display = senderTokens[0].getName();
590                if (TextUtils.isEmpty(display)) {
591                    display = senderTokens[0].getAddress();
592                }
593                namesOnly[i] = display;
594            }
595        }
596        return TextUtils.join(Address.ADDRESS_DELIMETER + " ", namesOnly);
597    }
598
599    private boolean canFitFragment(int width, int line, int fixedWidth) {
600        if (line == mCoordinates.sendersLineCount) {
601            return width + fixedWidth <= mSendersWidth;
602        } else {
603            return width <= mSendersWidth;
604        }
605    }
606
607    private void calculateCoordinates() {
608        startTimer(PERF_TAG_CALCULATE_COORDINATES);
609
610        sPaint.setTextSize(mCoordinates.dateFontSize);
611        sPaint.setTypeface(Typeface.DEFAULT);
612        mDateX = mCoordinates.dateXEnd - (int) sPaint.measureText(mHeader.dateText);
613
614        mPaperclipX = mDateX - ATTACHMENT.getWidth();
615
616        int cellWidth = mContext.getResources().getDimensionPixelSize(R.dimen.folder_cell_width);
617
618        if (ConversationItemViewCoordinates.isWideMode(mMode)) {
619            // Folders are displayed above the date.
620            mFoldersXEnd = mCoordinates.dateXEnd;
621            // In wide mode, the end of the senders should align with
622            // the start of the subject and is based on a max width.
623            mSendersWidth = mCoordinates.sendersWidth;
624        } else {
625            // In normal mode, the width is based on where the folders or date
626            // (or attachment icon) start.
627            if (mCoordinates.showFolders) {
628                if (mHeader.paperclip != null) {
629                    mFoldersXEnd = mPaperclipX;
630                } else {
631                    mFoldersXEnd = mDateX - cellWidth / 2;
632                }
633                mSendersWidth = mFoldersXEnd - mCoordinates.sendersX - 2 * cellWidth;
634                if (mHeader.folderDisplayer.hasVisibleFolders()) {
635                    mSendersWidth -= ConversationItemViewCoordinates.getFoldersWidth(mContext,
636                            mMode);
637                }
638            } else {
639                int dateAttachmentStart = 0;
640                // Have this end near the paperclip or date, not the folders.
641                if (mHeader.paperclip != null) {
642                    dateAttachmentStart = mPaperclipX;
643                } else {
644                    dateAttachmentStart = mDateX;
645                }
646                mSendersWidth = dateAttachmentStart - mCoordinates.sendersX - cellWidth;
647            }
648        }
649
650        if (mHeader.isLayoutValid(mContext)) {
651            pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
652            return;
653        }
654
655        // Layout subject.
656        layoutSubject();
657
658        // First pass to calculate width of each fragment.
659        int totalWidth = 0;
660        int fixedWidth = 0;
661        sPaint.setTextSize(mCoordinates.sendersFontSize);
662        sPaint.setTypeface(Typeface.DEFAULT);
663        for (SenderFragment senderFragment : mHeader.senderFragments) {
664            CharacterStyle style = senderFragment.style;
665            int start = senderFragment.start;
666            int end = senderFragment.end;
667            style.updateDrawState(sPaint);
668            senderFragment.width = (int) sPaint.measureText(mHeader.sendersText, start, end);
669            boolean isFixed = senderFragment.isFixed;
670            if (isFixed) {
671                fixedWidth += senderFragment.width;
672            }
673            totalWidth += senderFragment.width;
674        }
675
676        // Second pass to layout each fragment.
677        int sendersY = mCoordinates.sendersY - mCoordinates.sendersAscent;
678        if (!ConversationItemViewCoordinates.displaySendersInline(mMode)) {
679            sendersY += totalWidth <= mSendersWidth ? mCoordinates.sendersLineHeight / 2 : 0;
680        }
681        totalWidth = 0;
682        int currentLine = 1;
683        boolean ellipsize = false;
684        for (SenderFragment senderFragment : mHeader.senderFragments) {
685            CharacterStyle style = senderFragment.style;
686            int start = senderFragment.start;
687            int end = senderFragment.end;
688            int width = senderFragment.width;
689            boolean isFixed = senderFragment.isFixed;
690            style.updateDrawState(sPaint);
691
692            // No more width available, we'll only show fixed fragments.
693            if (ellipsize && !isFixed) {
694                senderFragment.shouldDisplay = false;
695                continue;
696            }
697
698            // New line and ellipsize text if needed.
699            senderFragment.ellipsizedText = null;
700            if (isFixed) {
701                fixedWidth -= width;
702            }
703            if (!canFitFragment(totalWidth + width, currentLine, fixedWidth)) {
704                // The text is too long, new line won't help. We have to
705                // ellipsize text.
706                if (totalWidth == 0) {
707                    ellipsize = true;
708                } else {
709                    // New line.
710                    if (currentLine < mCoordinates.sendersLineCount) {
711                        currentLine++;
712                        sendersY += mCoordinates.sendersLineHeight;
713                        totalWidth = 0;
714                        // The text is still too long, we have to ellipsize
715                        // text.
716                        if (totalWidth + width > mSendersWidth) {
717                            ellipsize = true;
718                        }
719                    } else {
720                        ellipsize = true;
721                    }
722                }
723
724                if (ellipsize) {
725                    width = mSendersWidth - totalWidth;
726                    // No more new line, we have to reserve width for fixed
727                    // fragments.
728                    if (currentLine == mCoordinates.sendersLineCount) {
729                        width -= fixedWidth;
730                    }
731                    senderFragment.ellipsizedText = TextUtils.ellipsize(
732                            mHeader.sendersText.substring(start, end), sPaint, width,
733                            TruncateAt.END).toString();
734                    width = (int) sPaint.measureText(senderFragment.ellipsizedText);
735                }
736            }
737            senderFragment.x = mCoordinates.sendersX + totalWidth;
738            senderFragment.y = sendersY;
739            senderFragment.shouldDisplay = true;
740            totalWidth += width;
741        }
742
743        pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
744    }
745
746    /**
747     * If the subject contains the tag of a mailing-list (text surrounded with
748     * []), return the subject with that tag ellipsized, e.g.
749     * "[android-gmail-team] Hello" -> "[andr...] Hello"
750     */
751    private String filterTag(String subject) {
752        String result = subject;
753        String formatString = getContext().getResources().getString(R.string.filtered_tag);
754        if (!TextUtils.isEmpty(subject) && subject.charAt(0) == '[') {
755            int end = subject.indexOf(']');
756            if (end > 0) {
757                String tag = subject.substring(1, end);
758                result = String.format(formatString, Utils.ellipsize(tag, 7),
759                        subject.substring(end + 1));
760            }
761        }
762        return result;
763    }
764
765    @Override
766    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
767        if (mAnimatedHeight == -1) {
768            int width = measureWidth(widthMeasureSpec);
769            int height = measureHeight(heightMeasureSpec,
770                    ConversationItemViewCoordinates.getMode(mContext, mViewMode));
771            setMeasuredDimension(width, height);
772        } else {
773            setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight);
774        }
775    }
776
777    /**
778     * Determine the width of this view.
779     *
780     * @param measureSpec A measureSpec packed into an int
781     * @return The width of the view, honoring constraints from measureSpec
782     */
783    private int measureWidth(int measureSpec) {
784        int result = 0;
785        int specMode = MeasureSpec.getMode(measureSpec);
786        int specSize = MeasureSpec.getSize(measureSpec);
787
788        if (specMode == MeasureSpec.EXACTLY) {
789            // We were told how big to be
790            result = specSize;
791        } else {
792            // Measure the text
793            result = mViewWidth;
794            if (specMode == MeasureSpec.AT_MOST) {
795                // Respect AT_MOST value if that was what is called for by
796                // measureSpec
797                result = Math.min(result, specSize);
798            }
799        }
800        return result;
801    }
802
803    /**
804     * Determine the height of this view.
805     *
806     * @param measureSpec A measureSpec packed into an int
807     * @param mode The current mode of this view
808     * @return The height of the view, honoring constraints from measureSpec
809     */
810    private int measureHeight(int measureSpec, int mode) {
811        int result = 0;
812        int specMode = MeasureSpec.getMode(measureSpec);
813        int specSize = MeasureSpec.getSize(measureSpec);
814
815        if (specMode == MeasureSpec.EXACTLY) {
816            // We were told how big to be
817            result = specSize;
818        } else {
819            // Measure the text
820            result = ConversationItemViewCoordinates.getHeight(mContext, mode);
821            if (specMode == MeasureSpec.AT_MOST) {
822                // Respect AT_MOST value if that was what is called for by
823                // measureSpec
824                result = Math.min(result, specSize);
825            }
826        }
827        return result;
828    }
829
830    @Override
831    protected void onDraw(Canvas canvas) {
832        // Check mark.
833        if (mHeader.checkboxVisible) {
834            Bitmap checkmark = mChecked ? CHECKMARK_ON : CHECKMARK_OFF;
835            canvas.drawBitmap(checkmark, mCoordinates.checkmarkX, mCoordinates.checkmarkY, sPaint);
836        }
837
838        // Personal Level.
839        if (mCoordinates.showPersonalLevel && mHeader.personalLevelBitmap != null) {
840            canvas.drawBitmap(mHeader.personalLevelBitmap, mCoordinates.personalLevelX,
841                    mCoordinates.personalLevelY, sPaint);
842        }
843
844        // Senders.
845        boolean isUnread = mHeader.unread;
846        sPaint.setTextSize(mCoordinates.sendersFontSize);
847        sPaint.setTypeface(isUnread ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT);
848        int sendersColor = getFontColor(isUnread ? SENDERS_TEXT_COLOR_UNREAD
849                : SENDERS_TEXT_COLOR_READ);
850        sPaint.setColor(sendersColor);
851        for (SenderFragment fragment : mHeader.senderFragments) {
852            if (fragment.shouldDisplay) {
853                fragment.style.updateDrawState(sPaint);
854                if (fragment.ellipsizedText != null) {
855                    canvas.drawText(fragment.ellipsizedText, fragment.x, fragment.y, sPaint);
856                } else {
857                    canvas.drawText(mHeader.sendersText, fragment.start, fragment.end, fragment.x,
858                            fragment.y, sPaint);
859                }
860            }
861        }
862
863        // Subject.
864        sPaint.setTextSize(mCoordinates.subjectFontSize);
865        sPaint.setTypeface(Typeface.DEFAULT);
866        sPaint.setColor(mHeader.fontColor);
867        canvas.save();
868        canvas.translate(mCoordinates.subjectX,
869                mCoordinates.subjectY + mHeader.subjectLayout.getTopPadding());
870        mHeader.subjectLayout.draw(canvas);
871        canvas.restore();
872
873        // Folders.
874        if (mCoordinates.showFolders) {
875            mHeader.folderDisplayer.drawFolders(canvas, mCoordinates, mFoldersXEnd, mMode);
876        }
877
878        // Date background: shown when there is an attachment or a visible
879        // folder.
880        if (!isActivated()
881                && mHeader.conversation.hasAttachments
882                && ConversationItemViewCoordinates.showAttachmentBackground(mMode)) {
883            mHeader.dateBackground = DATE_BACKGROUND;
884            int leftOffset = (mHeader.conversation.hasAttachments ? mPaperclipX : mDateX)
885                    - DATE_BACKGROUND_PADDING_LEFT;
886            int top = mCoordinates.showFolders ? mCoordinates.foldersY : mCoordinates.dateY;
887            Rect src = new Rect(0, 0, mHeader.dateBackground.getWidth(), mHeader.dateBackground
888                    .getHeight());
889            Rect dst = new Rect(leftOffset, top, mViewWidth, top + sDateBackgroundHeight);
890            canvas.drawBitmap(mHeader.dateBackground, src, dst, sPaint);
891        } else {
892            mHeader.dateBackground = null;
893        }
894
895        // Draw the reply state. Draw nothing if neither replied nor forwarded.
896        if (mCoordinates.showReplyState) {
897            if (mHeader.hasBeenRepliedTo && mHeader.hasBeenForwarded) {
898                canvas.drawBitmap(STATE_REPLIED_AND_FORWARDED, mCoordinates.replyStateX,
899                        mCoordinates.replyStateY, null);
900            } else if (mHeader.hasBeenRepliedTo) {
901                canvas.drawBitmap(STATE_REPLIED, mCoordinates.replyStateX,
902                        mCoordinates.replyStateY, null);
903            } else if (mHeader.hasBeenForwarded) {
904                canvas.drawBitmap(STATE_FORWARDED, mCoordinates.replyStateX,
905                        mCoordinates.replyStateY, null);
906            } else if (mHeader.isInvite) {
907                canvas.drawBitmap(STATE_CALENDAR_INVITE, mCoordinates.replyStateX,
908                        mCoordinates.replyStateY, null);
909            }
910        }
911
912        // Date.
913        sPaint.setTextSize(mCoordinates.dateFontSize);
914        sPaint.setTypeface(Typeface.DEFAULT);
915        sPaint.setColor(isUnread ? DATE_TEXT_COLOR_UNREAD : DATE_TEXT_COLOR_READ);
916        drawText(canvas, mHeader.dateText, mDateX, mCoordinates.dateY - mCoordinates.dateAscent,
917                sPaint);
918
919        // Paper clip icon.
920        if (mHeader.paperclip != null) {
921            canvas.drawBitmap(mHeader.paperclip, mPaperclipX, mCoordinates.paperclipY, sPaint);
922        }
923
924        if (mHeader.faded) {
925            int fadedColor = -1;
926            if (sFadedActivatedColor == -1) {
927                sFadedActivatedColor = mContext.getResources().getColor(
928                        R.color.faded_activated_conversation_header);
929            }
930            fadedColor = sFadedActivatedColor;
931            int restoreState = canvas.save();
932            Rect bounds = canvas.getClipBounds();
933            canvas.clipRect(bounds.left, bounds.top, bounds.right
934                    - mContext.getResources().getDimensionPixelSize(R.dimen.triangle_width),
935                    bounds.bottom);
936            canvas.drawARGB(Color.alpha(fadedColor), Color.red(fadedColor),
937                    Color.green(fadedColor), Color.blue(fadedColor));
938            canvas.restoreToCount(restoreState);
939        }
940
941        // Star.
942        canvas.drawBitmap(mHeader.starBitmap, mCoordinates.starX, mCoordinates.starY, sPaint);
943    }
944
945    private void drawText(Canvas canvas, CharSequence s, int x, int y, TextPaint paint) {
946        canvas.drawText(s, 0, s.length(), x, y, paint);
947    }
948
949    private void updateBackground(boolean isUnread) {
950        if (isUnread) {
951            if (mTabletDevice && mViewMode.getMode() == ViewMode.CONVERSATION_LIST) {
952                if (mChecked) {
953                    setBackgroundResource(R.drawable.list_conversation_wide_unread_selected_holo);
954                } else {
955                    setBackgroundResource(R.drawable.conversation_wide_unread_selector);
956                }
957            } else {
958                if (mChecked) {
959                    setCheckedActivatedBackground();
960                } else {
961                    setBackgroundResource(R.drawable.conversation_unread_selector);
962                }
963            }
964        } else {
965            if (mTabletDevice && mViewMode.getMode() == ViewMode.CONVERSATION_LIST) {
966                if (mChecked) {
967                    setBackgroundResource(R.drawable.list_conversation_wide_read_selected_holo);
968                } else {
969                    setBackgroundResource(R.drawable.conversation_wide_read_selector);
970                }
971            } else {
972                if (mChecked) {
973                    setCheckedActivatedBackground();
974                } else {
975                    setBackgroundResource(R.drawable.conversation_read_selector);
976                }
977            }
978        }
979    }
980
981    private void setCheckedActivatedBackground() {
982        if (isActivated() && mTabletDevice) {
983            setBackgroundResource(R.drawable.list_arrow_selected_holo);
984        } else {
985            setBackgroundResource(R.drawable.list_selected_holo);
986        }
987    }
988
989    /**
990     * Toggle the check mark on this view and update the conversation
991     */
992    public void toggleCheckMark() {
993        mChecked = !mChecked;
994        Conversation conv = mHeader.conversation;
995        // Set the list position of this item in the conversation
996        conv.position = mChecked ? ((ListView)getParent()).getPositionForView(this)
997                : Conversation.NO_POSITION;
998        if (mSelectedConversationSet != null) {
999            mSelectedConversationSet.toggle(this, conv);
1000        }
1001        // We update the background after the checked state has changed now that
1002        // we have a selected background asset. Setting the background usually
1003        // waits for a layout pass, but we don't need a full layout, just an
1004        // update to the background.
1005        requestLayout();
1006    }
1007
1008    /**
1009     * Return if the checkbox for this item is checked.
1010     */
1011    public boolean isChecked() {
1012        return mChecked;
1013    }
1014
1015    /**
1016     * Toggle the star on this view and update the conversation.
1017     */
1018    public void toggleStar() {
1019        mHeader.starred = !mHeader.starred;
1020        mHeader.starBitmap = mHeader.starred ? STAR_ON : STAR_OFF;
1021        postInvalidate(mCoordinates.starX, mCoordinates.starY, mCoordinates.starX
1022                + mHeader.starBitmap.getWidth(),
1023                mCoordinates.starY + mHeader.starBitmap.getHeight());
1024        // Generalize this...
1025        mHeader.conversation.updateBoolean(mContext, ConversationColumns.STARRED, mHeader.starred);
1026    }
1027
1028    private boolean isTouchInCheckmark(float x, float y) {
1029        // Everything before senders and include a touch slop.
1030        return mHeader.checkboxVisible && x < mCoordinates.sendersX + TOUCH_SLOP;
1031    }
1032
1033    private boolean isTouchInStar(float x, float y) {
1034        // Everything after the star and include a touch slop.
1035        return x > mCoordinates.starX - TOUCH_SLOP;
1036    }
1037
1038    /**
1039     * ConversationItemView is given the first chance to handle touch events.
1040     */
1041    @Override
1042    public boolean onTouchEvent(MotionEvent event) {
1043        boolean handled = true;
1044
1045        int x = (int) event.getX();
1046        int y = (int) event.getY();
1047        switch (event.getAction()) {
1048            case MotionEvent.ACTION_DOWN:
1049                mDownEvent = true;
1050                // In order to allow the down event and subsequent move events
1051                // to bubble to the swipe handler, we need to return that all
1052                // down events are handled.
1053                handled = true;
1054                break;
1055            case MotionEvent.ACTION_CANCEL:
1056                mDownEvent = false;
1057                break;
1058            case MotionEvent.ACTION_UP:
1059                if (mDownEvent) {
1060                    // ConversationItemView gets the first chance to handle up
1061                    // events if there was a down event and there was no move
1062                    // event in between. In this case, ConversationItemView
1063                    // received the down event, and then an up event in the
1064                    // same location (+/- slop). Treat this as a click on the
1065                    // view or on a specific part of the view.
1066                    if (isTouchInCheckmark(x, y)) {
1067                        // Touch on the check mark
1068                        toggleCheckMark();
1069                    } else if (isTouchInStar(x, y)) {
1070                        // Touch on the star
1071                        toggleStar();
1072                    } else {
1073                        ListView list = (ListView)getParent();
1074                        int pos = list.getPositionForView(this);
1075                        list.performItemClick(this, pos, mHeader.conversation.id);
1076                    }
1077                    handled = true;
1078                } else {
1079                    // There was no down event that this view was made aware of,
1080                    // therefore it cannot handle it.
1081                    handled = false;
1082                }
1083                break;
1084        }
1085
1086        if (!handled) {
1087            // Let View try to handle it as well.
1088            handled = super.onTouchEvent(event);
1089        }
1090
1091        return handled;
1092    }
1093
1094    /**
1095     * Grow the height of the item and fade it in when bringing a conversation
1096     * back from a destructive action.
1097     *
1098     * @param listener
1099     */
1100    public void startUndoAnimation(final AnimatorListener listener) {
1101        setMinimumHeight(140);
1102        final int start = 0 ;
1103        final int end = 140;
1104        ObjectAnimator undoAnimator = ObjectAnimator.ofInt(this, "animatedHeight", start, end);
1105        Animator fadeAnimator = ObjectAnimator.ofFloat(this, "itemAlpha", 0, 1.0f);
1106        mAnimatedHeight = start;
1107        undoAnimator.setInterpolator(new DecelerateInterpolator(2.0f));
1108        undoAnimator.setDuration(sUndoAnimationDuration);
1109        AnimatorSet transitionSet = new AnimatorSet();
1110        transitionSet.playTogether(undoAnimator, fadeAnimator);
1111        transitionSet.addListener(listener);
1112        transitionSet.start();
1113    }
1114
1115    // Used by animator
1116    @SuppressWarnings("unused")
1117    public void setItemAlpha(float alpha) {
1118        setAlpha(alpha);
1119        invalidate();
1120    }
1121
1122    // Used by animator
1123    @SuppressWarnings("unused")
1124    public void setAnimatedHeight(int height) {
1125        mAnimatedHeight = height;
1126        requestLayout();
1127    }
1128
1129    /**
1130     * Get the current position of this conversation item in the list.
1131     */
1132    public int getPosition() {
1133        return mHeader != null && mHeader.conversation != null ?
1134                mHeader.conversation.position : -1;
1135    }
1136}
1137