EmailWidget.java revision d94522c6d9b3afad6b6796bc58d5a31b11d7b16d
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 android.app.PendingIntent;
20import android.appwidget.AppWidgetManager;
21import android.content.Context;
22import android.content.Intent;
23import android.content.Loader;
24import android.content.Loader.OnLoadCompleteListener;
25import android.content.res.Resources;
26import android.database.Cursor;
27import android.graphics.Typeface;
28import android.net.Uri;
29import android.net.Uri.Builder;
30import android.text.Spannable;
31import android.text.SpannableString;
32import android.text.SpannableStringBuilder;
33import android.text.TextUtils;
34import android.text.format.DateUtils;
35import android.text.style.AbsoluteSizeSpan;
36import android.text.style.ForegroundColorSpan;
37import android.text.style.StyleSpan;
38import android.util.Log;
39import android.view.View;
40import android.widget.RemoteViews;
41import android.widget.RemoteViewsService;
42
43import com.android.email.Email;
44import com.android.email.R;
45import com.android.email.ResourceHelper;
46import com.android.email.activity.MessageCompose;
47import com.android.email.activity.UiUtilities;
48import com.android.email.activity.Welcome;
49import com.android.email.provider.WidgetProvider.WidgetService;
50import com.android.emailcommon.Logging;
51import com.android.emailcommon.provider.Account;
52import com.android.emailcommon.provider.EmailContent.Message;
53import com.android.emailcommon.provider.Mailbox;
54import com.android.emailcommon.utility.EmailAsyncTask;
55
56import java.util.List;
57
58/**
59 * The email widget.
60 * <p><em>NOTE</em>: All methods must be called on the UI thread so synchronization is NOT required
61 * in this class)
62 */
63public class EmailWidget implements RemoteViewsService.RemoteViewsFactory,
64        OnLoadCompleteListener<Cursor> {
65    public static final String TAG = "EmailWidget";
66
67    /**
68     * When handling clicks in a widget ListView, a single PendingIntent template is provided to
69     * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent"
70     * on each list element; when a click is received, this "fillInIntent" is merged with the
71     * PendingIntent using Intent.fillIn().  Since this mechanism does NOT preserve the Extras
72     * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its
73     * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via
74     * Intent.setDataAndType()
75     *
76     * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value
77     * is entirely arbitrary.
78     *
79     * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only
80     * in the requirement that it be syntactically valid.
81     *
82     * We use the following convention for our commands:
83     *     widget://command/<command>/<arg1>[/<arg2>]
84     */
85    private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data";
86
87    private static final Uri COMMAND_URI = Uri.parse("widget://command");
88
89    // Command names and Uri's built upon COMMAND_URI
90    private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message";
91    private static final Uri COMMAND_URI_VIEW_MESSAGE =
92            COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build();
93
94    // TODO Can this be moved to the loader and made a database 'LIMIT'?
95    private static final int MAX_MESSAGE_LIST_COUNT = 25;
96
97    private static String sSubjectSnippetDivider;
98    private static int sSenderFontSize;
99    private static int sSubjectFontSize;
100    private static int sDateFontSize;
101    private static int sDefaultTextColor;
102    private static int sLightTextColor;
103
104    private final Context mContext;
105    private final AppWidgetManager mWidgetManager;
106
107    // The widget identifier
108    private final int mWidgetId;
109
110    // The widget's loader (derived from ThrottlingCursorLoader)
111    private final EmailWidgetLoader mLoader;
112    private final ResourceHelper mResourceHelper;
113
114    /** The account ID of this widget. May be {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */
115    private long mAccountId = Account.NO_ACCOUNT;
116    /** The display name of this account */
117    private String mAccountName;
118    /** The display name of this mailbox */
119    private String mMailboxName;
120
121    /**
122     * The cursor for the messages, with some extra info such as the number of accounts.
123     *
124     * Note this cursor can be closed any time by the loader.  Always use {@link #isCursorValid()}
125     * before touching its contents.
126     */
127    private EmailWidgetLoader.WidgetCursor mCursor;
128
129    public EmailWidget(Context context, int _widgetId) {
130        super();
131        if (Email.DEBUG) {
132            Log.d(TAG, "Creating EmailWidget with id = " + _widgetId);
133        }
134        mContext = context.getApplicationContext();
135        mWidgetManager = AppWidgetManager.getInstance(mContext);
136
137        mWidgetId = _widgetId;
138        mLoader = new EmailWidgetLoader(mContext);
139        mLoader.registerListener(0, this);
140        if (sSubjectSnippetDivider == null) {
141            // Initialize string, color, dimension resources
142            Resources res = mContext.getResources();
143            sSubjectSnippetDivider =
144                res.getString(R.string.message_list_subject_snippet_divider);
145            sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size);
146            sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size);
147            sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size);
148            sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
149            sDefaultTextColor = res.getColor(R.color.widget_default_text_color);
150            sLightTextColor = res.getColor(R.color.widget_light_text_color);
151        }
152        mResourceHelper = ResourceHelper.getInstance(mContext);
153    }
154
155    /**
156     * Start loading the data.  At this point nothing on the widget changes -- the current view
157     * will remain valid until the loader loads the latest data.
158     */
159    public void start() {
160        long accountId = WidgetManager.loadAccountIdPref(mContext, mWidgetId);
161        long mailboxId = WidgetManager.loadMailboxIdPref(mContext, mWidgetId);
162        // Legacy support; if preferences haven't been saved for this widget, load something
163        if (accountId == Account.NO_ACCOUNT) {
164            accountId = Account.ACCOUNT_ID_COMBINED_VIEW;
165            mailboxId = Mailbox.QUERY_ALL_INBOXES;
166        }
167        mAccountId = accountId;
168        mLoader.load(mAccountId, mailboxId);
169    }
170
171    /**
172     * Resets the data in the widget and forces a reload.
173     */
174    public void reset() {
175        mLoader.reset();
176        start();
177    }
178
179    private boolean isCursorValid() {
180        return mCursor != null && !mCursor.isClosed();
181    }
182
183    /**
184     * Called when the loader finished loading data.  Update the widget.
185     */
186    @Override
187    public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
188        mCursor = (EmailWidgetLoader.WidgetCursor) cursor;   // Save away the cursor
189        mAccountName = mCursor.getAccountName();
190        mMailboxName = mCursor.getMailboxName();
191        updateHeader();
192        mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
193    }
194
195    /**
196     * Convenience method for creating an onClickPendingIntent that launches another activity
197     * directly.
198     *
199     * @param views The RemoteViews we're inflating
200     * @param buttonId the id of the button view
201     * @param intent The intent to be used when launching the activity
202     */
203    private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) {
204        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // just in case intent comes without it
205        PendingIntent pendingIntent =
206                PendingIntent.getActivity(mContext, (int) mAccountId, intent,
207                        PendingIntent.FLAG_UPDATE_CURRENT);
208        views.setOnClickPendingIntent(buttonId, pendingIntent);
209    }
210
211    /**
212     * Convenience method for constructing a fillInIntent for a given list view element.
213     * Appends the command and any arguments to a base Uri.
214     *
215     * @param views the RemoteViews we are inflating
216     * @param viewId the id of the view
217     * @param baseUri the base uri for the command
218     * @param args any arguments to the command
219     */
220    private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) {
221        Intent intent = new Intent();
222        Builder builder = baseUri.buildUpon();
223        for (String arg: args) {
224            builder.appendPath(arg);
225        }
226        intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
227        views.setOnClickFillInIntent(viewId, intent);
228    }
229
230    /**
231     * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to
232     * handle intents created by remote views.
233     */
234    public static boolean processIntent(Context context, Intent intent) {
235        final Uri data = intent.getData();
236        if (data == null) {
237            return false;
238        }
239        List<String> pathSegments = data.getPathSegments();
240        // Our path segments are <command>, <arg1> [, <arg2>]
241        // First, a quick check of Uri validity
242        if (pathSegments.size() < 2) {
243            throw new IllegalArgumentException();
244        }
245        String command = pathSegments.get(0);
246        // Ignore unknown action names
247        try {
248            final long arg1 = Long.parseLong(pathSegments.get(1));
249            if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
250                // "view", <message id>, <mailbox id>
251                openMessage(context, Long.parseLong(pathSegments.get(2)), arg1);
252            }
253        } catch (NumberFormatException e) {
254            // Shouldn't happen as we construct all of the Uri's
255            return false;
256        }
257        return true;
258    }
259
260    private static void openMessage(final Context context, final long mailboxId,
261            final long messageId) {
262        EmailAsyncTask.runAsyncParallel(new Runnable() {
263            @Override
264            public void run() {
265                Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
266                if (mailbox == null) return;
267                context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
268                        mailboxId, messageId));
269            }
270        });
271    }
272
273    private void setTextViewTextAndDesc(RemoteViews views, final int id, String text) {
274        views.setTextViewText(id, text);
275        views.setContentDescription(id, text);
276    }
277
278    private void setupTitleAndCount(RemoteViews views) {
279        // Set up the title (view type + count of messages)
280        setTextViewTextAndDesc(views, R.id.widget_title, mMailboxName);
281        views.setViewVisibility(R.id.widget_tap, View.VISIBLE);
282        setTextViewTextAndDesc(views, R.id.widget_tap, mAccountName);
283        String count = "";
284        if (isCursorValid()) {
285            count = UiUtilities.getMessageCountForUi(mContext, mCursor.getMessageCount(), false);
286        }
287        setTextViewTextAndDesc(views, R.id.widget_count, count);
288    }
289
290    /**
291     * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
292     * message list)
293     */
294    private void updateHeader() {
295        if (Email.DEBUG) {
296            Log.d(TAG, "#updateHeader(); widgetId: " + mWidgetId);
297        }
298
299        // Get the widget layout
300        RemoteViews views =
301                new RemoteViews(mContext.getPackageName(), R.layout.widget);
302
303        // Set up the list with an adapter
304        Intent intent = new Intent(mContext, WidgetService.class);
305        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
306        intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
307        views.setRemoteAdapter(R.id.message_list, intent);
308
309        setupTitleAndCount(views);
310
311        if (isCursorValid()) {
312            // Show compose icon & message list
313            if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
314                // Don't allow compose for "combined" view
315                views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
316            } else {
317                views.setViewVisibility(R.id.widget_compose, View.VISIBLE);
318            }
319            views.setViewVisibility(R.id.message_list, View.VISIBLE);
320            views.setViewVisibility(R.id.tap_to_configure, View.GONE);
321            // Create click intent for "compose email" target
322            intent = MessageCompose.getMessageComposeIntent(mContext, mAccountId);
323            intent.putExtra(MessageCompose.EXTRA_FROM_WIDGET, true);
324            setActivityIntent(views, R.id.widget_compose, intent);
325            // Create click intent for logo to open inbox
326            intent = Welcome.createOpenAccountInboxIntent(mContext, mAccountId);
327            setActivityIntent(views, R.id.widget_logo, intent);
328        } else {
329            // TODO This really should never happen ... probably can remove the else block
330            // Hide compose icon & show "touch to configure" text
331            views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
332            views.setViewVisibility(R.id.message_list, View.GONE);
333            views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE);
334            // Create click intent for "touch to configure" target
335            intent = Welcome.createOpenAccountInboxIntent(mContext, -1);
336            setActivityIntent(views, R.id.tap_to_configure, intent);
337        }
338
339        // Use a bare intent for our template; we need to fill everything in
340        intent = new Intent(mContext, WidgetService.class);
341        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
342                PendingIntent.FLAG_UPDATE_CURRENT);
343        views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
344
345        // And finally update the widget
346        mWidgetManager.updateAppWidget(mWidgetId, views);
347    }
348
349    /**
350     * Add size and color styling to text
351     *
352     * @param text the text to style
353     * @param size the font size for this text
354     * @param color the color for this text
355     * @return a CharSequence quitable for use in RemoteViews.setTextViewText()
356     */
357    private CharSequence addStyle(CharSequence text, int size, int color) {
358        SpannableStringBuilder builder = new SpannableStringBuilder(text);
359        builder.setSpan(
360                new AbsoluteSizeSpan(size), 0, text.length(),
361                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
362        if (color != 0) {
363            builder.setSpan(new ForegroundColorSpan(color), 0, text.length(),
364                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
365        }
366        return builder;
367    }
368
369    /**
370     * Create styled text for our combination subject and snippet
371     *
372     * @param subject the message's subject (or null)
373     * @param snippet the message's snippet (or null)
374     * @param read whether or not the message is read
375     * @return a CharSequence suitable for use in RemoteViews.setTextViewText()
376     */
377    private CharSequence getStyledSubjectSnippet(String subject, String snippet, boolean read) {
378        SpannableStringBuilder ssb = new SpannableStringBuilder();
379        boolean hasSubject = false;
380        if (!TextUtils.isEmpty(subject)) {
381            SpannableString ss = new SpannableString(subject);
382            ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
383                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
384            ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(),
385                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
386            ssb.append(ss);
387            hasSubject = true;
388        }
389        if (!TextUtils.isEmpty(snippet)) {
390            if (hasSubject) {
391                ssb.append(sSubjectSnippetDivider);
392            }
393            SpannableString ss = new SpannableString(snippet);
394            ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(),
395                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
396            ssb.append(ss);
397        }
398        return addStyle(ssb, sSubjectFontSize, 0);
399    }
400
401    @Override
402    public RemoteViews getViewAt(int position) {
403        // Use the cursor to set up the widget
404        if (!isCursorValid() || !mCursor.moveToPosition(position)) {
405            return getLoadingView();
406        }
407        RemoteViews views =
408            new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item);
409        boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1;
410        int drawableId = R.drawable.message_list_read_selector;
411        if (isUnread) {
412            drawableId = R.drawable.message_list_unread_selector;
413        }
414        views.setInt(R.id.widget_message, "setBackgroundResource", drawableId);
415
416        // Add style to sender
417        String rawSender =
418                mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME)
419                    ? ""    // an empty string
420                    : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME);
421        SpannableStringBuilder from = new SpannableStringBuilder(rawSender);
422        from.setSpan(
423                isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0,
424                from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
425        CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor);
426        views.setTextViewText(R.id.widget_from, styledFrom);
427        views.setContentDescription(R.id.widget_from, rawSender);
428
429        long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP);
430        // Get a nicely formatted date string (relative to today)
431        String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
432        // Add style to date
433        CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
434        views.setTextViewText(R.id.widget_date, styledDate);
435        views.setContentDescription(R.id.widget_date, date);
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 = getStyledSubjectSnippet(subject, snippet, !isUnread);
441        views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
442        views.setContentDescription(R.id.widget_subject, subject);
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 (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) {
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 (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
512            Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId);
513        }
514
515        if (mLoader != null) {
516            mLoader.reset();
517        }
518    }
519
520    @Override
521    public void onDestroy() {
522        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
523            Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId);
524        }
525
526        if (mLoader != null) {
527            mLoader.reset();
528        }
529    }
530
531    @Override
532    public void onCreate() {
533        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
534            Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId);
535        }
536    }
537
538    @Override
539    public String toString() {
540        return "View=" + mAccountName;
541    }
542}
543