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