MessageListItem.java revision d6decef1d2a8d14aa8a65229bc784e6fdbb31864
1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.activity;
18
19import android.content.Context;
20import android.content.res.Configuration;
21import android.content.res.Resources;
22import android.graphics.Bitmap;
23import android.graphics.BitmapFactory;
24import android.graphics.Canvas;
25import android.graphics.Paint;
26import android.graphics.Typeface;
27import android.graphics.drawable.Drawable;
28import android.text.Layout.Alignment;
29import android.text.Spannable;
30import android.text.SpannableString;
31import android.text.SpannableStringBuilder;
32import android.text.StaticLayout;
33import android.text.TextPaint;
34import android.text.TextUtils;
35import android.text.TextUtils.TruncateAt;
36import android.text.format.DateUtils;
37import android.text.style.ForegroundColorSpan;
38import android.text.style.StyleSpan;
39import android.util.AttributeSet;
40import android.view.MotionEvent;
41import android.view.View;
42import android.view.accessibility.AccessibilityEvent;
43
44import com.android.email.R;
45import com.android.emailcommon.utility.TextUtilities;
46import com.google.common.base.Objects;
47
48/**
49 * This custom View is the list item for the MessageList activity, and serves two purposes:
50 * 1.  It's a container to store message metadata (e.g. the ids of the message, mailbox, & account)
51 * 2.  It handles internal clicks such as the checkbox or the favorite star
52 */
53public class MessageListItem extends View {
54    // Note: messagesAdapter directly fiddles with these fields.
55    /* package */ long mMessageId;
56    /* package */ long mMailboxId;
57    /* package */ long mAccountId;
58
59    private ThreePaneLayout mLayout;
60    private MessagesAdapter mAdapter;
61    private MessageListItemCoordinates mCoordinates;
62    private Context mContext;
63    private boolean mIsSearchResult = false;
64
65    private boolean mDownEvent;
66
67    public static final String MESSAGE_LIST_ITEMS_CLIP_LABEL =
68        "com.android.email.MESSAGE_LIST_ITEMS";
69
70    public MessageListItem(Context context) {
71        super(context);
72        init(context);
73    }
74
75    public MessageListItem(Context context, AttributeSet attrs) {
76        super(context, attrs);
77        init(context);
78    }
79
80    public MessageListItem(Context context, AttributeSet attrs, int defStyle) {
81        super(context, attrs, defStyle);
82        init(context);
83    }
84
85    // Wide mode shows sender, snippet, time, and favorite spread out across the screen
86    private static final int MODE_WIDE = MessageListItemCoordinates.WIDE_MODE;
87    // Sentinel indicating that the view needs layout
88    public static final int NEEDS_LAYOUT = -1;
89
90    private static boolean sInit = false;
91    private static final TextPaint sDefaultPaint = new TextPaint();
92    private static final TextPaint sBoldPaint = new TextPaint();
93    private static final TextPaint sDatePaint = new TextPaint();
94    private static Bitmap sAttachmentIcon;
95    private static Bitmap sInviteIcon;
96    private static int sBadgeMargin;
97    private static Bitmap sFavoriteIconOff;
98    private static Bitmap sFavoriteIconOn;
99    private static Bitmap sSelectedIconOn;
100    private static Bitmap sSelectedIconOff;
101    private static Bitmap sStateReplied;
102    private static Bitmap sStateForwarded;
103    private static Bitmap sStateRepliedAndForwarded;
104    private static String sSubjectSnippetDivider;
105    private static String sSubjectDescription;
106    private static String sSubjectEmptyDescription;
107
108    // Static colors.
109    private static int DEFAULT_TEXT_COLOR;
110    private static int ACTIVATED_TEXT_COLOR;
111    private static int LIGHT_TEXT_COLOR;
112    private static int DRAFT_TEXT_COLOR;
113    private static int SUBJECT_TEXT_COLOR_READ;
114    private static int SUBJECT_TEXT_COLOR_UNREAD;
115    private static int SNIPPET_TEXT_COLOR_READ;
116    private static int SNIPPET_TEXT_COLOR_UNREAD;
117    private static int SENDERS_TEXT_COLOR_READ;
118    private static int SENDERS_TEXT_COLOR_UNREAD;
119    private static int DATE_TEXT_COLOR_READ;
120    private static int DATE_TEXT_COLOR_UNREAD;
121
122    public String mSender;
123    public SpannableStringBuilder mText;
124    public CharSequence mSnippet;
125    private String mSubject;
126    private StaticLayout mSubjectLayout;
127    public boolean mRead;
128    public boolean mHasAttachment = false;
129    public boolean mHasInvite = true;
130    public boolean mIsFavorite = false;
131    public boolean mHasBeenRepliedTo = false;
132    public boolean mHasBeenForwarded = false;
133    /** {@link Paint} for account color chips.  null if no chips should be drawn.  */
134    public Paint mColorChipPaint;
135
136    private int mMode = -1;
137
138    private int mViewWidth = 0;
139    private int mViewHeight = 0;
140
141    private static int sItemHeightWide;
142    private static int sItemHeightNormal;
143
144    // Note: these cannot be shared Drawables because they are selectors which have state.
145    private Drawable mReadSelector;
146    private Drawable mUnreadSelector;
147    private Drawable mWideReadSelector;
148    private Drawable mWideUnreadSelector;
149
150    private CharSequence mFormattedSender;
151    // We must initialize this to something, in case the timestamp of the message is zero (which
152    // should be very rare); this is otherwise set in setTimestamp
153    private CharSequence mFormattedDate = "";
154
155    private void init(Context context) {
156        mContext = context;
157        if (!sInit) {
158            Resources r = context.getResources();
159            sSubjectDescription = r.getString(R.string.message_subject_description).concat(", ");
160            sSubjectEmptyDescription = r.getString(R.string.message_is_empty_description);
161            sSubjectSnippetDivider = r.getString(R.string.message_list_subject_snippet_divider);
162            sItemHeightWide =
163                r.getDimensionPixelSize(R.dimen.message_list_item_height_wide);
164            sItemHeightNormal =
165                r.getDimensionPixelSize(R.dimen.message_list_item_height_normal);
166
167            sDefaultPaint.setTypeface(Typeface.DEFAULT);
168            sDefaultPaint.setAntiAlias(true);
169            sDatePaint.setTypeface(Typeface.DEFAULT);
170            sDatePaint.setAntiAlias(true);
171            sBoldPaint.setTypeface(Typeface.DEFAULT_BOLD);
172            sBoldPaint.setAntiAlias(true);
173
174            sAttachmentIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_attachment);
175            sInviteIcon = BitmapFactory.decodeResource(r, R.drawable.ic_badge_invite_holo_light);
176            sBadgeMargin = r.getDimensionPixelSize(R.dimen.message_list_badge_margin);
177            sFavoriteIconOff =
178                BitmapFactory.decodeResource(r, R.drawable.btn_star_off_normal_email_holo_light);
179            sFavoriteIconOn =
180                BitmapFactory.decodeResource(r, R.drawable.btn_star_on_normal_email_holo_light);
181            sSelectedIconOff =
182                BitmapFactory.decodeResource(r, R.drawable.btn_check_off_normal_holo_light);
183            sSelectedIconOn =
184                BitmapFactory.decodeResource(r, R.drawable.btn_check_on_normal_holo_light);
185
186            sStateReplied =
187                BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_holo_light);
188            sStateForwarded =
189                BitmapFactory.decodeResource(r, R.drawable.ic_badge_forward_holo_light);
190            sStateRepliedAndForwarded =
191                BitmapFactory.decodeResource(r, R.drawable.ic_badge_reply_forward_holo_light);
192
193            DEFAULT_TEXT_COLOR = r.getColor(R.color.default_text_color);
194            ACTIVATED_TEXT_COLOR = r.getColor(android.R.color.white);
195            SUBJECT_TEXT_COLOR_READ = r.getColor(R.color.subject_text_color_read);
196            SUBJECT_TEXT_COLOR_UNREAD = r.getColor(R.color.subject_text_color_unread);
197            SNIPPET_TEXT_COLOR_READ = r.getColor(R.color.snippet_text_color_read);
198            SNIPPET_TEXT_COLOR_UNREAD = r.getColor(R.color.snippet_text_color_unread);
199            SENDERS_TEXT_COLOR_READ = r.getColor(R.color.senders_text_color_read);
200            SENDERS_TEXT_COLOR_UNREAD = r.getColor(R.color.senders_text_color_unread);
201            DATE_TEXT_COLOR_READ = r.getColor(R.color.date_text_color_read);
202            DATE_TEXT_COLOR_UNREAD = r.getColor(R.color.date_text_color_unread);
203
204            sInit = true;
205        }
206    }
207
208    /**
209     * Invalidate all drawing caches associated with drawing message list items.
210     * This is an expensive operation, and should be done rarely, such as when system font size
211     * changes occurs.
212     */
213    public static void resetDrawingCaches() {
214        MessageListItemCoordinates.resetCaches();
215        sInit = false;
216    }
217
218    /**
219     * Sets message subject and snippet safely, ensuring the cache is invalidated.
220     */
221    public void setText(String subject, String snippet, boolean forceUpdate) {
222        boolean changed = false;
223        if (!Objects.equal(mSubject, subject)) {
224            mSubject = subject;
225            changed = true;
226            populateContentDescription();
227        }
228
229        if (!Objects.equal(mSnippet, snippet)) {
230            mSnippet = snippet;
231            changed = true;
232        }
233
234        if (forceUpdate || changed || (mSubject == null && mSnippet == null) /* first time */) {
235            SpannableStringBuilder ssb = new SpannableStringBuilder();
236            boolean hasSubject = false;
237            if (!TextUtils.isEmpty(mSubject)) {
238                SpannableString ss = new SpannableString(mSubject);
239                ss.setSpan(new StyleSpan(mRead ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
240                        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
241                ssb.append(ss);
242                hasSubject = true;
243            }
244            if (!TextUtils.isEmpty(mSnippet)) {
245                if (hasSubject) {
246                    ssb.append(sSubjectSnippetDivider);
247                }
248                ssb.append(mSnippet);
249            }
250            mText = ssb;
251            requestLayout();
252        }
253    }
254
255    long mTimeFormatted = 0;
256
257    public void setTimestamp(long timestamp) {
258        if (mTimeFormatted != timestamp) {
259            mFormattedDate = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
260            mTimeFormatted = timestamp;
261        }
262    }
263
264    /**
265     * Determine the mode of this view (WIDE or NORMAL)
266     *
267     * @param width The width of the view
268     * @return The mode of the view
269     */
270    private int getViewMode(int width) {
271        return MessageListItemCoordinates.getMode(mContext, width, mIsSearchResult);
272    }
273
274    private Drawable mCurentBackground = null; // Only used by updateBackground()
275
276    private void updateBackground() {
277        final Drawable newBackground;
278        boolean isMultiPane = MessageListItemCoordinates.isMultiPane(mContext);
279        if (mRead) {
280            if (isMultiPane && mLayout.isLeftPaneVisible()) {
281                if (mWideReadSelector == null) {
282                    mWideReadSelector = getContext().getResources()
283                            .getDrawable(R.drawable.conversation_wide_read_selector);
284                }
285                newBackground = mWideReadSelector;
286            } else {
287                if (mReadSelector == null) {
288                    mReadSelector = getContext().getResources()
289                            .getDrawable(R.drawable.conversation_read_selector);
290                }
291                newBackground = mReadSelector;
292            }
293        } else {
294            if (isMultiPane && mLayout.isLeftPaneVisible()) {
295                if (mWideUnreadSelector == null) {
296                    mWideUnreadSelector = getContext().getResources().getDrawable(
297                            R.drawable.conversation_wide_unread_selector);
298                }
299                newBackground = mWideUnreadSelector;
300            } else {
301                if (mUnreadSelector == null) {
302                    mUnreadSelector = getContext().getResources()
303                            .getDrawable(R.drawable.conversation_unread_selector);
304                }
305                newBackground = mUnreadSelector;
306            }
307        }
308        if (newBackground != mCurentBackground) {
309            // setBackgroundDrawable is a heavy operation.  Only call it when really needed.
310            setBackgroundDrawable(newBackground);
311            mCurentBackground = newBackground;
312        }
313    }
314
315    private void calculateSubjectText() {
316        if (mText == null || mText.length() == 0) {
317            return;
318        }
319        boolean hasSubject = false;
320        int snippetStart = 0;
321        if (!TextUtils.isEmpty(mSubject)) {
322            int subjectColor = getFontColor(mRead ? SUBJECT_TEXT_COLOR_READ
323                    : SUBJECT_TEXT_COLOR_UNREAD);
324            mText.setSpan(new ForegroundColorSpan(subjectColor), 0, mSubject.length(),
325                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
326            snippetStart = mSubject.length() + 1;
327        }
328        if (!TextUtils.isEmpty(mSnippet)) {
329            int snippetColor = getFontColor(mRead ? SNIPPET_TEXT_COLOR_READ
330                    : SNIPPET_TEXT_COLOR_UNREAD);
331            mText.setSpan(new ForegroundColorSpan(snippetColor), snippetStart, mText.length(),
332                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
333        }
334    }
335
336    private void calculateDrawingData() {
337        sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
338        calculateSubjectText();
339        mSubjectLayout = new StaticLayout(mText, sDefaultPaint,
340                mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, false /* includePad */);
341        if (mCoordinates.subjectLineCount < mSubjectLayout.getLineCount()) {
342            // TODO: ellipsize.
343            int end = mSubjectLayout.getLineEnd(mCoordinates.subjectLineCount - 1);
344            mSubjectLayout = new StaticLayout(mText.subSequence(0, end),
345                    sDefaultPaint, mCoordinates.subjectWidth, Alignment.ALIGN_NORMAL, 1, 0, true);
346        }
347
348        // Now, format the sender for its width
349        TextPaint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
350        // And get the ellipsized string for the calculated width
351        if (TextUtils.isEmpty(mSender)) {
352            mFormattedSender = "";
353        } else {
354            int senderWidth = mCoordinates.sendersWidth;
355            senderPaint.setTextSize(mCoordinates.sendersFontSize);
356            senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ
357                    : SENDERS_TEXT_COLOR_UNREAD));
358            mFormattedSender = TextUtils.ellipsize(mSender, senderPaint, senderWidth,
359                    TruncateAt.END);
360        }
361    }
362    @Override
363    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
364        if (widthMeasureSpec != 0 || mViewWidth == 0) {
365            mViewWidth = MeasureSpec.getSize(widthMeasureSpec);
366            int mode = getViewMode(mViewWidth);
367            if (mode != mMode) {
368                mMode = mode;
369            }
370            mViewHeight = measureHeight(heightMeasureSpec, mMode);
371        }
372        setMeasuredDimension(mViewWidth, mViewHeight);
373    }
374
375    /**
376     * Determine the height of this view
377     *
378     * @param measureSpec A measureSpec packed into an int
379     * @param mode The current mode of this view
380     * @return The height of the view, honoring constraints from measureSpec
381     */
382    private int measureHeight(int measureSpec, int mode) {
383        int result = 0;
384        int specMode = MeasureSpec.getMode(measureSpec);
385        int specSize = MeasureSpec.getSize(measureSpec);
386
387        if (specMode == MeasureSpec.EXACTLY) {
388            // We were told how big to be
389            result = specSize;
390        } else {
391            // Measure the text
392            if (mMode == MODE_WIDE) {
393                result = sItemHeightWide;
394            } else {
395                result = sItemHeightNormal;
396            }
397            if (specMode == MeasureSpec.AT_MOST) {
398                // Respect AT_MOST value if that was what is called for by
399                // measureSpec
400                result = Math.min(result, specSize);
401            }
402        }
403        return result;
404    }
405
406    @Override
407    public void draw(Canvas canvas) {
408        // Update the background, before View.draw() draws it.
409        setSelected(mAdapter.isSelected(this));
410        updateBackground();
411        super.draw(canvas);
412    }
413
414    @Override
415    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
416        super.onLayout(changed, left, top, right, bottom);
417
418        mCoordinates = MessageListItemCoordinates.forWidth(mContext, mViewWidth, mIsSearchResult);
419        calculateDrawingData();
420    }
421
422    private int getFontColor(int defaultColor) {
423        return isActivated() && MessageListItemCoordinates.isMultiPane(mContext) ?
424                ACTIVATED_TEXT_COLOR : defaultColor;
425    }
426
427    @Override
428    protected void onDraw(Canvas canvas) {
429        // Draw the color chip indicating the mailbox this belongs to
430        if (mColorChipPaint != null) {
431            canvas.drawRect(
432                    mCoordinates.chipX, mCoordinates.chipY,
433                    mCoordinates.chipX + mCoordinates.chipWidth,
434                    mCoordinates.chipY + mCoordinates.chipHeight,
435                    mColorChipPaint);
436        }
437
438        // Draw the checkbox
439        canvas.drawBitmap(mAdapter.isSelected(this) ? sSelectedIconOn : sSelectedIconOff,
440                mCoordinates.checkmarkX, mCoordinates.checkmarkY, null);
441
442        // Draw the sender name
443        Paint senderPaint = mRead ? sDefaultPaint : sBoldPaint;
444        senderPaint.setColor(getFontColor(mRead ? SENDERS_TEXT_COLOR_READ
445                : SENDERS_TEXT_COLOR_UNREAD));
446        senderPaint.setTextSize(mCoordinates.sendersFontSize);
447        canvas.drawText(mFormattedSender, 0, mFormattedSender.length(),
448                mCoordinates.sendersX, mCoordinates.sendersY - mCoordinates.sendersAscent,
449                senderPaint);
450
451        // Draw the reply state. Draw nothing if neither replied nor forwarded.
452        if (mHasBeenRepliedTo && mHasBeenForwarded) {
453            canvas.drawBitmap(sStateRepliedAndForwarded,
454                    mCoordinates.stateX, mCoordinates.stateY, null);
455        } else if (mHasBeenRepliedTo) {
456            canvas.drawBitmap(sStateReplied,
457                    mCoordinates.stateX, mCoordinates.stateY, null);
458        } else if (mHasBeenForwarded) {
459            canvas.drawBitmap(sStateForwarded,
460                    mCoordinates.stateX, mCoordinates.stateY, null);
461        }
462
463        // Subject and snippet.
464        sDefaultPaint.setTextSize(mCoordinates.subjectFontSize);
465        canvas.save();
466        canvas.translate(
467                mCoordinates.subjectX,
468                mCoordinates.subjectY);
469        mSubjectLayout.draw(canvas);
470        canvas.restore();
471
472        // Draw the date
473        sDatePaint.setTextSize(mCoordinates.dateFontSize);
474        sDatePaint.setColor(mRead ? DATE_TEXT_COLOR_READ : DATE_TEXT_COLOR_UNREAD);
475        int dateX = mCoordinates.dateXEnd
476                - (int) sDatePaint.measureText(mFormattedDate, 0, mFormattedDate.length());
477
478        canvas.drawText(mFormattedDate, 0, mFormattedDate.length(),
479                dateX, mCoordinates.dateY - mCoordinates.dateAscent, sDatePaint);
480
481        // Draw the favorite icon
482        canvas.drawBitmap(mIsFavorite ? sFavoriteIconOn : sFavoriteIconOff,
483                mCoordinates.starX, mCoordinates.starY, null);
484
485        // TODO: deal with the icon layouts better from the coordinate class so that this logic
486        // doesn't have to exist.
487        // Draw the attachment and invite icons, if necessary.
488        int iconsLeft = dateX - sBadgeMargin;
489        if (mHasAttachment) {
490            iconsLeft = iconsLeft - sAttachmentIcon.getWidth();
491            canvas.drawBitmap(sAttachmentIcon, iconsLeft, mCoordinates.paperclipY, null);
492        }
493        if (mHasInvite) {
494            iconsLeft -= sInviteIcon.getWidth();
495            canvas.drawBitmap(sInviteIcon, iconsLeft, mCoordinates.paperclipY, null);
496        }
497
498    }
499
500    /**
501     * Called by the adapter at bindView() time
502     *
503     * @param adapter the adapter that creates this view
504     * @param layout If this is a three pane implementation, the
505     *            ThreePaneLayout. Otherwise, null.
506     */
507    public void bindViewInit(MessagesAdapter adapter, ThreePaneLayout layout,
508            boolean isSearchResult) {
509        mLayout = layout;
510        mAdapter = adapter;
511        mIsSearchResult = isSearchResult;
512        requestLayout();
513    }
514
515    private static final int TOUCH_SLOP = 24;
516    private static int sScaledTouchSlop = -1;
517
518    private void initializeSlop(Context context) {
519        if (sScaledTouchSlop == -1) {
520            final Resources res = context.getResources();
521            final Configuration config = res.getConfiguration();
522            final float density = res.getDisplayMetrics().density;
523            final float sizeAndDensity;
524            if (config.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_XLARGE)) {
525                sizeAndDensity = density * 1.5f;
526            } else {
527                sizeAndDensity = density;
528            }
529            sScaledTouchSlop = (int) (sizeAndDensity * TOUCH_SLOP + 0.5f);
530        }
531    }
532
533    /**
534     * Overriding this method allows us to "catch" clicks in the checkbox or star
535     * and process them accordingly.
536     */
537    @Override
538    public boolean onTouchEvent(MotionEvent event) {
539        initializeSlop(getContext());
540
541        boolean handled = false;
542        int touchX = (int) event.getX();
543        int checkRight = mCoordinates.checkmarkX
544                + mCoordinates.checkmarkWidthIncludingMargins + sScaledTouchSlop;
545        int starLeft = mCoordinates.starX - sScaledTouchSlop;
546
547        switch (event.getAction()) {
548            case MotionEvent.ACTION_DOWN:
549                if (touchX < checkRight || touchX > starLeft) {
550                    mDownEvent = true;
551                    if ((touchX < checkRight) || (touchX > starLeft)) {
552                        handled = true;
553                    }
554                }
555                break;
556
557            case MotionEvent.ACTION_CANCEL:
558                mDownEvent = false;
559                break;
560
561            case MotionEvent.ACTION_UP:
562                if (mDownEvent) {
563                    if (touchX < checkRight) {
564                        mAdapter.toggleSelected(this);
565                        handled = true;
566                    } else if (touchX > starLeft) {
567                        mIsFavorite = !mIsFavorite;
568                        mAdapter.updateFavorite(this, mIsFavorite);
569                        handled = true;
570                    }
571                }
572                break;
573        }
574
575        if (handled) {
576            invalidate();
577        } else {
578            handled = super.onTouchEvent(event);
579        }
580
581        return handled;
582    }
583
584    @Override
585    public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
586        event.setClassName(getClass().getName());
587        event.setPackageName(getContext().getPackageName());
588        event.setEnabled(true);
589        event.setContentDescription(getContentDescription());
590        return true;
591    }
592
593    /**
594     * Sets the content description for this item, used for accessibility.
595     */
596    private void populateContentDescription() {
597        if (!TextUtils.isEmpty(mSubject)) {
598            setContentDescription(sSubjectDescription + mSubject);
599        } else {
600            setContentDescription(sSubjectEmptyDescription);
601        }
602    }
603}
604