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