EmailWidget.java revision 458816b2c8c3bc684119adba8126584c45b468e8
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 ... args) {
225        Intent intent = new Intent();
226        Builder builder = baseUri.buildUpon();
227        for (String arg: args) {
228            builder.appendPath(arg);
229        }
230        intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE);
231        views.setOnClickFillInIntent(viewId, intent);
232    }
233
234    /**
235     * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to
236     * handle intents created by remote views.
237     */
238    public static boolean processIntent(Context context, Intent intent) {
239        final Uri data = intent.getData();
240        if (data == null) {
241            return false;
242        }
243        List<String> pathSegments = data.getPathSegments();
244        // Our path segments are <command>, <arg1> [, <arg2>]
245        // First, a quick check of Uri validity
246        if (pathSegments.size() < 2) {
247            throw new IllegalArgumentException();
248        }
249        String command = pathSegments.get(0);
250        // Ignore unknown action names
251        try {
252            final long arg1 = Long.parseLong(pathSegments.get(1));
253            if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) {
254                // "view", <message id>, <mailbox id>
255                openMessage(context, Long.parseLong(pathSegments.get(2)), arg1);
256            }
257        } catch (NumberFormatException e) {
258            // Shouldn't happen as we construct all of the Uri's
259            return false;
260        }
261        return true;
262    }
263
264    private static void openMessage(final Context context, final long mailboxId,
265            final long messageId) {
266        EmailAsyncTask.runAsyncParallel(new Runnable() {
267            @Override
268            public void run() {
269                Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId);
270                if (mailbox == null) return;
271                context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey,
272                        mailboxId, messageId, true));
273            }
274        });
275    }
276
277    private void setTextViewTextAndDesc(RemoteViews views, final int id, String text) {
278        views.setTextViewText(id, text);
279        views.setContentDescription(id, text);
280    }
281
282    private void setupTitleAndCount(RemoteViews views) {
283        // Set up the title (view type + count of messages)
284        setTextViewTextAndDesc(views, R.id.widget_title, mMailboxName);
285        views.setViewVisibility(R.id.widget_tap, View.VISIBLE);
286        setTextViewTextAndDesc(views, R.id.widget_tap, mAccountName);
287        String count = "";
288        synchronized (sWidgetLock) {
289            if (isCursorValid()) {
290                count = UiUtilities
291                        .getMessageCountForUi(mContext, mCursor.getMessageCount(), false);
292            }
293        }
294        setTextViewTextAndDesc(views, R.id.widget_count, count);
295    }
296
297    /**
298     * Update the "header" of the widget (i.e. everything that doesn't include the scrolling
299     * message list)
300     */
301    private void updateHeader() {
302        if (Email.DEBUG) {
303            Log.d(TAG, "#updateHeader(); widgetId: " + mWidgetId);
304        }
305
306        // Get the widget layout
307        RemoteViews views =
308                new RemoteViews(mContext.getPackageName(), R.layout.widget);
309
310        // Set up the list with an adapter
311        Intent intent = new Intent(mContext, WidgetService.class);
312        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId);
313        intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
314        views.setRemoteAdapter(R.id.message_list, intent);
315
316        setupTitleAndCount(views);
317
318        if (isCursorValid()) {
319            // Show compose icon & message list
320            if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
321                // Don't allow compose for "combined" view
322                views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
323            } else {
324                views.setViewVisibility(R.id.widget_compose, View.VISIBLE);
325            }
326            views.setViewVisibility(R.id.message_list, View.VISIBLE);
327            views.setViewVisibility(R.id.tap_to_configure, View.GONE);
328            // Create click intent for "compose email" target
329            intent = MessageCompose.getMessageComposeIntent(mContext, mAccountId);
330            intent.putExtra(MessageCompose.EXTRA_FROM_WIDGET, true);
331            setActivityIntent(views, R.id.widget_compose, intent);
332            // Create click intent for logo to open inbox
333            intent = Welcome.createOpenAccountInboxIntent(mContext, mAccountId);
334            setActivityIntent(views, R.id.widget_header, intent);
335        } else {
336            // TODO This really should never happen ... probably can remove the else block
337            // Hide compose icon & show "touch to configure" text
338            views.setViewVisibility(R.id.widget_compose, View.INVISIBLE);
339            views.setViewVisibility(R.id.message_list, View.GONE);
340            views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE);
341            // Create click intent for "touch to configure" target
342            intent = Welcome.createOpenAccountInboxIntent(mContext, -1);
343            setActivityIntent(views, R.id.tap_to_configure, intent);
344        }
345
346        // Use a bare intent for our template; we need to fill everything in
347        intent = new Intent(mContext, WidgetService.class);
348        PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent,
349                PendingIntent.FLAG_UPDATE_CURRENT);
350        views.setPendingIntentTemplate(R.id.message_list, pendingIntent);
351
352        // And finally update the widget
353        mWidgetManager.updateAppWidget(mWidgetId, views);
354    }
355
356    /**
357     * Add size and color styling to text
358     *
359     * @param text the text to style
360     * @param size the font size for this text
361     * @param color the color for this text
362     * @return a CharSequence quitable for use in RemoteViews.setTextViewText()
363     */
364    private CharSequence addStyle(CharSequence text, int size, int color) {
365        SpannableStringBuilder builder = new SpannableStringBuilder(text);
366        builder.setSpan(
367                new AbsoluteSizeSpan(size), 0, text.length(),
368                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
369        if (color != 0) {
370            builder.setSpan(new ForegroundColorSpan(color), 0, text.length(),
371                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
372        }
373        return builder;
374    }
375
376    /**
377     * Create styled text for our combination subject and snippet
378     *
379     * @param subject the message's subject (or null)
380     * @param snippet the message's snippet (or null)
381     * @param read whether or not the message is read
382     * @return a CharSequence suitable for use in RemoteViews.setTextViewText()
383     */
384    private CharSequence getStyledSubjectSnippet(String subject, String snippet, boolean read) {
385        SpannableStringBuilder ssb = new SpannableStringBuilder();
386        boolean hasSubject = false;
387        if (!TextUtils.isEmpty(subject)) {
388            SpannableString ss = new SpannableString(subject);
389            ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(),
390                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
391            ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(),
392                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
393            ssb.append(ss);
394            hasSubject = true;
395        }
396        if (!TextUtils.isEmpty(snippet)) {
397            if (hasSubject) {
398                ssb.append(sSubjectSnippetDivider);
399            }
400            SpannableString ss = new SpannableString(snippet);
401            ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(),
402                    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
403            ssb.append(ss);
404        }
405        return addStyle(ssb, sSubjectFontSize, 0);
406    }
407
408    @Override
409    public RemoteViews getViewAt(int position) {
410        synchronized (sWidgetLock) {
411            // Use the cursor to set up the widget
412            if (!isCursorValid() || !mCursor.moveToPosition(position)) {
413                return getLoadingView();
414            }
415            RemoteViews views = new RemoteViews(mContext.getPackageName(),
416                    R.layout.widget_list_item);
417            boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1;
418            int drawableId = R.drawable.conversation_read_selector;
419            if (isUnread) {
420                drawableId = R.drawable.conversation_unread_selector;
421            }
422            views.setInt(R.id.widget_message, "setBackgroundResource", drawableId);
423
424            // Add style to sender
425            String rawSender = mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME) ?
426                    "" : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME);
427            SpannableStringBuilder from = new SpannableStringBuilder(rawSender);
428            from.setSpan(isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL),
429                    0, from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
430            CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor);
431            views.setTextViewText(R.id.widget_from, styledFrom);
432            views.setContentDescription(R.id.widget_from, rawSender);
433            long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP);
434            // Get a nicely formatted date string (relative to today)
435            String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString();
436            // Add style to date
437            CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor);
438            views.setTextViewText(R.id.widget_date, styledDate);
439            views.setContentDescription(R.id.widget_date, date);
440
441            // Add style to subject/snippet
442            String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT);
443            String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET);
444            CharSequence subjectAndSnippet = getStyledSubjectSnippet(subject, snippet, !isUnread);
445            views.setTextViewText(R.id.widget_subject, subjectAndSnippet);
446            views.setContentDescription(R.id.widget_subject, subject);
447
448            int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS);
449            boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0;
450            views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE);
451
452            boolean hasAttachment = mCursor
453                    .getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0;
454            views.setViewVisibility(R.id.widget_attachment, hasAttachment ? View.VISIBLE
455                    : View.GONE);
456
457            if (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) {
458                views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
459            } else {
460                long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY);
461                int colorId = mResourceHelper.getAccountColorId(accountId);
462                if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) {
463                    // Color defined by resource ID, so, use it
464                    views.setViewVisibility(R.id.color_chip, View.VISIBLE);
465                    views.setImageViewResource(R.id.color_chip, colorId);
466                } else {
467                    // Color not defined by resource ID, nothing we can do, so,
468                    // hide the chip
469                    views.setViewVisibility(R.id.color_chip, View.INVISIBLE);
470                }
471            }
472
473            // Set button intents for view, reply, and delete
474            String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID);
475            String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY);
476            setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId,
477                    mailboxId);
478
479            return views;
480        }
481    }
482
483    @Override
484    public int getCount() {
485        if (!isCursorValid())
486            return 0;
487        synchronized (sWidgetLock) {
488            return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT);
489        }
490    }
491
492    @Override
493    public long getItemId(int position) {
494        return position;
495    }
496
497    @Override
498    public RemoteViews getLoadingView() {
499        RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
500        view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading));
501        return view;
502    }
503
504    @Override
505    public int getViewTypeCount() {
506        // Regular list view and the "loading" view
507        return 2;
508    }
509
510    @Override
511    public boolean hasStableIds() {
512        return true;
513    }
514
515    @Override
516    public void onDataSetChanged() {
517        // Note: we are not doing anything special in onDataSetChanged().  Since this service has
518        // a reference to a loader that will keep itself updated, if the service is running, it
519        // shouldn't be necessary to for the query to be run again.  If the service hadn't been
520        // running, the act of starting the service will also start the loader.
521    }
522
523    public void onDeleted() {
524        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
525            Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId);
526        }
527
528        if (mLoader != null) {
529            mLoader.reset();
530        }
531    }
532
533    @Override
534    public void onDestroy() {
535        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
536            Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId);
537        }
538
539        if (mLoader != null) {
540            mLoader.reset();
541        }
542    }
543
544    @Override
545    public void onCreate() {
546        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
547            Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId);
548        }
549    }
550
551    @Override
552    public String toString() {
553        return "View=" + mAccountName;
554    }
555}
556