WidgetProvider.java revision ebf0f18cbad20d39900d5ed165fff9978d929e5f
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.Utility;
22import com.android.email.activity.MessageCompose;
23import com.android.email.activity.Welcome;
24import com.android.email.data.ThrottlingCursorLoader;
25import com.android.email.provider.EmailContent.Mailbox;
26import com.android.email.provider.EmailContent.Message;
27import com.android.email.provider.EmailContent.MessageColumns;
28
29import android.app.Activity;
30import android.app.PendingIntent;
31import android.app.Service;
32import android.appwidget.AppWidgetManager;
33import android.appwidget.AppWidgetProvider;
34import android.content.ContentResolver;
35import android.content.ContentUris;
36import android.content.Context;
37import android.content.Intent;
38import android.content.Loader;
39import android.database.Cursor;
40import android.graphics.Typeface;
41import android.graphics.Paint.Align;
42import android.net.Uri;
43import android.net.Uri.Builder;
44import android.os.Bundle;
45import android.text.Spannable;
46import android.text.SpannableString;
47import android.text.TextPaint;
48import android.text.TextUtils;
49import android.text.TextUtils.TruncateAt;
50import android.text.format.DateUtils;
51import android.text.style.StyleSpan;
52import android.util.Log;
53import android.widget.RemoteViews;
54import android.widget.RemoteViewsService;
55
56import java.util.HashMap;
57import java.util.List;
58
59public class WidgetProvider extends AppWidgetProvider {
60    private static final String TAG = "WidgetProvider";
61
62    /**
63     * When handling clicks in a widget ListView, a single PendingIntent template is provided to
64     * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent"
65     * on each list element; when a click is received, this "fillInIntent" is merged with the
66     * PendingIntent using Intent.fillIn().  Since this mechanism does NOT preserve the Extras
67     * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its
68     * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via
69     * Intent.setDataAndType()
70     *
71     * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value
72     * is entirely arbitrary.
73     *
74     * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only
75     * in the requirement that it be syntactically valid.
76     *
77     * We use the following convention for our commands:
78     *     widget://command/<command>/<arg1>[/<arg2>]
79     */
80    private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data";
81    private static final Uri COMMAND_URI = Uri.parse("widget://command");
82
83    // Command names and Uri's built upon COMMAND_URI
84    private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view";
85    private static final Uri COMMAND_URI_SWITCH_LIST_VIEW =
86        COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build();
87    private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message";
88    private static final Uri COMMAND_URI_VIEW_MESSAGE =
89        COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build();
90
91    private static final int TOTAL_COUNT_UNKNOWN = -1;
92    private static final int MAX_MESSAGE_LIST_COUNT = 25;
93
94    private static final String SORT_DESCENDING = MessageColumns.TIMESTAMP + " DESC";
95
96    // Map holding our instantiated widgets, accessed by widget id
97    private static HashMap<Integer, EmailWidget> sWidgetMap = new HashMap<Integer, EmailWidget>();
98    private static AppWidgetManager sWidgetManager;
99    private static Context sContext;
100    private static ContentResolver sResolver;
101    private static TextPaint sDatePaint = new TextPaint();
102
103    /**
104     * Types of views that we're prepared to show in the widget - all mail, unread mail, and starred
105     * mail; we rotate between them.  Each ViewType is composed of a selection string and a title.
106     */
107    public enum ViewType {
108        ALL_MAIL(null, R.string.widget_all_mail),
109        UNREAD(MessageColumns.FLAG_READ + "=0", R.string.widget_unread),
110        STARRED(MessageColumns.FLAG_FAVORITE + "=1", R.string.widget_starred);
111
112        private final String selection;
113        private final int titleResource;
114        private String title;
115
116        ViewType(String _selection, int _titleResource) {
117            selection = _selection;
118            titleResource = _titleResource;
119        }
120
121        public String getTitle(Context context) {
122            if (title == null) {
123                title = context.getString(titleResource);
124            }
125            return title;
126        }
127    }
128
129    static class EmailWidget implements RemoteViewsService.RemoteViewsFactory {
130        // The widget identifier
131        private final int mWidgetId;
132
133        // The cursor underlying the message list for this widget; this must only be modified while
134        // holding mCursorLock
135        private volatile Cursor mCursor;
136        // A lock on our cursor, which is used in the UI thread while inflating views, and by
137        // our Loader in the background
138        private final Object mCursorLock = new Object();
139        // Number of records in the cursor
140        private int mCursorCount = TOTAL_COUNT_UNKNOWN;
141        // The widget's loader (derived from ThrottlingCursorLoader)
142        private WidgetLoader mLoader;
143
144        // The current view type (all mail, unread, or starred for now)
145        private ViewType mViewType = ViewType.ALL_MAIL;
146
147        // The projection to be used by the WidgetLoader
148        public static final String[] WIDGET_PROJECTION = new String[] {
149            EmailContent.RECORD_ID, MessageColumns.DISPLAY_NAME, MessageColumns.TIMESTAMP,
150            MessageColumns.SUBJECT, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE,
151            MessageColumns.FLAG_ATTACHMENT, MessageColumns.MAILBOX_KEY, MessageColumns.SNIPPET,
152            MessageColumns.ACCOUNT_KEY
153            };
154        public static final int WIDGET_COLUMN_ID = 0;
155        public static final int WIDGET_COLUMN_DISPLAY_NAME = 1;
156        public static final int WIDGET_COLUMN_TIMESTAMP = 2;
157        public static final int WIDGET_COLUMN_SUBJECT = 3;
158        public static final int WIDGET_COLUMN_FLAG_READ = 4;
159        public static final int WIDGET_COLUMN_FLAG_FAVORITE = 5;
160        public static final int WIDGET_COLUMN_FLAG_ATTACHMENT = 6;
161        public static final int WIDGET_COLUMN_MAILBOX_KEY = 7;
162        public static final int WIDGET_COLUMN_SNIPPET = 8;
163        public static final int WIDGET_COLUMN_ACCOUNT_KEY = 9;
164
165        public EmailWidget(int _widgetId) {
166            super();
167            if (Email.DEBUG) {
168                Log.d(TAG, "Creating EmailWidget with id = " + _widgetId);
169            }
170            mWidgetId = _widgetId;
171            mLoader = new WidgetLoader();
172            if (sDatePaint == null) {
173                sDatePaint = new TextPaint();
174                sDatePaint.setTypeface(Typeface.DEFAULT);
175                sDatePaint.setTextSize(14);
176                sDatePaint.setAntiAlias(true);
177                sDatePaint.setTextAlign(Align.RIGHT);
178            }
179        }
180
181        /**
182         * The ThrottlingCursorLoader does all of the heavy lifting in managing the data loading
183         * task; all we need is to register a listener so that we're notified when the load is
184         * complete.
185         */
186        final class WidgetLoader extends ThrottlingCursorLoader {
187            protected WidgetLoader() {
188                super(sContext, Message.CONTENT_URI, WIDGET_PROJECTION, mViewType.selection, null,
189                        SORT_DESCENDING);
190                registerListener(0, new OnLoadCompleteListener<Cursor>() {
191                    @Override
192                    public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
193                        synchronized (mCursorLock) {
194                            // Save away the cursor
195                            mCursor = cursor;
196                            // Reset the notification Uri to our Message table notifier URI
197                            mCursor.setNotificationUri(sResolver, Message.NOTIFIER_URI);
198                            // Save away the count (for display)
199                            mCursorCount = mCursor.getCount();
200                            if (Email.DEBUG) {
201                                Log.d(TAG, "onLoadComplete, count = " + cursor.getCount());
202                            }
203                        }
204                        RemoteViews views =
205                            new RemoteViews(sContext.getPackageName(), R.layout.widget);
206                        views.setTextViewText(R.id.widget_title,
207                                mViewType.getTitle(sContext) + " ("  + mCursorCount + ")");
208                        sWidgetManager.partiallyUpdateAppWidget(mWidgetId, views);
209                        sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
210                    }
211                });
212                startLoading();
213            }
214
215            /**
216             * Convenience method that stops existing loading (if any), sets a (possibly new)
217             * selection criterion, and starts loading
218             *
219             * @param selection a valid query selection argument
220             */
221            void startLoadingWithSelection(String selection) {
222                stopLoading();
223                setSelection(selection);
224                startLoading();
225            }
226        }
227
228        /**
229         * Switch to the next widget view (cycles all -> unread -> starred)
230         */
231        public void switchToNextView() {
232            switch(mViewType) {
233                case ALL_MAIL:
234                    mViewType = ViewType.UNREAD;
235                    break;
236                case UNREAD:
237                    mViewType = ViewType.STARRED;
238                    break;
239                case STARRED:
240                    mViewType = ViewType.ALL_MAIL;
241                    break;
242            }
243            synchronized(mCursorLock) {
244                mCursorCount = TOTAL_COUNT_UNKNOWN;
245                invalidateCursorLocked();
246                mLoader.startLoadingWithSelection(mViewType.selection);
247            }
248        }
249
250        /**
251         * Invalidates the current cursor and tells the UI that the underlying data has changed.
252         * This method must be called while holding mCursorLock
253         */
254        private void invalidateCursorLocked() {
255            mCursor = null;
256            sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
257        }
258
259        private void setStyleSpan(SpannableString str, int typeface) {
260            int length = str.length();
261            str.setSpan(new StyleSpan(typeface), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
262        }
263
264        private CharSequence formattedText(String str, int typeface) {
265            if (str == null) {
266                return "";
267            }
268            SpannableString ss = new SpannableString(str);
269            setStyleSpan(ss, typeface);
270            return ss;
271        }
272
273        private CharSequence formattedTextFromCursor(Cursor c, int column, int typeface) {
274            return formattedText(mCursor.getString(column), typeface);
275        }
276
277
278        /**
279         * Convenience method for creating an onClickPendingIntent that executes a command via
280         * our command Uri.  Used for the "next view" command; appends the widget id to the command
281         * Uri.
282         *
283         * @param views The RemoteViews we're inflating
284         * @param buttonId the id of the button view
285         * @param data the command Uri
286         */
287        private void setCommandIntent(RemoteViews views, int buttonId, Uri data) {
288            Intent intent = new Intent(sContext, WidgetService.class);
289            intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId),
290                    WIDGET_DATA_MIME_TYPE);
291            PendingIntent pendingIntent = PendingIntent.getService(sContext, 0, intent,
292                    PendingIntent.FLAG_UPDATE_CURRENT);
293            views.setOnClickPendingIntent(buttonId, pendingIntent);
294        }
295
296        /**
297         * Convenience method for creating an onClickPendingIntent that launches another activity
298         * directly.  Used for the "Compose" button
299         *
300         * @param views The RemoteViews we're inflating
301         * @param buttonId the id of the button view
302         * @param activityClass the class of the activity to be launched
303         */
304        private void setActivityIntent(RemoteViews views, int buttonId,
305                Class<? extends Activity> activityClass) {
306            Intent intent = new Intent(sContext, activityClass);
307            PendingIntent pendingIntent = PendingIntent.getActivity(sContext, 0, intent, 0);
308            views.setOnClickPendingIntent(buttonId, pendingIntent);
309        }
310
311        /**
312         * Convenience method for constructing a fillInIntent for a given list view element.
313         * Appends the command and any arguments to a base Uri.
314         *
315         * @param views the RemoteViews we are inflating
316         * @param viewId the id of the view
317         * @param baseUri the base uri for the command
318         * @param args any arguments to the command
319         */
320        private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) {
321            Intent intent = new Intent();
322            Builder builder = baseUri.buildUpon();
323            for (String arg: args) {
324                builder.appendPath(arg);
325            }
326            intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
327            views.setOnClickFillInIntent(viewId, intent);
328        }
329
330        /**
331         * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
332         * message list)
333         */
334        private void updateHeader() {
335            if (Email.DEBUG) {
336                Log.d(TAG, "updateWidget " + mWidgetId);
337            }
338
339            // Get the widget layout
340            RemoteViews views = new RemoteViews(sContext.getPackageName(), R.layout.widget);
341
342            // Set up the list with an adapter
343            Intent intent = new Intent(sContext, WidgetService.class);
344            intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
345            intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
346            views.setRemoteAdapter(R.id.message_list, intent);
347
348            // Set up the title (view type + count of messages)
349            views.setTextViewText(R.id.widget_title,
350                    mViewType.getTitle(sContext) + " ("  + mCursorCount + ")");
351
352             // Set up "new" button (compose new message) and "next view" button
353            setActivityIntent(views, R.id.widget_compose, MessageCompose.class);
354            setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW);
355
356            // Use a bare intent for our template; we need to fill everything in
357            intent = new Intent(sContext, WidgetService.class);
358            PendingIntent pendingIntent =
359                PendingIntent.getService(sContext, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
360            views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
361
362            // And finally update the widget
363            sWidgetManager.updateAppWidget(mWidgetId, views);
364        }
365
366        /* (non-Javadoc)
367         * @see android.widget.RemoteViewsService.RemoteViewsFactory#getViewAt(int)
368         */
369        public RemoteViews getViewAt(int position) {
370            // Use the cursor to set up the widget
371            synchronized (mCursorLock) {
372                if (mCursor == null || !mCursor.moveToPosition(position)) {
373                    return getLoadingView();
374                }
375                RemoteViews views =
376                    new RemoteViews(sContext.getPackageName(), R.layout.widget_list_item);
377
378                // Typeface for from, subject, and date (normal/bold) depends on whether the message
379                // is read/unread
380                int typeface = (mCursor.getInt(WIDGET_COLUMN_FLAG_READ) == 0) ? Typeface.BOLD
381                        : Typeface.NORMAL;
382                views.setTextViewText(R.id.widget_from,
383                        formattedTextFromCursor(mCursor, WIDGET_COLUMN_DISPLAY_NAME, typeface));
384                views.setTextViewText(R.id.widget_subject,
385                        formattedTextFromCursor(mCursor, WIDGET_COLUMN_SUBJECT, typeface));
386
387                long timestamp = mCursor.getLong(WIDGET_COLUMN_TIMESTAMP);
388                // Get a nicely formatted date string (relative to today)
389                String date = DateUtils.getRelativeTimeSpanString(sContext, timestamp).toString();
390                views.setTextViewText(R.id.widget_date, TextUtils.ellipsize(date, sDatePaint, 64,
391                        TruncateAt.END));
392
393                // Set button intents for view, reply, and delete
394                String messageId = mCursor.getString(WIDGET_COLUMN_ID);
395                String mailboxId = mCursor.getString(WIDGET_COLUMN_MAILBOX_KEY);
396                setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId,
397                        mailboxId);
398
399                return views;
400            }
401        }
402
403        @Override
404        public int getCount() {
405            if (mCursor == null) return 0;
406            return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
407        }
408
409        @Override
410        public long getItemId(int position) {
411            return position;
412        }
413
414        @Override
415        public RemoteViews getLoadingView() {
416            RemoteViews view = new RemoteViews(sContext.getPackageName(), R.layout.widget_loading);
417            view.setTextViewText(R.id.loading_text, sContext.getString(R.string.widget_loading));
418            return view;
419        }
420
421        @Override
422        public int getViewTypeCount() {
423            // Regular list view and the "loading" view
424            return 2;
425        }
426
427        @Override
428        public boolean hasStableIds() {
429            return true;
430        }
431
432        @Override
433        public void onDataSetChanged() {
434        }
435
436        @Override
437        public void onDestroy() {
438            if (mLoader != null) {
439                mLoader.stopLoading();
440            }
441            sWidgetMap.remove(mWidgetId);
442        }
443
444        @Override
445        public void onCreate() {
446        }
447    }
448
449    private static synchronized void update(Context context, int[] appWidgetIds) {
450        for (int widgetId: appWidgetIds) {
451            getOrCreateWidget(context, widgetId).updateHeader();
452        }
453    }
454
455    private static EmailWidget getOrCreateWidget(Context context, int widgetId) {
456        // Lazily initialize these
457        if (sContext == null) {
458            if (context == null) { // STOPSHIP remove this check
459                throw new RuntimeException("context == null!");
460            }
461            sContext = context.getApplicationContext();
462            if (sContext == null) { // STOPSHIP remove this check
463                throw new RuntimeException("getApplicationContext() returned null!");
464            }
465            sWidgetManager = AppWidgetManager.getInstance(context);
466            sResolver = context.getContentResolver();
467        }
468        EmailWidget widget = sWidgetMap.get(widgetId);
469        if (widget == null) {
470            if (Email.DEBUG) {
471                Log.d(TAG, "Creating EmailWidget for id #" + widgetId);
472            }
473            widget = new EmailWidget(widgetId);
474            sWidgetMap.put(widgetId, widget);
475        }
476        return widget;
477    }
478
479    @Override
480    public void onDisabled(Context context) {
481        super.onDisabled(context);
482        if (Email.DEBUG) {
483            Log.d(TAG, "onDisabled");
484        }
485        context.stopService(new Intent(context, WidgetService.class));
486    }
487
488    @Override
489    public void onEnabled(final Context context) {
490        super.onEnabled(context);
491        if (Email.DEBUG) {
492            Log.d(TAG, "onEnabled");
493        }
494        context.startService(new Intent(context, WidgetService.class));
495    }
496
497    @Override
498    public void onReceive(final Context context, Intent intent) {
499        String action = intent.getAction();
500        if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) {
501            Bundle extras = intent.getExtras();
502            if (extras != null) {
503                final int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS);
504                if (appWidgetIds != null && appWidgetIds.length > 0) {
505                    context.startService(new Intent(context, WidgetService.class));
506                    update(context, appWidgetIds);
507                }
508            }
509        } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) {
510            Bundle extras = intent.getExtras();
511            if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) {
512                final int widgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID);
513                // Find the widget in the map
514                EmailWidget widget = sWidgetMap.get(widgetId);
515                if (widget != null) {
516                    // Stop loading and remove the widget from the map
517                    widget.onDestroy();
518                }
519            }
520        }
521    }
522
523    /**
524     * We use the WidgetService for two purposes:
525     *  1) To provide a widget factory for RemoteViews, and
526     *  2) To process our command Uri's (i.e. take actions on user clicks)
527     */
528    public static class WidgetService extends RemoteViewsService {
529        @Override
530        public RemoteViewsFactory onGetViewFactory(Intent intent) {
531            // Which widget do we want (nice alliteration, huh?)
532            int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1);
533            if (widgetId == -1) return null;
534            // Find the existing widget or create it
535            return getOrCreateWidget(this, widgetId);
536        }
537
538        @Override
539        public void startActivity(Intent intent) {
540            // Since we're not calling startActivity from an Activity, we need the new task flag
541            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
542            super.startActivity(intent);
543        }
544
545        @Override
546        public int onStartCommand(Intent intent, int flags, int startId) {
547            Uri data = intent.getData();
548            if (Email.DEBUG) {
549                Log.d(TAG, "Executing: " + data);
550            }
551            if (data == null) return Service.START_NOT_STICKY;
552            List<String> pathSegments = data.getPathSegments();
553            // Our path segments are <command>, <arg1> [, <arg2>]
554            // First, a quick check of Uri validity
555            if (pathSegments.size() < 2) {
556                throw new IllegalArgumentException();
557            }
558            String command = pathSegments.get(0);
559            // Ignore unknown action names
560            try {
561                long arg1 = Long.parseLong(pathSegments.get(1));
562                if (COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
563                    // "view", <message id>, <mailbox id>
564                    final long mailboxId = Long.parseLong(pathSegments.get(2));
565                    final long messageId = arg1;
566                    Utility.runAsync(new Runnable() {
567                        @Override
568                        public void run() {
569                            openMessage(mailboxId, messageId);
570                        }
571                    });
572                } else if (COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) {
573                    // "next_view", <widget id>
574                    EmailWidget widget = sWidgetMap.get((int)arg1);
575                    if (widget != null) {
576                        widget.switchToNextView();
577                    }
578                }
579            } catch (NumberFormatException e) {
580                // Shouldn't happen as we construct all of the Uri's
581            }
582            return Service.START_NOT_STICKY;
583        }
584
585        private void openMessage(long mailboxId, long messageId) {
586            // TODO Use narrower projection.
587            Mailbox mailbox = Mailbox.restoreMailboxWithId(this, mailboxId);
588            if (mailbox == null) {
589                return;
590            }
591            startActivity(Welcome.createOpenMessageIntent(this, mailbox.mAccountKey, mailboxId,
592                    messageId));
593        }
594    }
595}
596