EmailWidget.java revision 561004883da8d1c5507c163eab7237262e4abbaf
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    @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    /**
175     * Resets the data in the widget and forces a reload.
176     */
177    public void reset() {
178        mLoader.reset();
179        start();
180    }
181
182    private boolean isCursorValid() {
183        return mCursor != null && !mCursor.isClosed();
184    }
185
186    /**
187     * Called when the loader finished loading data.  Update the widget.
188     */
189    @Override
190    public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
191        mCursor = (EmailWidgetLoader.WidgetCursor) cursor;   // Save away the cursor
192        mAccountName = mCursor.getAccountName();
193        mMailboxName = mCursor.getMailboxName();
194        updateHeader();
195        mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list);
196    }
197
198    /**
199     * Convenience method for creating an onClickPendingIntent that launches another activity
200     * directly.
201     *
202     * @param views The RemoteViews we're inflating
203     * @param buttonId the id of the button view
204     * @param intent The intent to be used when launching the activity
205     */
206    private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) {
207        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // just in case intent comes without it
208        PendingIntent pendingIntent =
209                PendingIntent.getActivity(mContext, (int) mAccountId, intent,
210                        PendingIntent.FLAG_UPDATE_CURRENT);
211        views.setOnClickPendingIntent(buttonId, pendingIntent);
212    }
213
214    /**
215     * Convenience method for constructing a fillInIntent for a given list view element.
216     * Appends the command and any arguments to a base Uri.
217     *
218     * @param views the RemoteViews we are inflating
219     * @param viewId the id of the view
220     * @param baseUri the base uri for the command
221     * @param args any arguments to the command
222     */
223    private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) {
224        Intent intent = new Intent();
225        Builder builder = baseUri.buildUpon();
226        for (String arg: args) {
227            builder.appendPath(arg);
228        }
229        intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
230        views.setOnClickFillInIntent(viewId, intent);
231    }
232
233    /**
234     * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to
235     * handle intents created by remote views.
236     */
237    public static boolean processIntent(Context context, Intent intent) {
238        final Uri data = intent.getData();
239        if (data == null) {
240            return false;
241        }
242        List<String> pathSegments = data.getPathSegments();
243        // Our path segments are <command>, <arg1> [, <arg2>]
244        // First, a quick check of Uri validity
245        if (pathSegments.size() < 2) {
246            throw new IllegalArgumentException();
247        }
248        String command = pathSegments.get(0);
249        // Ignore unknown action names
250        try {
251            final long arg1 = Long.parseLong(pathSegments.get(1));
252            if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
253                // "view", <message id>, <mailbox id>
254                openMessage(context, Long.parseLong(pathSegments.get(2)), arg1);
255            }
256        } catch (NumberFormatException e) {
257            // Shouldn't happen as we construct all of the Uri's
258            return false;
259        }
260        return true;
261    }
262
263    private static void openMessage(final Context context, final long mailboxId,
264            final long messageId) {
265        EmailAsyncTask.runAsyncParallel(new Runnable() {
266            @Override
267            public void run() {
268                Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
269                if (mailbox == null) return;
270                context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
271                        mailboxId, messageId));
272            }
273        });
274    }
275
276    private void setupTitleAndCount(RemoteViews views) {
277        // Set up the title (view type + count of messages)
278        views.setTextViewText(R.id.widget_title, mMailboxName);
279        // TODO Temporary UX; need to make this visible and create the correct UX
280        //views.setTextViewText(R.id.widget_tap, sConfigureText);
281        views.setViewVisibility(R.id.widget_tap, View.VISIBLE);
282        views.setTextViewText(R.id.widget_tap, mAccountName);
283        String count = "";
284        if (isCursorValid()) {
285            count = UiUtilities.getMessageCountForUi(mContext, mCursor.getMessageCount(), false);
286        }
287        views.setTextViewText(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 cursorString =
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(cursorString);
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
428        long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP);
429        // Get a nicely formatted date string (relative to today)
430        String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
431        // Add style to date
432        CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
433        views.setTextViewText(R.id.widget_date, styledDate);
434
435        // Add style to subject/snippet
436        String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT);
437        String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET);
438        CharSequence subjectAndSnippet = getStyledSubjectSnippet(subject, snippet, !isUnread);
439        views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
440
441        int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS);
442        boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0;
443        views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE);
444
445        boolean hasAttachment =
446                mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0;
447        views.setViewVisibility(R.id.widget_attachment,
448                hasAttachment ? View.VISIBLE : View.GONE);
449
450        if (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) {
451            views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
452        } else {
453            long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY);
454            int colorId = mResourceHelper.getAccountColorId(accountId);
455            if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) {
456                // Color defined by resource ID, so, use it
457                views.setViewVisibility(R.id.color_chip, View.VISIBLE);
458                views.setImageViewResource(R.id.color_chip, colorId);
459            } else {
460                // Color not defined by resource ID, nothing we can do, so, hide the chip
461                views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
462            }
463        }
464
465        // Set button intents for view, reply, and delete
466        String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID);
467        String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY);
468        setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE,
469                messageId, mailboxId);
470
471        return views;
472    }
473
474    @Override
475    public int getCount() {
476        if (!isCursorValid()) return 0;
477        return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
478    }
479
480    @Override
481    public long getItemId(int position) {
482        return position;
483    }
484
485    @Override
486    public RemoteViews getLoadingView() {
487        RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
488        view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading));
489        return view;
490    }
491
492    @Override
493    public int getViewTypeCount() {
494        // Regular list view and the "loading" view
495        return 2;
496    }
497
498    @Override
499    public boolean hasStableIds() {
500        return true;
501    }
502
503    @Override
504    public void onDataSetChanged() {
505    }
506
507    public void onDeleted() {
508        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
509            Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId);
510        }
511
512        if (mLoader != null) {
513            mLoader.reset();
514        }
515    }
516
517    @Override
518    public void onDestroy() {
519        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
520            Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId);
521        }
522
523        if (mLoader != null) {
524            mLoader.reset();
525        }
526    }
527
528    @Override
529    public void onCreate() {
530        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
531            Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId);
532        }
533    }
534
535    @Override
536    public String toString() {
537        return "View=" + mAccountName;
538    }
539}
540