EmailWidget.java revision f5418f1f93b02e7fab9f15eb201800b65510998e
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.Logging;
27import com.android.emailcommon.provider.Account;
28import com.android.emailcommon.provider.EmailContent.Message;
29import com.android.emailcommon.provider.Mailbox;
30import com.android.emailcommon.utility.EmailAsyncTask;
31
32import android.app.PendingIntent;
33import android.appwidget.AppWidgetManager;
34import android.content.Context;
35import android.content.Intent;
36import android.content.Loader;
37import android.content.Loader.OnLoadCompleteListener;
38import android.content.res.Resources;
39import android.database.Cursor;
40import android.graphics.Typeface;
41import android.net.Uri;
42import android.net.Uri.Builder;
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 * <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    @SuppressWarnings("unused")
99    private static String sConfigureText;
100    private static int sSenderFontSize;
101    private static int sSubjectFontSize;
102    private static int sDateFontSize;
103    private static int sDefaultTextColor;
104    private static int sLightTextColor;
105
106    private final Context mContext;
107    private final AppWidgetManager mWidgetManager;
108
109    // The widget identifier
110    private final int mWidgetId;
111
112    // The widget's loader (derived from ThrottlingCursorLoader)
113    private final EmailWidgetLoader mLoader;
114    private final ResourceHelper mResourceHelper;
115
116    /** The account ID of this widget. May be {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */
117    private long mAccountId = Account.NO_ACCOUNT;
118    /** The display name of this account */
119    private String mAccountName;
120    /** The display name of this mailbox */
121    private String mMailboxName;
122
123    /**
124     * The cursor for the messages, with some extra info such as the number of accounts.
125     *
126     * Note this cursor can be closed any time by the loader.  Always use {@link #isCursorValid()}
127     * before touching its contents.
128     */
129    private EmailWidgetLoader.WidgetCursor mCursor;
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    /**
159     * Start loading the data.  At this point nothing on the widget changes -- the current view
160     * will remain valid until the loader loads the latest data.
161     */
162    public void start() {
163        long accountId = WidgetManager.loadAccountIdPref(mContext, mWidgetId);
164        long mailboxId = WidgetManager.loadMailboxIdPref(mContext, mWidgetId);
165        // Legacy support; if preferences haven't been saved for this widget, load something
166        if (accountId == Account.NO_ACCOUNT) {
167            accountId = Account.ACCOUNT_ID_COMBINED_VIEW;
168            mailboxId = Mailbox.QUERY_ALL_INBOXES;
169        }
170        mAccountId = accountId;
171        mLoader.load(mAccountId, mailboxId);
172    }
173
174    private boolean isCursorValid() {
175        return mCursor != null && !mCursor.isClosed();
176    }
177
178    /**
179     * Called when the loader finished loading data.  Update the widget.
180     */
181    @Override
182    public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
183        mCursor = (EmailWidgetLoader.WidgetCursor) cursor;   // Save away the cursor
184        mAccountName = mCursor.getAccountName();
185        mMailboxName = mCursor.getMailboxName();
186        updateHeader();
187        mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
188    }
189
190    /**
191     * Convenience method for creating an onClickPendingIntent that launches another activity
192     * directly.
193     *
194     * @param views The RemoteViews we're inflating
195     * @param buttonId the id of the button view
196     * @param intent The intent to be used when launching the activity
197     */
198    private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) {
199        PendingIntent pendingIntent =
200                PendingIntent.getActivity(mContext, 0, intent, 0);
201        views.setOnClickPendingIntent(buttonId, pendingIntent);
202    }
203
204    /**
205     * Convenience method for constructing a fillInIntent for a given list view element.
206     * Appends the command and any arguments to a base Uri.
207     *
208     * @param views the RemoteViews we are inflating
209     * @param viewId the id of the view
210     * @param baseUri the base uri for the command
211     * @param args any arguments to the command
212     */
213    private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) {
214        Intent intent = new Intent();
215        Builder builder = baseUri.buildUpon();
216        for (String arg: args) {
217            builder.appendPath(arg);
218        }
219        intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
220        views.setOnClickFillInIntent(viewId, intent);
221    }
222
223    /**
224     * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to
225     * handle intents created by remote views.
226     */
227    public static boolean processIntent(Context context, Intent intent) {
228        final Uri data = intent.getData();
229        if (data == null) {
230            return false;
231        }
232        List<String> pathSegments = data.getPathSegments();
233        // Our path segments are <command>, <arg1> [, <arg2>]
234        // First, a quick check of Uri validity
235        if (pathSegments.size() < 2) {
236            throw new IllegalArgumentException();
237        }
238        String command = pathSegments.get(0);
239        // Ignore unknown action names
240        try {
241            final long arg1 = Long.parseLong(pathSegments.get(1));
242            if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
243                // "view", <message id>, <mailbox id>
244                openMessage(context, Long.parseLong(pathSegments.get(2)), arg1);
245            }
246        } catch (NumberFormatException e) {
247            // Shouldn't happen as we construct all of the Uri's
248            return false;
249        }
250        return true;
251    }
252
253    private static void openMessage(final Context context, final long mailboxId,
254            final long messageId) {
255        EmailAsyncTask.runAsyncParallel(new Runnable() {
256            @Override
257            public void run() {
258                Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
259                if (mailbox == null) return;
260                context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
261                        mailboxId, messageId));
262            }
263        });
264    }
265
266    private void setupTitleAndCount(RemoteViews views) {
267        // Set up the title (view type + count of messages)
268        views.setTextViewText(R.id.widget_title, mMailboxName);
269        // TODO Temporary UX; need to make this visible and create the correct UX
270        //views.setTextViewText(R.id.widget_tap, sConfigureText);
271        views.setViewVisibility(R.id.widget_tap, View.VISIBLE);
272        views.setTextViewText(R.id.widget_tap, mAccountName);
273        String count = "";
274        if (isCursorValid()) {
275            count = UiUtilities.getMessageCountForUi(mContext, mCursor.getMessageCount(), false);
276        }
277        views.setTextViewText(R.id.widget_count, count);
278    }
279
280    /**
281     * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
282     * message list)
283     */
284    private void updateHeader() {
285        if (Email.DEBUG) {
286            Log.d(TAG, "#updateHeader(); widgetId: " + mWidgetId);
287        }
288
289        // Get the widget layout
290        RemoteViews views =
291                new RemoteViews(mContext.getPackageName(), R.layout.widget);
292
293        // Set up the list with an adapter
294        Intent intent = new Intent(mContext, WidgetService.class);
295        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
296        intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
297        views.setRemoteAdapter(R.id.message_list, intent);
298
299        setupTitleAndCount(views);
300
301        if (isCursorValid()) {
302            // Show compose icon & message list
303            if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
304                // Don't allow compose for "combined" view
305                views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
306            } else {
307                views.setViewVisibility(R.id.widget_compose, View.VISIBLE);
308            }
309            views.setViewVisibility(R.id.message_list, View.VISIBLE);
310            views.setViewVisibility(R.id.tap_to_configure, View.GONE);
311            // Create click intent for "compose email" target
312            intent = MessageCompose.getMessageComposeIntent(mContext, -1);
313            setActivityIntent(views, R.id.widget_compose, intent);
314        } else {
315            // TODO This really should never happen ... probably can remove the else block
316            // Hide compose icon & show "touch to configure" text
317            views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
318            views.setViewVisibility(R.id.message_list, View.GONE);
319            views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE);
320            // Create click intent for "touch to configure" target
321            intent = Welcome.createOpenAccountInboxIntent(mContext, -1);
322            setActivityIntent(views, R.id.tap_to_configure, intent);
323        }
324
325        // Use a bare intent for our template; we need to fill everything in
326        intent = new Intent(mContext, WidgetService.class);
327        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
328                PendingIntent.FLAG_UPDATE_CURRENT);
329        views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
330
331        // And finally update the widget
332        mWidgetManager.updateAppWidget(mWidgetId, views);
333    }
334
335    /**
336     * Add size and color styling to text
337     *
338     * @param text the text to style
339     * @param size the font size for this text
340     * @param color the color for this text
341     * @return a CharSequence quitable for use in RemoteViews.setTextViewText()
342     */
343    private CharSequence addStyle(CharSequence text, int size, int color) {
344        SpannableStringBuilder builder = new SpannableStringBuilder(text);
345        builder.setSpan(
346                new AbsoluteSizeSpan(size), 0, text.length(),
347                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
348        if (color != 0) {
349            builder.setSpan(new ForegroundColorSpan(color), 0, text.length(),
350                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
351        }
352        return builder;
353    }
354
355    /**
356     * Create styled text for our combination subject and snippet
357     *
358     * @param subject the message's subject (or null)
359     * @param snippet the message's snippet (or null)
360     * @param read whether or not the message is read
361     * @return a CharSequence suitable for use in RemoteViews.setTextViewText()
362     */
363    private CharSequence getStyledSubjectSnippet(String subject, String snippet, boolean read) {
364        SpannableStringBuilder ssb = new SpannableStringBuilder();
365        boolean hasSubject = false;
366        if (!TextUtils.isEmpty(subject)) {
367            SpannableString ss = new SpannableString(subject);
368            ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
369                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
370            ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(),
371                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
372            ssb.append(ss);
373            hasSubject = true;
374        }
375        if (!TextUtils.isEmpty(snippet)) {
376            if (hasSubject) {
377                ssb.append(sSubjectSnippetDivider);
378            }
379            SpannableString ss = new SpannableString(snippet);
380            ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(),
381                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
382            ssb.append(ss);
383        }
384        return addStyle(ssb, sSubjectFontSize, 0);
385    }
386
387    @Override
388    public RemoteViews getViewAt(int position) {
389        // Use the cursor to set up the widget
390        if (!isCursorValid() || !mCursor.moveToPosition(position)) {
391            return getLoadingView();
392        }
393        RemoteViews views =
394            new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item);
395        boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1;
396        int drawableId = R.drawable.widget_read_conversation_selector;
397        if (isUnread) {
398            drawableId = R.drawable.widget_unread_conversation_selector;
399        }
400        views.setInt(R.id.widget_message, "setBackgroundResource", drawableId);
401
402        // Add style to sender
403        String cursorString =
404                mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME)
405                    ? ""    // an empty string
406                    : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME);
407        SpannableStringBuilder from = new SpannableStringBuilder(cursorString);
408        from.setSpan(
409                isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0,
410                from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
411        CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor);
412        views.setTextViewText(R.id.widget_from, styledFrom);
413
414        long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP);
415        // Get a nicely formatted date string (relative to today)
416        String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
417        // Add style to date
418        CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
419        views.setTextViewText(R.id.widget_date, styledDate);
420
421        // Add style to subject/snippet
422        String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT);
423        String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET);
424        CharSequence subjectAndSnippet = getStyledSubjectSnippet(subject, snippet, !isUnread);
425        views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
426
427        int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS);
428        boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0;
429        views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE);
430
431        boolean hasAttachment =
432                mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0;
433        views.setViewVisibility(R.id.widget_attachment,
434                hasAttachment ? View.VISIBLE : View.GONE);
435
436        if (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) {
437            views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
438        } else {
439            long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY);
440            int colorId = mResourceHelper.getAccountColorId(accountId);
441            if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) {
442                // Color defined by resource ID, so, use it
443                views.setViewVisibility(R.id.color_chip, View.VISIBLE);
444                views.setImageViewResource(R.id.color_chip, colorId);
445            } else {
446                // Color not defined by resource ID, nothing we can do, so, hide the chip
447                views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
448            }
449        }
450
451        // Set button intents for view, reply, and delete
452        String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID);
453        String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY);
454        setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE,
455                messageId, mailboxId);
456
457        return views;
458    }
459
460    @Override
461    public int getCount() {
462        if (!isCursorValid()) return 0;
463        return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
464    }
465
466    @Override
467    public long getItemId(int position) {
468        return position;
469    }
470
471    @Override
472    public RemoteViews getLoadingView() {
473        RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
474        view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading));
475        return view;
476    }
477
478    @Override
479    public int getViewTypeCount() {
480        // Regular list view and the "loading" view
481        return 2;
482    }
483
484    @Override
485    public boolean hasStableIds() {
486        return true;
487    }
488
489    @Override
490    public void onDataSetChanged() {
491    }
492
493    public void onDeleted() {
494        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
495            Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId);
496        }
497
498        if (mLoader != null) {
499            mLoader.reset();
500        }
501    }
502
503    @Override
504    public void onDestroy() {
505        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
506            Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId);
507        }
508
509        if (mLoader != null) {
510            mLoader.reset();
511        }
512    }
513
514    @Override
515    public void onCreate() {
516        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
517            Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId);
518        }
519    }
520
521    @Override
522    public String toString() {
523        return "View=" + mAccountName;
524    }
525}
526