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