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