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