EmailWidget.java revision 31d9acbf0623872f9d4a2b3210b5970854b654c7
1/*
2 * Copyright (C) 2011 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.widget;
18
19import com.android.email.Email;
20import com.android.email.UiUtilities;
21import com.android.email.R;
22import com.android.email.ResourceHelper;
23import com.android.email.activity.MessageCompose;
24import com.android.email.activity.Welcome;
25import com.android.email.data.ThrottlingCursorLoader;
26import com.android.email.provider.WidgetProvider.WidgetService;
27import com.android.emailcommon.provider.EmailContent;
28import com.android.emailcommon.provider.EmailContent.Account;
29import com.android.emailcommon.provider.EmailContent.AccountColumns;
30import com.android.emailcommon.provider.EmailContent.Mailbox;
31import com.android.emailcommon.provider.EmailContent.Message;
32import com.android.emailcommon.provider.EmailContent.MessageColumns;
33import com.android.emailcommon.utility.Utility;
34
35import android.app.PendingIntent;
36import android.appwidget.AppWidgetManager;
37import android.content.ContentResolver;
38import android.content.ContentUris;
39import android.content.Context;
40import android.content.Intent;
41import android.content.Loader;
42import android.content.res.Resources;
43import android.database.Cursor;
44import android.graphics.Typeface;
45import android.net.Uri;
46import android.net.Uri.Builder;
47import android.os.AsyncTask;
48import android.text.Spannable;
49import android.text.SpannableString;
50import android.text.SpannableStringBuilder;
51import android.text.TextUtils;
52import android.text.format.DateUtils;
53import android.text.style.AbsoluteSizeSpan;
54import android.text.style.ForegroundColorSpan;
55import android.text.style.StyleSpan;
56import android.util.Log;
57import android.view.View;
58import android.widget.RemoteViews;
59import android.widget.RemoteViewsService;
60
61import java.util.List;
62import java.util.concurrent.ExecutionException;
63
64import junit.framework.Assert;
65
66public class EmailWidget implements RemoteViewsService.RemoteViewsFactory {
67    public static final String TAG = "EmailWidget";
68
69    /**
70     * When handling clicks in a widget ListView, a single PendingIntent template is provided to
71     * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent"
72     * on each list element; when a click is received, this "fillInIntent" is merged with the
73     * PendingIntent using Intent.fillIn().  Since this mechanism does NOT preserve the Extras
74     * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its
75     * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via
76     * Intent.setDataAndType()
77     *
78     * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value
79     * is entirely arbitrary.
80     *
81     * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only
82     * in the requirement that it be syntactically valid.
83     *
84     * We use the following convention for our commands:
85     *     widget://command/<command>/<arg1>[/<arg2>]
86     */
87    private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data";
88
89    private static final Uri COMMAND_URI = Uri.parse("widget://command");
90
91    // Command names and Uri's built upon COMMAND_URI
92    private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view";
93    private static final Uri COMMAND_URI_SWITCH_LIST_VIEW =
94            COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build();
95    private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message";
96    private static final Uri COMMAND_URI_VIEW_MESSAGE =
97            COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build();
98
99
100    private static final int TOTAL_COUNT_UNKNOWN = -1;
101    private static final int MAX_MESSAGE_LIST_COUNT = 25;
102
103    private static final String SORT_TIMESTAMP_DESCENDING = MessageColumns.TIMESTAMP + " DESC";
104    private static final String SORT_ID_ASCENDING = AccountColumns.ID + " ASC";
105    private static final String[] ID_NAME_PROJECTION = {Account.RECORD_ID, Account.DISPLAY_NAME};
106    private static final int ID_NAME_COLUMN_ID = 0;
107    private static final int ID_NAME_COLUMN_NAME = 1;
108
109    private static String sSubjectSnippetDivider;
110    private static String sConfigureText;
111    private static int sSenderFontSize;
112    private static int sSubjectFontSize;
113    private static int sDateFontSize;
114    private static int sDefaultTextColor;
115    private static int sLightTextColor;
116
117    private final Context mContext;
118    private final ContentResolver mResolver;
119    private final AppWidgetManager mWidgetManager;
120
121    // The widget identifier
122    private final int mWidgetId;
123
124    // The cursor underlying the message list for this widget; this must only be modified while
125    // holding mCursorLock
126    private volatile Cursor mCursor;
127    // A lock on our cursor, which is used in the UI thread while inflating views, and by
128    // our Loader in the background
129    private final Object mCursorLock = new Object();
130    // Number of records in the cursor
131    private int mCursorCount = TOTAL_COUNT_UNKNOWN;
132    // The widget's loader (derived from ThrottlingCursorLoader)
133    private ViewCursorLoader mLoader;
134    private final ResourceHelper mResourceHelper;
135    // Number of defined accounts
136    private int mAccountCount = TOTAL_COUNT_UNKNOWN;
137
138    // The current view type (all mail, unread, or starred for now)
139    /*package*/ ViewType mViewType = ViewType.STARRED;
140
141    // The projection to be used by the WidgetLoader
142    private static final String[] WIDGET_PROJECTION = new String[] {
143            EmailContent.RECORD_ID, MessageColumns.DISPLAY_NAME, MessageColumns.TIMESTAMP,
144            MessageColumns.SUBJECT, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE,
145            MessageColumns.FLAG_ATTACHMENT, MessageColumns.MAILBOX_KEY, MessageColumns.SNIPPET,
146            MessageColumns.ACCOUNT_KEY, MessageColumns.FLAGS
147            };
148    private static final int WIDGET_COLUMN_ID = 0;
149    private static final int WIDGET_COLUMN_DISPLAY_NAME = 1;
150    private static final int WIDGET_COLUMN_TIMESTAMP = 2;
151    private static final int WIDGET_COLUMN_SUBJECT = 3;
152    private static final int WIDGET_COLUMN_FLAG_READ = 4;
153    @SuppressWarnings("unused")
154    private static final int WIDGET_COLUMN_FLAG_FAVORITE = 5;
155    private static final int WIDGET_COLUMN_FLAG_ATTACHMENT = 6;
156    private static final int WIDGET_COLUMN_MAILBOX_KEY = 7;
157    private static final int WIDGET_COLUMN_SNIPPET = 8;
158    private static final int WIDGET_COLUMN_ACCOUNT_KEY = 9;
159    private static final int WIDGET_COLUMN_FLAGS = 10;
160
161    public EmailWidget(Context context, int _widgetId) {
162        super();
163        if (Email.DEBUG) {
164            Log.d(TAG, "Creating EmailWidget with id = " + _widgetId);
165        }
166        mContext = context.getApplicationContext();
167        mResolver = mContext.getContentResolver();
168        mWidgetManager = AppWidgetManager.getInstance(mContext);
169
170        mWidgetId = _widgetId;
171        mLoader = new ViewCursorLoader();
172        if (sSubjectSnippetDivider == null) {
173            // Initialize string, color, dimension resources
174            Resources res = mContext.getResources();
175            sSubjectSnippetDivider =
176                res.getString(R.string.message_list_subject_snippet_divider);
177            sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size);
178            sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size);
179            sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size);
180            sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
181            sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
182            sLightTextColor = res.getColor(R.color.widget_light_text_color);
183            sConfigureText =  res.getString(R.string.widget_other_views);
184        }
185        mResourceHelper = ResourceHelper.getInstance(mContext);
186    }
187
188    public void updateWidget(boolean validateView) {
189        new WidgetUpdateTask().execute(validateView);
190    }
191
192    /**
193     *  Task for updating widget data (eg: the header, view list items, etc...)
194     *  If parameter to {@link #execute(Boolean...)} is <code>true</code>, the current
195     *  view is validated against the current set of accounts. And if the current view
196     *  is determined to be invalid, the view will automatically progress to the next
197     *  valid view.
198     */
199    private final class WidgetUpdateTask extends AsyncTask<Boolean, Void, Boolean> {
200        @Override
201        protected Boolean doInBackground(Boolean... validateView) {
202            mAccountCount = EmailContent.count(mContext, EmailContent.Account.CONTENT_URI);
203            // If displaying invalid view, switch to the next view
204            return !validateView[0] || isViewValid();
205        }
206
207        @Override
208        protected void onPostExecute(Boolean isValidView) {
209            updateHeader();
210            if (!isValidView) {
211                switchView();
212            }
213        }
214    }
215
216    /**
217     * The ThrottlingCursorLoader does all of the heavy lifting in managing the data loading
218     * task; all we need is to register a listener so that we're notified when the load is
219     * complete.
220     */
221    private final class ViewCursorLoader extends ThrottlingCursorLoader {
222        protected ViewCursorLoader() {
223            super(mContext, Message.CONTENT_URI, WIDGET_PROJECTION, mViewType.selection,
224                    mViewType.selectionArgs, SORT_TIMESTAMP_DESCENDING);
225            registerListener(0, new OnLoadCompleteListener<Cursor>() {
226                @Override
227                public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
228                    synchronized (mCursorLock) {
229                        // Save away the cursor
230                        mCursor = cursor;
231                        // Reset the notification Uri to our Message table notifier URI
232                        mCursor.setNotificationUri(mResolver, Message.NOTIFIER_URI);
233                        // Save away the count (for display)
234                        mCursorCount = mCursor.getCount();
235                        if (Email.DEBUG) {
236                            Log.d(TAG, "onLoadComplete, count = " + cursor.getCount());
237                        }
238                    }
239                    RemoteViews views =
240                        new RemoteViews(mContext.getPackageName(), R.layout.widget);
241                    setupTitleAndCount(views);
242                    mWidgetManager.partiallyUpdateAppWidget(mWidgetId, views);
243                    mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
244                }
245            });
246        }
247
248        /**
249         * Stop any pending load, reset selection parameters, and start loading
250         * Must be called from the UI thread
251         * @param viewType the current ViewType
252         */
253        private void load(ViewType viewType) {
254            reset();
255            setSelection(viewType.selection);
256            setSelectionArgs(viewType.selectionArgs);
257            startLoading();
258        }
259    }
260
261    /**
262     * Initialize to first appropriate view (depending on the number of accounts)
263     */
264    public void init() {
265        // Just update the account count & header; no need to validate the view
266        updateWidget(false);
267        switchView(); // TODO Do we really need this??
268    }
269
270    /**
271     * Reset cursor and cursor count, notify widget that list data is invalid, and start loading
272     * with our current ViewType
273     */
274    private void loadView() {
275        synchronized(mCursorLock) {
276            mCursorCount = TOTAL_COUNT_UNKNOWN;
277            mCursor = null;
278            mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
279            mLoader.load(mViewType);
280        }
281    }
282
283    /**
284     * Switch to the next widget view (all -> account1 -> ... -> account n -> unread -> starred)
285     *
286     * This must be called on a background thread.  Use {@link #switchView} on the UI thread.
287     */
288    private synchronized void switchToNextView() {
289        switch(mViewType) {
290            // If we're in starred and there is more than one account, go to "all mail"
291            // Otherwise, fall through to the accounts themselves
292            case STARRED:
293                if (EmailContent.count(mContext, Account.CONTENT_URI) > 1) {
294                    mViewType = ViewType.ALL_INBOX;
295                    break;
296                }
297                //$FALL-THROUGH$
298            case ALL_INBOX:
299                ViewType.ACCOUNT.selectionArgs[0] = "0";
300                //$FALL-THROUGH$
301            case ACCOUNT:
302                // Find the next account (or, if none, default to UNREAD)
303                String idString = ViewType.ACCOUNT.selectionArgs[0];
304                Cursor c = mResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id>?",
305                        new String[] {idString}, SORT_ID_ASCENDING);
306                try {
307                    if (c.moveToFirst()) {
308                        mViewType = ViewType.ACCOUNT;
309                        mViewType.selectionArgs[0] = c.getString(ID_NAME_COLUMN_ID);
310                        mViewType.setTitle(c.getString(ID_NAME_COLUMN_NAME));
311                    } else {
312                        mViewType = ViewType.UNREAD;
313                    }
314                } finally {
315                    c.close();
316                }
317                break;
318            case UNREAD:
319                mViewType = ViewType.STARRED;
320                break;
321        }
322    }
323
324    /**
325     * Returns whether the current view is valid. The following rules determine if a view is
326     * considered valid:
327     * 1. If the view is either {@link ViewType#STARRED} or {@link ViewType#UNREAD}, always
328     * returns <code>true</code>.
329     * 2. If the view is {@link ViewType#ALL_INBOX}, returns <code>true</code> if more than
330     * one account is defined. Otherwise, returns <code>false</code>.
331     * 3. If the view is {@link ViewType#ACCOUNT}, returns <code>true</code> if the account
332     * is defined. Otherwise, returns <code>false</code>.
333     */
334    private boolean isViewValid() {
335        switch(mViewType) {
336            case ALL_INBOX:
337                // "all inbox" is valid only if there is more than one account
338                return (EmailContent.count(mContext, Account.CONTENT_URI) > 1);
339            case ACCOUNT:
340                // Ensure current account still exists
341                String idString = ViewType.ACCOUNT.selectionArgs[0];
342                Cursor c = mResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id=?",
343                        new String[] {idString}, SORT_ID_ASCENDING);
344                try {
345                    return c.moveToFirst();
346                } finally {
347                    c.close();
348                }
349        }
350        return true;
351    }
352
353    /**
354     * Convenience method for creating an onClickPendingIntent that executes a command via
355     * our command Uri.  Used for the "next view" command; appends the widget id to the command
356     * Uri.
357     *
358     * @param views The RemoteViews we're inflating
359     * @param buttonId the id of the button view
360     * @param data the command Uri
361     */
362    private void setCommandIntent(RemoteViews views, int buttonId, Uri data) {
363        Intent intent = new Intent(mContext, WidgetService.class);
364        intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), WIDGET_DATA_MIME_TYPE);
365        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
366                PendingIntent.FLAG_UPDATE_CURRENT);
367        views.setOnClickPendingIntent(buttonId, pendingIntent);
368    }
369
370    /**
371     * Convenience method for creating an onClickPendingIntent that launches another activity
372     * directly.
373     *
374     * @param views The RemoteViews we're inflating
375     * @param buttonId the id of the button view
376     * @param intent The intent to be used when launching the activity
377     */
378    private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) {
379        PendingIntent pendingIntent =
380                PendingIntent.getActivity(mContext, 0, intent, 0);
381        views.setOnClickPendingIntent(buttonId, pendingIntent);
382    }
383
384    /**
385     * Convenience method for constructing a fillInIntent for a given list view element.
386     * Appends the command and any arguments to a base Uri.
387     *
388     * @param views the RemoteViews we are inflating
389     * @param viewId the id of the view
390     * @param baseUri the base uri for the command
391     * @param args any arguments to the command
392     */
393    private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) {
394        Intent intent = new Intent();
395        Builder builder = baseUri.buildUpon();
396        for (String arg: args) {
397            builder.appendPath(arg);
398        }
399        intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
400        views.setOnClickFillInIntent(viewId, intent);
401    }
402
403    /**
404     * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to
405     * handle intents created by remote views.
406     */
407    public static boolean processIntent(Context context, Intent intent) {
408        final Uri data = intent.getData();
409        if (data == null) {
410            return false;
411        }
412        List<String> pathSegments = data.getPathSegments();
413        // Our path segments are <command>, <arg1> [, <arg2>]
414        // First, a quick check of Uri validity
415        if (pathSegments.size() < 2) {
416            throw new IllegalArgumentException();
417        }
418        String command = pathSegments.get(0);
419        // Ignore unknown action names
420        try {
421            final long arg1 = Long.parseLong(pathSegments.get(1));
422            if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
423                // "view", <message id>, <mailbox id>
424                openMessage(context, Long.parseLong(pathSegments.get(2)), arg1);
425            } else if (EmailWidget.COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) {
426                // "next_view", <widget id>
427                EmailWidget widget = WidgetManager.getInstance().get((int)arg1);
428                if (widget != null) {
429                    widget.switchView();
430                }
431            }
432        } catch (NumberFormatException e) {
433            // Shouldn't happen as we construct all of the Uri's
434            return false;
435        }
436        return true;
437    }
438
439    private static void openMessage(final Context context, final long mailboxId,
440            final long messageId) {
441        Utility.runAsync(new Runnable() {
442            @Override
443            public void run() {
444                Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
445                if (mailbox == null) return;
446                context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
447                        mailboxId, messageId));
448            }
449        });
450    }
451
452    private void setupTitleAndCount(RemoteViews views) {
453        // Set up the title (view type + count of messages)
454        views.setTextViewText(R.id.widget_title, mViewType.getTitle(mContext));
455        views.setTextViewText(R.id.widget_tap, sConfigureText);
456        String count = "";
457        if (mCursorCount != TOTAL_COUNT_UNKNOWN) {
458            count = UiUtilities.getMessageCountForUi(mContext, mCursor.getCount(), false);
459        }
460        views.setTextViewText(R.id.widget_count, count);
461    }
462    /**
463     * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
464     * message list)
465     */
466    public void updateHeader() {
467        if (Email.DEBUG) {
468            Log.d(TAG, "updateWidget " + mWidgetId);
469        }
470
471        // Get the widget layout
472        RemoteViews views =
473                new RemoteViews(mContext.getPackageName(), R.layout.widget);
474
475        // Set up the list with an adapter
476        Intent intent = new Intent(mContext, WidgetService.class);
477        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
478        intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
479        views.setRemoteAdapter(mWidgetId, R.id.message_list, intent);
480
481        setupTitleAndCount(views);
482
483        if (mAccountCount == 0) {
484            // Hide compose icon & show "touch to configure" text
485            views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
486            views.setViewVisibility(R.id.message_list, View.GONE);
487            views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE);
488            // Create click intent for "touch to configure" target
489            intent = Welcome.createOpenAccountInboxIntent(mContext, -1);
490            setActivityIntent(views, R.id.tap_to_configure, intent);
491        } else {
492            // Show compose icon & message list
493            views.setViewVisibility(R.id.widget_compose, View.VISIBLE);
494            views.setViewVisibility(R.id.message_list, View.VISIBLE);
495            views.setViewVisibility(R.id.tap_to_configure, View.GONE);
496            // Create click intent for "compose email" target
497            intent = MessageCompose.getMessageComposeIntent(mContext, -1);
498            setActivityIntent(views, R.id.widget_compose, intent);
499        }
500        // Create click intent for "view rotation" target
501        setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW);
502
503        // Use a bare intent for our template; we need to fill everything in
504        intent = new Intent(mContext, WidgetService.class);
505        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
506                PendingIntent.FLAG_UPDATE_CURRENT);
507        views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
508
509        // And finally update the widget
510        mWidgetManager.updateAppWidget(mWidgetId, views);
511    }
512
513    /**
514     * Add size and color styling to text
515     *
516     * @param text the text to style
517     * @param size the font size for this text
518     * @param color the color for this text
519     * @return a CharSequence quitable for use in RemoteViews.setTextViewText()
520     */
521    private CharSequence addStyle(CharSequence text, int size, int color) {
522        SpannableStringBuilder builder = new SpannableStringBuilder(text);
523        builder.setSpan(
524                new AbsoluteSizeSpan(size), 0, text.length(),
525                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
526        if (color != 0) {
527            builder.setSpan(new ForegroundColorSpan(color), 0, text.length(),
528                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
529        }
530        return builder;
531    }
532
533    /**
534     * Create styled text for our combination subject and snippet
535     *
536     * @param subject the message's subject (or null)
537     * @param snippet the message's snippet (or null)
538     * @param read whether or not the message is read
539     * @return a CharSequence suitable for use in RemoteViews.setTextViewText()
540     */
541    private CharSequence getStyledSubjectSnippet (String subject, String snippet,
542            boolean read) {
543        SpannableStringBuilder ssb = new SpannableStringBuilder();
544        boolean hasSubject = false;
545        if (!TextUtils.isEmpty(subject)) {
546            SpannableString ss = new SpannableString(subject);
547            ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
548                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
549            ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(),
550                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
551            ssb.append(ss);
552            hasSubject = true;
553        }
554        if (!TextUtils.isEmpty(snippet)) {
555            if (hasSubject) {
556                ssb.append(sSubjectSnippetDivider);
557            }
558            SpannableString ss = new SpannableString(snippet);
559            ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(),
560                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
561            ssb.append(ss);
562        }
563        return addStyle(ssb, sSubjectFontSize, 0);
564    }
565
566    @Override
567    public RemoteViews getViewAt(int position) {
568        // Use the cursor to set up the widget
569        synchronized (mCursorLock) {
570            if (mCursor == null || mCursor.isClosed() || !mCursor.moveToPosition(position)) {
571                return getLoadingView();
572            }
573            RemoteViews views =
574                new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item);
575            boolean isUnread = mCursor.getInt(WIDGET_COLUMN_FLAG_READ) != 1;
576            int drawableId = R.drawable.widget_read_conversation_selector;
577            if (isUnread) {
578                drawableId = R.drawable.widget_unread_conversation_selector;
579            }
580            views.setInt(R.id.widget_message, "setBackgroundResource", drawableId);
581
582            // Add style to sender
583            SpannableStringBuilder from =
584                new SpannableStringBuilder(mCursor.getString(WIDGET_COLUMN_DISPLAY_NAME));
585            from.setSpan(
586                    isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0,
587                    from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
588            CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor);
589            views.setTextViewText(R.id.widget_from, styledFrom);
590
591            long timestamp = mCursor.getLong(WIDGET_COLUMN_TIMESTAMP);
592            // Get a nicely formatted date string (relative to today)
593            String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
594            // Add style to date
595            CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
596            views.setTextViewText(R.id.widget_date, styledDate);
597
598            // Add style to subject/snippet
599            String subject = mCursor.getString(WIDGET_COLUMN_SUBJECT);
600            String snippet = mCursor.getString(WIDGET_COLUMN_SNIPPET);
601            CharSequence subjectAndSnippet =
602                getStyledSubjectSnippet(subject, snippet, !isUnread);
603            views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
604
605            int messageFlags = mCursor.getInt(WIDGET_COLUMN_FLAGS);
606            boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0;
607            views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE);
608
609            boolean hasAttachment = mCursor.getInt(WIDGET_COLUMN_FLAG_ATTACHMENT) != 0;
610            views.setViewVisibility(R.id.widget_attachment,
611                    hasAttachment ? View.VISIBLE : View.GONE);
612
613            if (mViewType == ViewType.ACCOUNT) {
614                views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
615            } else {
616                long accountId = mCursor.getLong(WIDGET_COLUMN_ACCOUNT_KEY);
617                int colorId = mResourceHelper.getAccountColorId(accountId);
618                if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) {
619                    // Color defined by resource ID, so, use it
620                    views.setViewVisibility(R.id.color_chip, View.VISIBLE);
621                    views.setImageViewResource(R.id.color_chip, colorId);
622                } else {
623                    // Color not defined by resource ID, nothing we can do, so, hide the chip
624                    views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
625                }
626            }
627
628            // Set button intents for view, reply, and delete
629            String messageId = mCursor.getString(WIDGET_COLUMN_ID);
630            String mailboxId = mCursor.getString(WIDGET_COLUMN_MAILBOX_KEY);
631            setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE,
632                    messageId, mailboxId);
633
634            return views;
635        }
636    }
637
638    @Override
639    public int getCount() {
640        if (mCursor == null) return 0;
641        return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
642    }
643
644    @Override
645    public long getItemId(int position) {
646        return position;
647    }
648
649    @Override
650    public RemoteViews getLoadingView() {
651        RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
652        view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading));
653        return view;
654    }
655
656    @Override
657    public int getViewTypeCount() {
658        // Regular list view and the "loading" view
659        return 2;
660    }
661
662    @Override
663    public boolean hasStableIds() {
664        return true;
665    }
666
667    @Override
668    public void onDataSetChanged() {
669    }
670
671    public void onDeleted() {
672        if (mLoader != null) {
673            mLoader.stopLoading();
674        }
675        WidgetManager.getInstance().remove(mWidgetId);
676    }
677
678    @Override
679    public void onDestroy() {
680        if (mLoader != null) {
681            mLoader.stopLoading();
682        }
683        WidgetManager.getInstance().remove(mWidgetId);
684    }
685
686    @Override
687    public void onCreate() {
688    }
689
690    /**
691     * Switch to the next view.
692     */
693    /* package */ void switchView() {
694        switchView(false);
695    }
696
697    private WidgetViewSwitcher switchView(boolean disableLoadAfterSwitchForTest) {
698        WidgetViewSwitcher switcher = new WidgetViewSwitcher(this, disableLoadAfterSwitchForTest);
699        switcher.execute();
700        return switcher;
701    }
702
703    /**
704     * Switch views synchronously without loading
705     */
706    /* package */ void switchViewSyncForTest() {
707        WidgetViewSwitcher switcher = switchView(true);
708        try {
709            switcher.get();
710        } catch (InterruptedException e) {
711            Assert.fail();
712        } catch (ExecutionException e) {
713            Assert.fail();
714        }
715    }
716
717    /**
718     * Utility class to handle switching widget views; in the background, we access the database
719     * to determine account status, etc.  In the foreground, we start up the Loader with new
720     * parameters
721     */
722    private static class WidgetViewSwitcher extends AsyncTask<Void, Void, Void> {
723        private final EmailWidget mWidget;
724        private final boolean mDisableLoadAfterSwitchForTest;
725
726        public WidgetViewSwitcher(EmailWidget widget, boolean disableLoadAfterSwitchForTest) {
727            mWidget = widget;
728            mDisableLoadAfterSwitchForTest = disableLoadAfterSwitchForTest;
729        }
730
731        @Override
732        protected Void doInBackground(Void... params) {
733            mWidget.switchToNextView();
734            return null;
735        }
736
737        @Override
738        protected void onPostExecute(Void param) {
739            if (isCancelled()) {
740                return;
741            }
742            if (!mDisableLoadAfterSwitchForTest) {
743                mWidget.loadView();
744            }
745        }
746    }
747}