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