EmailWidget.java revision 2fbb3db5d86210d03175ce77ff08c989a96c5864
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.R;
21import com.android.email.ResourceHelper;
22import com.android.email.activity.MessageCompose;
23import com.android.email.activity.UiUtilities;
24import com.android.email.activity.Welcome;
25import com.android.email.provider.WidgetProvider.WidgetService;
26import com.android.emailcommon.provider.EmailContent.Mailbox;
27import com.android.emailcommon.provider.EmailContent.Message;
28import com.android.emailcommon.utility.Utility;
29
30import android.app.PendingIntent;
31import android.appwidget.AppWidgetManager;
32import android.content.ContentUris;
33import android.content.Context;
34import android.content.Intent;
35import android.content.Loader;
36import android.content.Loader.OnLoadCompleteListener;
37import android.content.res.Resources;
38import android.database.Cursor;
39import android.graphics.Typeface;
40import android.net.Uri;
41import android.net.Uri.Builder;
42import android.os.AsyncTask;
43import android.text.Spannable;
44import android.text.SpannableString;
45import android.text.SpannableStringBuilder;
46import android.text.TextUtils;
47import android.text.format.DateUtils;
48import android.text.style.AbsoluteSizeSpan;
49import android.text.style.ForegroundColorSpan;
50import android.text.style.StyleSpan;
51import android.util.Log;
52import android.view.View;
53import android.widget.RemoteViews;
54import android.widget.RemoteViewsService;
55
56import java.util.List;
57
58/**
59 * The email widget.
60 *
61 * Threading notes:
62 * - All methods must be called on the UI thread, except for {@link WidgetUpdater#doInBackground}.
63 * - {@link WidgetUpdater#doInBackground} must not read/write any members of {@link EmailWidget}.
64 * - (So no synchronizations are required in this class)
65 */
66public class EmailWidget implements RemoteViewsService.RemoteViewsFactory,
67        OnLoadCompleteListener<Cursor> {
68    public static final String TAG = "EmailWidget";
69
70    /**
71     * When handling clicks in a widget ListView, a single PendingIntent template is provided to
72     * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent"
73     * on each list element; when a click is received, this "fillInIntent" is merged with the
74     * PendingIntent using Intent.fillIn().  Since this mechanism does NOT preserve the Extras
75     * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its
76     * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via
77     * Intent.setDataAndType()
78     *
79     * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value
80     * is entirely arbitrary.
81     *
82     * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only
83     * in the requirement that it be syntactically valid.
84     *
85     * We use the following convention for our commands:
86     *     widget://command/<command>/<arg1>[/<arg2>]
87     */
88    private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data";
89
90    private static final Uri COMMAND_URI = Uri.parse("widget://command");
91
92    // Command names and Uri's built upon COMMAND_URI
93    private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view";
94    private static final Uri COMMAND_URI_SWITCH_LIST_VIEW =
95            COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build();
96    private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message";
97    private static final Uri COMMAND_URI_VIEW_MESSAGE =
98            COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build();
99
100    private static final int MAX_MESSAGE_LIST_COUNT = 25;
101
102    private static String sSubjectSnippetDivider;
103    private static String sConfigureText;
104    private static int sSenderFontSize;
105    private static int sSubjectFontSize;
106    private static int sDateFontSize;
107    private static int sDefaultTextColor;
108    private static int sLightTextColor;
109
110    private final Context mContext;
111    private final AppWidgetManager mWidgetManager;
112
113    // The widget identifier
114    private final int mWidgetId;
115
116    // The widget's loader (derived from ThrottlingCursorLoader)
117    private final EmailWidgetLoader mLoader;
118    private final ResourceHelper mResourceHelper;
119
120    /**
121     * The cursor for the messages, with some extra info such as the number of accounts.
122     *
123     * Note this cursor can be closed any time by the loader.  Always use {@link #isCursorValid()}
124     * before touching its contents.
125     */
126    private EmailWidgetLoader.CursorWithCounts mCursor;
127
128    /** The current view type */
129    /* package */ WidgetView mWidgetView = WidgetView.UNINITIALIZED_VIEW;
130
131    public EmailWidget(Context context, int _widgetId) {
132        super();
133        if (Email.DEBUG) {
134            Log.d(TAG, "Creating EmailWidget with id = " + _widgetId);
135        }
136        mContext = context.getApplicationContext();
137        mWidgetManager = AppWidgetManager.getInstance(mContext);
138
139        mWidgetId = _widgetId;
140        mLoader = new EmailWidgetLoader(mContext);
141        mLoader.registerListener(0, this);
142        if (sSubjectSnippetDivider == null) {
143            // Initialize string, color, dimension resources
144            Resources res = mContext.getResources();
145            sSubjectSnippetDivider =
146                res.getString(R.string.message_list_subject_snippet_divider);
147            sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size);
148            sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size);
149            sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size);
150            sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
151            sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
152            sLightTextColor = res.getColor(R.color.widget_light_text_color);
153            sConfigureText =  res.getString(R.string.widget_other_views);
154        }
155        mResourceHelper = ResourceHelper.getInstance(mContext);
156    }
157
158    public void start() {
159        // The default view is UNINITIALIZED_VIEW, and we switch to the next one, which should
160        // be the initial view.  (the first view shown to the user.)
161        switchView();
162    }
163
164    private boolean isCursorValid() {
165        return mCursor != null && !mCursor.isClosed();
166    }
167
168    /**
169     * Called when the loader finished loading data.  Update the widget.
170     */
171    @Override
172    public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
173        // Save away the cursor
174        mCursor = (EmailWidgetLoader.CursorWithCounts) cursor;
175        mWidgetView = mLoader.getLoadingWidgetView();
176
177        RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget);
178        updateHeader();
179        setupTitleAndCount(views);
180        mWidgetManager.partiallyUpdateAppWidget(mWidgetId, views);
181        mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
182    }
183
184    /**
185     * Start loading the data.  At this point nothing on the widget changes -- the current view
186     * will remain valid until the loader loads the latest data.
187     */
188    private void loadView(WidgetView view) {
189        mLoader.load(view);
190    }
191
192    /**
193     * Convenience method for creating an onClickPendingIntent that executes a command via
194     * our command Uri.  Used for the "next view" command; appends the widget id to the command
195     * Uri.
196     *
197     * @param views The RemoteViews we're inflating
198     * @param buttonId the id of the button view
199     * @param data the command Uri
200     */
201    private void setCommandIntent(RemoteViews views, int buttonId, Uri data) {
202        Intent intent = new Intent(mContext, WidgetService.class);
203        intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), WIDGET_DATA_MIME_TYPE);
204        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
205                PendingIntent.FLAG_UPDATE_CURRENT);
206        views.setOnClickPendingIntent(buttonId, pendingIntent);
207    }
208
209    /**
210     * Convenience method for creating an onClickPendingIntent that launches another activity
211     * directly.
212     *
213     * @param views The RemoteViews we're inflating
214     * @param buttonId the id of the button view
215     * @param intent The intent to be used when launching the activity
216     */
217    private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) {
218        PendingIntent pendingIntent =
219                PendingIntent.getActivity(mContext, 0, intent, 0);
220        views.setOnClickPendingIntent(buttonId, pendingIntent);
221    }
222
223    /**
224     * Convenience method for constructing a fillInIntent for a given list view element.
225     * Appends the command and any arguments to a base Uri.
226     *
227     * @param views the RemoteViews we are inflating
228     * @param viewId the id of the view
229     * @param baseUri the base uri for the command
230     * @param args any arguments to the command
231     */
232    private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) {
233        Intent intent = new Intent();
234        Builder builder = baseUri.buildUpon();
235        for (String arg: args) {
236            builder.appendPath(arg);
237        }
238        intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
239        views.setOnClickFillInIntent(viewId, intent);
240    }
241
242    /**
243     * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to
244     * handle intents created by remote views.
245     */
246    public static boolean processIntent(Context context, Intent intent) {
247        final Uri data = intent.getData();
248        if (data == null) {
249            return false;
250        }
251        List<String> pathSegments = data.getPathSegments();
252        // Our path segments are <command>, <arg1> [, <arg2>]
253        // First, a quick check of Uri validity
254        if (pathSegments.size() < 2) {
255            throw new IllegalArgumentException();
256        }
257        String command = pathSegments.get(0);
258        // Ignore unknown action names
259        try {
260            final long arg1 = Long.parseLong(pathSegments.get(1));
261            if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
262                // "view", <message id>, <mailbox id>
263                openMessage(context, Long.parseLong(pathSegments.get(2)), arg1);
264            } else if (EmailWidget.COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) {
265                // "next_view", <widget id>
266                EmailWidget widget = WidgetManager.getInstance().get((int)arg1);
267                if (widget != null) {
268                    widget.switchView();
269                }
270            }
271        } catch (NumberFormatException e) {
272            // Shouldn't happen as we construct all of the Uri's
273            return false;
274        }
275        return true;
276    }
277
278    private static void openMessage(final Context context, final long mailboxId,
279            final long messageId) {
280        Utility.runAsync(new Runnable() {
281            @Override
282            public void run() {
283                Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
284                if (mailbox == null) return;
285                context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
286                        mailboxId, messageId));
287            }
288        });
289    }
290
291    private void setupTitleAndCount(RemoteViews views) {
292        // Set up the title (view type + count of messages)
293        views.setTextViewText(R.id.widget_title, mWidgetView.getTitle(mContext));
294        views.setTextViewText(R.id.widget_tap, sConfigureText);
295        String count = "";
296        if (isCursorValid()) {
297            count = UiUtilities.getMessageCountForUi(mContext, mCursor.getMessageCount(), false);
298        }
299        views.setTextViewText(R.id.widget_count, count);
300    }
301    /**
302     * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
303     * message list)
304     */
305    private void updateHeader() {
306        if (Email.DEBUG) {
307            Log.d(TAG, "updateWidget " + mWidgetId);
308        }
309
310        // Get the widget layout
311        RemoteViews views =
312                new RemoteViews(mContext.getPackageName(), R.layout.widget);
313
314        // Set up the list with an adapter
315        Intent intent = new Intent(mContext, WidgetService.class);
316        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
317        intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
318        views.setRemoteAdapter(mWidgetId, R.id.message_list, intent);
319
320        setupTitleAndCount(views);
321
322        if (!isCursorValid() || mCursor.getAccountCount() == 0) {
323            // Hide compose icon & show "touch to configure" text
324            views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
325            views.setViewVisibility(R.id.message_list, View.GONE);
326            views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE);
327            // Create click intent for "touch to configure" target
328            intent = Welcome.createOpenAccountInboxIntent(mContext, -1);
329            setActivityIntent(views, R.id.tap_to_configure, intent);
330        } else {
331            // Show compose icon & message list
332            views.setViewVisibility(R.id.widget_compose, View.VISIBLE);
333            views.setViewVisibility(R.id.message_list, View.VISIBLE);
334            views.setViewVisibility(R.id.tap_to_configure, View.GONE);
335            // Create click intent for "compose email" target
336            intent = MessageCompose.getMessageComposeIntent(mContext, -1);
337            setActivityIntent(views, R.id.widget_compose, intent);
338        }
339        // Create click intent for "view rotation" target
340        setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW);
341
342        // Use a bare intent for our template; we need to fill everything in
343        intent = new Intent(mContext, WidgetService.class);
344        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
345                PendingIntent.FLAG_UPDATE_CURRENT);
346        views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
347
348        // And finally update the widget
349        mWidgetManager.updateAppWidget(mWidgetId, views);
350    }
351
352    /**
353     * Add size and color styling to text
354     *
355     * @param text the text to style
356     * @param size the font size for this text
357     * @param color the color for this text
358     * @return a CharSequence quitable for use in RemoteViews.setTextViewText()
359     */
360    private CharSequence addStyle(CharSequence text, int size, int color) {
361        SpannableStringBuilder builder = new SpannableStringBuilder(text);
362        builder.setSpan(
363                new AbsoluteSizeSpan(size), 0, text.length(),
364                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
365        if (color != 0) {
366            builder.setSpan(new ForegroundColorSpan(color), 0, text.length(),
367                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
368        }
369        return builder;
370    }
371
372    /**
373     * Create styled text for our combination subject and snippet
374     *
375     * @param subject the message's subject (or null)
376     * @param snippet the message's snippet (or null)
377     * @param read whether or not the message is read
378     * @return a CharSequence suitable for use in RemoteViews.setTextViewText()
379     */
380    private CharSequence getStyledSubjectSnippet (String subject, String snippet,
381            boolean read) {
382        SpannableStringBuilder ssb = new SpannableStringBuilder();
383        boolean hasSubject = false;
384        if (!TextUtils.isEmpty(subject)) {
385            SpannableString ss = new SpannableString(subject);
386            ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
387                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
388            ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(),
389                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
390            ssb.append(ss);
391            hasSubject = true;
392        }
393        if (!TextUtils.isEmpty(snippet)) {
394            if (hasSubject) {
395                ssb.append(sSubjectSnippetDivider);
396            }
397            SpannableString ss = new SpannableString(snippet);
398            ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(),
399                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
400            ssb.append(ss);
401        }
402        return addStyle(ssb, sSubjectFontSize, 0);
403    }
404
405    @Override
406    public RemoteViews getViewAt(int position) {
407        // Use the cursor to set up the widget
408        if (!isCursorValid() || !mCursor.moveToPosition(position)) {
409            return getLoadingView();
410        }
411        RemoteViews views =
412            new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item);
413        boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1;
414        int drawableId = R.drawable.widget_read_conversation_selector;
415        if (isUnread) {
416            drawableId = R.drawable.widget_unread_conversation_selector;
417        }
418        views.setInt(R.id.widget_message, "setBackgroundResource", drawableId);
419
420        // Add style to sender
421        SpannableStringBuilder from =
422            new SpannableStringBuilder(mCursor.getString(
423                    EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME));
424        from.setSpan(
425                isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0,
426                from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
427        CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor);
428        views.setTextViewText(R.id.widget_from, styledFrom);
429
430        long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP);
431        // Get a nicely formatted date string (relative to today)
432        String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
433        // Add style to date
434        CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
435        views.setTextViewText(R.id.widget_date, styledDate);
436
437        // Add style to subject/snippet
438        String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT);
439        String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET);
440        CharSequence subjectAndSnippet =
441            getStyledSubjectSnippet(subject, snippet, !isUnread);
442        views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
443
444        int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS);
445        boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0;
446        views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE);
447
448        boolean hasAttachment =
449                mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0;
450        views.setViewVisibility(R.id.widget_attachment,
451                hasAttachment ? View.VISIBLE : View.GONE);
452
453        if (mCursor.getAccountCount() <= 1 || mWidgetView.isPerAccount()) {
454            views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
455        } else {
456            long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY);
457            int colorId = mResourceHelper.getAccountColorId(accountId);
458            if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) {
459                // Color defined by resource ID, so, use it
460                views.setViewVisibility(R.id.color_chip, View.VISIBLE);
461                views.setImageViewResource(R.id.color_chip, colorId);
462            } else {
463                // Color not defined by resource ID, nothing we can do, so, hide the chip
464                views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
465            }
466        }
467
468        // Set button intents for view, reply, and delete
469        String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID);
470        String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY);
471        setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE,
472                messageId, mailboxId);
473
474        return views;
475    }
476
477    @Override
478    public int getCount() {
479        if (!isCursorValid()) return 0;
480        return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
481    }
482
483    @Override
484    public long getItemId(int position) {
485        return position;
486    }
487
488    @Override
489    public RemoteViews getLoadingView() {
490        RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
491        view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading));
492        return view;
493    }
494
495    @Override
496    public int getViewTypeCount() {
497        // Regular list view and the "loading" view
498        return 2;
499    }
500
501    @Override
502    public boolean hasStableIds() {
503        return true;
504    }
505
506    @Override
507    public void onDataSetChanged() {
508    }
509
510    public void onDeleted() {
511        if (mLoader != null) {
512            mLoader.reset();
513        }
514        WidgetManager.getInstance().remove(mWidgetId);
515    }
516
517    @Override
518    public void onDestroy() {
519        if (mLoader != null) {
520            mLoader.reset();
521        }
522        WidgetManager.getInstance().remove(mWidgetId);
523    }
524
525    @Override
526    public void onCreate() {
527    }
528
529    /**
530     * Update the widget.  If the current view is invalid, switch to the next view, then update.
531     */
532    /* package */ void validateAndUpdate() {
533        new WidgetUpdater(false).execute();
534    }
535
536    /**
537     * Switch to the next view.
538     */
539    /* package */ void switchView() {
540        new WidgetUpdater(true).execute();
541    }
542
543    /**
544     * Update the widget.  If {@code switchToNextView} is set true, or the current view is invalid,
545     * switch to the next view.
546     */
547    private class WidgetUpdater extends AsyncTask<Void, Void, WidgetView> {
548        private final WidgetView mCurrentView;
549        private final boolean mSwitchToNextView;
550
551        public WidgetUpdater(boolean switchToNextView) {
552            mCurrentView = mWidgetView;
553            mSwitchToNextView = switchToNextView;
554        }
555
556        @Override
557        protected WidgetView doInBackground(Void... params) {
558            if (mSwitchToNextView || !mCurrentView.isValid(mContext)) {
559                return mCurrentView.getNext(mContext);
560            } else {
561                return mCurrentView; // Reload the same view.
562            }
563        }
564
565        @Override
566        protected void onPostExecute(WidgetView nextView) {
567            if (nextView != null) {
568                loadView(nextView);
569            }
570        }
571    }
572}