WidgetService.java revision 88acafa03a87f5c84b959697d13b81df8f11a96e
1/*
2 * Copyright (C) 2012 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 */
16package com.android.mail.widget;
17
18import android.app.PendingIntent;
19import android.appwidget.AppWidgetManager;
20import android.content.Context;
21import android.content.CursorLoader;
22import android.content.Intent;
23import android.content.Loader;
24import android.content.Loader.OnLoadCompleteListener;
25import android.content.res.Resources;
26import android.database.Cursor;
27import android.net.Uri;
28import android.os.Looper;
29import android.support.v4.app.TaskStackBuilder;
30import android.text.SpannableString;
31import android.text.SpannableStringBuilder;
32import android.text.TextUtils;
33import android.text.format.DateUtils;
34import android.text.style.CharacterStyle;
35import android.text.style.TextAppearanceSpan;
36import android.view.View;
37import android.widget.RemoteViews;
38import android.widget.RemoteViewsService;
39
40import com.android.mail.R;
41import com.android.mail.browse.SendersView;
42import com.android.mail.compose.ComposeActivity;
43import com.android.mail.preferences.MailPrefs;
44import com.android.mail.providers.Account;
45import com.android.mail.providers.Conversation;
46import com.android.mail.providers.ConversationInfo;
47import com.android.mail.providers.Folder;
48import com.android.mail.providers.UIProvider;
49import com.android.mail.providers.UIProvider.ConversationListQueryParameters;
50import com.android.mail.utils.AccountUtils;
51import com.android.mail.utils.DelayedTaskHandler;
52import com.android.mail.utils.LogTag;
53import com.android.mail.utils.LogUtils;
54import com.android.mail.utils.Utils;
55import com.google.android.common.html.parser.HtmlParser;
56import com.google.android.common.html.parser.HtmlTreeBuilder;
57
58import java.util.ArrayList;
59
60public class WidgetService extends RemoteViewsService {
61    /**
62     * Lock to avoid race condition between widgets.
63     */
64    private static Object sWidgetLock = new Object();
65
66    private static final String LOG_TAG = LogTag.getLogTag();
67
68    @Override
69    public RemoteViewsFactory onGetViewFactory(Intent intent) {
70        return new MailFactory(getApplicationContext(), intent, this);
71    }
72
73
74    protected void configureValidAccountWidget(Context context, RemoteViews remoteViews,
75            int appWidgetId, Account account, Folder folder, String folderName) {
76        configureValidAccountWidget(context, remoteViews, appWidgetId, account, folder, folderName,
77                WidgetService.class);
78    }
79
80    /**
81     * Modifies the remoteView for the given account and folder.
82     */
83    public static void configureValidAccountWidget(Context context, RemoteViews remoteViews,
84            int appWidgetId, Account account, Folder folder, String folderDisplayName,
85            Class<?> widgetService) {
86        remoteViews.setViewVisibility(R.id.widget_folder, View.VISIBLE);
87
88        // If the folder or account name are empty, we don't want to overwrite the valid data that
89        // had been saved previously.  Since the launcher will save the state of the remote views
90        // we should rely on the fact that valid data has been saved.  But we should still log this,
91        // as it shouldn't happen
92        if (TextUtils.isEmpty(folderDisplayName) || TextUtils.isEmpty(account.name)) {
93            LogUtils.e(LOG_TAG, new Error(),
94                    "Empty folder or account name.  account: %s, folder: %s",
95                    account.name, folderDisplayName);
96        }
97        if (!TextUtils.isEmpty(folderDisplayName)) {
98            remoteViews.setTextViewText(R.id.widget_folder, folderDisplayName);
99        }
100        remoteViews.setViewVisibility(R.id.widget_account, View.VISIBLE);
101
102        if (!TextUtils.isEmpty(account.name)) {
103            remoteViews.setTextViewText(R.id.widget_account, account.name);
104        }
105        remoteViews.setViewVisibility(R.id.widget_unread_count, View.VISIBLE);
106        remoteViews.setViewVisibility(R.id.widget_compose, View.VISIBLE);
107        remoteViews.setViewVisibility(R.id.conversation_list, View.VISIBLE);
108        remoteViews.setViewVisibility(R.id.widget_folder_not_synced, View.GONE);
109        remoteViews.setEmptyView(R.id.conversation_list, R.id.empty_conversation_list);
110
111        WidgetService.configureValidWidgetIntents(context, remoteViews, appWidgetId, account,
112                folder, folderDisplayName, widgetService);
113    }
114
115    public static void configureValidWidgetIntents(Context context, RemoteViews remoteViews,
116            int appWidgetId, Account account, Folder folder, String folderDisplayName,
117            Class<?> serviceClass) {
118        remoteViews.setViewVisibility(R.id.widget_configuration, View.GONE);
119
120
121        // Launch an intent to avoid ANRs
122        final Intent intent = new Intent(context, serviceClass);
123        intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
124        intent.putExtra(BaseWidgetProvider.EXTRA_ACCOUNT, account.serialize());
125        intent.putExtra(BaseWidgetProvider.EXTRA_FOLDER, Folder.toString(folder));
126        intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)));
127        remoteViews.setRemoteAdapter(R.id.conversation_list, intent);
128        // Open mail app when click on header
129        final Intent mailIntent = Utils.createViewFolderIntent(folder, account);
130        PendingIntent clickIntent = PendingIntent.getActivity(context, 0, mailIntent,
131                PendingIntent.FLAG_UPDATE_CURRENT);
132        remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent);
133
134        // On click intent for Compose
135        final Intent composeIntent = new Intent();
136        composeIntent.setAction(Intent.ACTION_SEND);
137        composeIntent.putExtra(Utils.EXTRA_ACCOUNT, account.serialize());
138        composeIntent.setData(account.composeIntentUri);
139        composeIntent.putExtra(ComposeActivity.EXTRA_FROM_EMAIL_TASK, true);
140        if (account.composeIntentUri != null) {
141            composeIntent.putExtra(Utils.EXTRA_COMPOSE_URI, account.composeIntentUri);
142        }
143
144        // Build a task stack that forces the conversation list on the stack before the compose
145        // activity.
146        final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context);
147        clickIntent = taskStackBuilder.addNextIntent(mailIntent)
148                .addNextIntent(composeIntent)
149                .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT);
150        remoteViews.setOnClickPendingIntent(R.id.widget_compose, clickIntent);
151
152        // On click intent for Conversation
153        final Intent conversationIntent = new Intent();
154        conversationIntent.setAction(Intent.ACTION_VIEW);
155        clickIntent = PendingIntent.getActivity(context, 0, conversationIntent,
156                PendingIntent.FLAG_UPDATE_CURRENT);
157        remoteViews.setPendingIntentTemplate(R.id.conversation_list, clickIntent);
158    }
159
160    /**
161     * Persists the information about the specified widget.
162     */
163    public static void saveWidgetInformation(Context context, int appWidgetId, Account account,
164                Folder folder) {
165        MailPrefs.get(context).configureWidget(appWidgetId, account, folder);
166    }
167
168    /**
169     * Returns true if this widget id has been configured and saved.
170     */
171    public boolean isWidgetConfigured(Context context, int appWidgetId, Account account,
172            Folder folder) {
173        return isAccountValid(context, account) &&
174                MailPrefs.get(context).isWidgetConfigured(appWidgetId);
175    }
176
177    protected boolean isAccountValid(Context context, Account account) {
178        if (account != null) {
179            Account[] accounts = AccountUtils.getSyncingAccounts(context);
180            for (Account existing : accounts) {
181                if (account != null && existing != null && account.uri.equals(existing.uri)) {
182                    return true;
183                }
184            }
185        }
186        return false;
187    }
188
189    /**
190     * Remote Views Factory for Mail Widget.
191     */
192    protected static class MailFactory
193            implements RemoteViewsService.RemoteViewsFactory, OnLoadCompleteListener<Cursor> {
194        private static final int MAX_CONVERSATIONS_COUNT = 25;
195        private static final int MAX_SENDERS_LENGTH = 25;
196
197        private static final int FOLDER_LOADER_ID = 0;
198        private static final int CONVERSATION_CURSOR_LOADER_ID = 1;
199
200        private final Context mContext;
201        private final int mAppWidgetId;
202        private final Account mAccount;
203        private Folder mFolder;
204        private final WidgetConversationViewBuilder mWidgetConversationViewBuilder;
205        private CursorLoader mConversationCursorLoader;
206        private Cursor mConversationCursor;
207        private CursorLoader mFolderLoader;
208        private FolderUpdateHandler mFolderUpdateHandler;
209        private int mFolderCount;
210        private boolean mShouldShowViewMore;
211        private boolean mFolderInformationShown = false;
212        private WidgetService mService;
213        private String mSendersSplitToken;
214        private String mElidedPaddingToken;
215        private TextAppearanceSpan mUnreadStyle;
216        private TextAppearanceSpan mReadStyle;
217
218        public MailFactory(Context context, Intent intent, WidgetService service) {
219            mContext = context;
220            mAppWidgetId = intent.getIntExtra(
221                    AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
222            mAccount = Account.newinstance(intent.getStringExtra(WidgetProvider.EXTRA_ACCOUNT));
223            mFolder = Folder.fromString(intent.getStringExtra(WidgetProvider.EXTRA_FOLDER));
224            mWidgetConversationViewBuilder = new WidgetConversationViewBuilder(context,
225                    mAccount);
226            mService = service;
227        }
228
229        @Override
230        public void onCreate() {
231
232            // Save the map between widgetId and account to preference
233            saveWidgetInformation(mContext, mAppWidgetId, mAccount, mFolder);
234
235            // If the account of this widget has been removed, we want to update the widget to
236            // "Tap to configure" mode.
237            if (!mService.isWidgetConfigured(mContext, mAppWidgetId, mAccount, mFolder)) {
238                BaseWidgetProvider.updateWidget(mContext, mAppWidgetId, mAccount, mFolder);
239            }
240
241            mFolderInformationShown = false;
242
243            // We want to limit the query result to 25 and don't want these queries to cause network
244            // traffic
245            // We also want this cursor to receive notifications on all changes.  Any change that
246            // the user made locally, the default policy of the UI provider is to not send
247            // notifications for.  But in this case, since the widget is not using the
248            // ConversationCursor instance that the UI is using, the widget would not be updated.
249            final Uri.Builder builder = mFolder.conversationListUri.buildUpon();
250            final String maxConversations = Integer.toString(MAX_CONVERSATIONS_COUNT);
251            final Uri widgetConversationQueryUri = builder
252                    .appendQueryParameter(ConversationListQueryParameters.LIMIT, maxConversations)
253                    .appendQueryParameter(ConversationListQueryParameters.USE_NETWORK,
254                            Boolean.FALSE.toString())
255                    .appendQueryParameter(ConversationListQueryParameters.ALL_NOTIFICATIONS,
256                            Boolean.TRUE.toString()).build();
257
258            final Resources res = mContext.getResources();
259            mConversationCursorLoader = new CursorLoader(mContext, widgetConversationQueryUri,
260                    UIProvider.CONVERSATION_PROJECTION, null, null, null);
261            mConversationCursorLoader.registerListener(CONVERSATION_CURSOR_LOADER_ID, this);
262            mConversationCursorLoader.setUpdateThrottle(
263                    res.getInteger(R.integer.widget_refresh_delay_ms));
264            mConversationCursorLoader.startLoading();
265            mSendersSplitToken = res.getString(R.string.senders_split_token);
266            mElidedPaddingToken = res.getString(R.string.elided_padding_token);
267            mFolderLoader = new CursorLoader(mContext, mFolder.uri, UIProvider.FOLDERS_PROJECTION,
268                    null, null, null);
269            mFolderLoader.registerListener(FOLDER_LOADER_ID, this);
270            mFolderUpdateHandler = new FolderUpdateHandler(
271                    res.getInteger(R.integer.widget_folder_refresh_delay_ms));
272            mFolderUpdateHandler.scheduleTask();
273
274        }
275
276        @Override
277        public void onDestroy() {
278            synchronized (sWidgetLock) {
279                if (mConversationCursorLoader != null) {
280                    mConversationCursorLoader.reset();
281                    mConversationCursorLoader.unregisterListener(this);
282                    mConversationCursorLoader = null;
283                }
284
285                // The Loader should close the cursor, so just unset the reference
286                // to it here.
287                mConversationCursor = null;
288            }
289
290            if (mFolderLoader != null) {
291                mFolderLoader.reset();
292                mFolderLoader.unregisterListener(this);
293                mFolderLoader = null;
294            }
295        }
296
297        @Override
298        public void onDataSetChanged() {
299            // We are not using this as signal to requery the cursor.  The query will be started
300            // in the following ways:
301            // 1) The Service is started and the loader is started in onCreate()
302            //       This will happen when the service is not running, and
303            //       AppWidgetManager#notifyAppWidgetViewDataChanged() is called
304            // 2) The service is running, with a previously created loader.  The loader is watching
305            //    for updates from the existing cursor.  If one is seen, the loader will load a new
306            //    cursor in the background.
307            mFolderUpdateHandler.scheduleTask();
308        }
309
310        /**
311         * Returns the number of items should be shown in the widget list.  This method also updates
312         * the boolean that indicates whether the "show more" item should be shown.
313         * @return the number of items to be displayed in the list.
314         */
315        @Override
316        public int getCount() {
317            synchronized (sWidgetLock) {
318                final int count = getConversationCount();
319                final int cursorCount = mConversationCursor != null ?
320                        mConversationCursor.getCount() : 0;
321                mShouldShowViewMore = count < cursorCount || count < mFolderCount;
322                return count + (mShouldShowViewMore ? 1 : 0);
323            }
324        }
325
326        /**
327         * Returns the number of conversations that should be shown in the widget.  This method
328         * doesn't update the boolean that indicates that the "show more" item should be included
329         * in the list.
330         * @return
331         */
332        private int getConversationCount() {
333            synchronized (sWidgetLock) {
334                final int cursorCount = mConversationCursor != null ?
335                        mConversationCursor.getCount() : 0;
336                return Math.min(cursorCount, MAX_CONVERSATIONS_COUNT);
337            }
338        }
339
340        /**
341         * @return the {@link RemoteViews} for a specific position in the list.
342         */
343        @Override
344        public RemoteViews getViewAt(int position) {
345            synchronized (sWidgetLock) {
346                // "View more conversations" view.
347                if (mConversationCursor == null || mConversationCursor.isClosed()
348                        || (mShouldShowViewMore && position >= getConversationCount())) {
349                    return getViewMoreConversationsView();
350                }
351
352                if (!mConversationCursor.moveToPosition(position)) {
353                    // If we ever fail to move to a position, return the
354                    // "View More conversations"
355                    // view.
356                    LogUtils.e(LOG_TAG, "Failed to move to position %d in the cursor.", position);
357                    return getViewMoreConversationsView();
358                }
359
360                Conversation conversation = new Conversation(mConversationCursor);
361                // Split the senders and status from the instructions.
362                SpannableStringBuilder senderBuilder = new SpannableStringBuilder();
363                SpannableStringBuilder statusBuilder = new SpannableStringBuilder();
364
365                if (conversation.conversationInfo != null) {
366                    ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
367                    SendersView.format(mContext,
368                            conversation.conversationInfo, "", MAX_SENDERS_LENGTH,
369                            new HtmlParser(), new HtmlTreeBuilder(), senders, null);
370                    senderBuilder = ellipsizeStyledSenders(conversation.conversationInfo,
371                            MAX_SENDERS_LENGTH, senders);
372                } else {
373                    senderBuilder.append(conversation.senders);
374                    senderBuilder.setSpan(conversation.read ? getReadStyle() : getUnreadStyle(), 0,
375                            senderBuilder.length(), 0);
376                }
377                // Get styled date.
378                CharSequence date = DateUtils.getRelativeTimeSpanString(mContext,
379                        conversation.dateMs);
380
381                // Load up our remote view.
382                RemoteViews remoteViews = mWidgetConversationViewBuilder.getStyledView(
383                        statusBuilder, date, conversation, mFolder, senderBuilder,
384                        filterTag(conversation.subject));
385
386                // On click intent.
387                remoteViews.setOnClickFillInIntent(R.id.widget_conversation,
388                        Utils.createViewConversationIntent(conversation, mFolder, mAccount));
389
390                return remoteViews;
391            }
392        }
393
394        private CharacterStyle getUnreadStyle() {
395            if (mUnreadStyle == null) {
396                mUnreadStyle = new TextAppearanceSpan(mContext,
397                        R.style.SendersUnreadTextAppearance);
398            }
399            return CharacterStyle.wrap(mUnreadStyle);
400        }
401
402        private CharacterStyle getReadStyle() {
403            if (mReadStyle == null) {
404                mReadStyle = new TextAppearanceSpan(mContext, R.style.SendersReadTextAppearance);
405            }
406            return CharacterStyle.wrap(mReadStyle);
407        }
408
409        private SpannableStringBuilder ellipsizeStyledSenders(ConversationInfo info, int maxChars,
410                ArrayList<SpannableString> styledSenders) {
411            SpannableStringBuilder builder = new SpannableStringBuilder();
412            SpannableString prevSender = null;
413            for (SpannableString sender : styledSenders) {
414                if (sender == null) {
415                    LogUtils.e(LOG_TAG, "null sender while iterating over styledSenders");
416                    continue;
417                }
418                CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
419                if (SendersView.sElidedString.equals(sender.toString())) {
420                    prevSender = sender;
421                    sender = copyStyles(spans, mElidedPaddingToken + sender + mElidedPaddingToken);
422                } else if (builder.length() > 0
423                        && (prevSender == null || !SendersView.sElidedString.equals(prevSender
424                                .toString()))) {
425                    prevSender = sender;
426                    sender = copyStyles(spans, mSendersSplitToken + sender);
427                } else {
428                    prevSender = sender;
429                }
430                builder.append(sender);
431            }
432            return builder;
433        }
434
435        private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
436            SpannableString s = new SpannableString(newText);
437            if (spans != null && spans.length > 0) {
438                s.setSpan(spans[0], 0, s.length(), 0);
439            }
440            return s;
441        }
442
443        /**
444         * @return the "View more conversations" view.
445         */
446        private RemoteViews getViewMoreConversationsView() {
447            RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
448            view.setTextViewText(
449                    R.id.loading_text, mContext.getText(R.string.view_more_conversations));
450            view.setOnClickFillInIntent(R.id.widget_loading,
451                    Utils.createViewFolderIntent(mFolder, mAccount));
452            return view;
453        }
454
455        @Override
456        public RemoteViews getLoadingView() {
457            RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading);
458            view.setTextViewText(
459                    R.id.loading_text, mContext.getText(R.string.loading_conversation));
460            return view;
461        }
462
463        @Override
464        public int getViewTypeCount() {
465            return 2;
466        }
467
468        @Override
469        public long getItemId(int position) {
470            return position;
471        }
472
473        @Override
474        public boolean hasStableIds() {
475            return false;
476        }
477
478        @Override
479        public void onLoadComplete(Loader<Cursor> loader, Cursor data) {
480            final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(mContext);
481            final RemoteViews remoteViews =
482                    new RemoteViews(mContext.getPackageName(), R.layout.widget);
483
484            if (loader == mFolderLoader) {
485                if (!isDataValid(data)) {
486                    return;
487                }
488
489                final int unreadCount = data.getInt(UIProvider.FOLDER_UNREAD_COUNT_COLUMN);
490                final String folderName = data.getString(UIProvider.FOLDER_NAME_COLUMN);
491                mFolderCount = data.getInt(UIProvider.FOLDER_TOTAL_COUNT_COLUMN);
492
493                if (!mFolderInformationShown && !TextUtils.isEmpty(folderName) &&
494                        !TextUtils.isEmpty(mAccount.name)) {
495                    // We want to do a full update to the widget at least once, as the widget
496                    // manager doesn't cache the state of the remote views when doing a partial
497                    // widget update. This causes the folder name to be shown as blank if the state
498                    // of the widget is restored.
499                    mService.configureValidAccountWidget(
500                            mContext, remoteViews, mAppWidgetId, mAccount, mFolder, folderName);
501                    appWidgetManager.updateAppWidget(mAppWidgetId, remoteViews);
502                    mFolderInformationShown = true;
503                }
504
505                // There is no reason to overwrite a valid non-null folder name with an empty string
506                if (!TextUtils.isEmpty(folderName)) {
507                    remoteViews.setViewVisibility(R.id.widget_folder, View.VISIBLE);
508                    remoteViews.setTextViewText(R.id.widget_folder, folderName);
509                } else {
510                    LogUtils.e(LOG_TAG, "Empty folder name");
511                }
512                if (!TextUtils.isEmpty(mAccount.name)) {
513                    remoteViews.setTextViewText(R.id.widget_account, mAccount.name);
514                }
515                remoteViews.setViewVisibility(R.id.widget_unread_count, View.VISIBLE);
516                remoteViews.setTextViewText(R.id.widget_unread_count,
517                        Utils.getUnreadCountString(mContext, unreadCount));
518
519                appWidgetManager.partiallyUpdateAppWidget(mAppWidgetId, remoteViews);
520            } else if (loader == mConversationCursorLoader) {
521                // We want to cache the new cursor
522                synchronized (sWidgetLock) {
523                    if (!isDataValid(data)) {
524                        mConversationCursor = null;
525                    } else {
526                        mConversationCursor = data;
527                    }
528                }
529
530                appWidgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId,
531                        R.id.conversation_list);
532
533                if (mConversationCursor == null || mConversationCursor.getCount() == 0) {
534                    remoteViews.setTextViewText(R.id.empty_conversation_list,
535                            mContext.getString(R.string.no_conversations));
536                    appWidgetManager.partiallyUpdateAppWidget(mAppWidgetId, remoteViews);
537                }
538            }
539        }
540
541        /**
542         * Returns a boolean indicating whether this cursor has valid data.
543         * Note: This seeks to the first position in the cursor
544         */
545        private boolean isDataValid(Cursor cursor) {
546            return cursor != null && !cursor.isClosed() && cursor.moveToFirst();
547        }
548
549        /**
550         * If the subject contains the tag of a mailing-list (text surrounded with []), return the
551         * subject with that tag ellipsized, e.g. "[android-gmail-team] Hello" -> "[andr...] Hello"
552         */
553        private static String filterTag(String subject) {
554            String result = subject;
555            if (subject.length() > 0 && subject.charAt(0) == '[') {
556                int end = subject.indexOf(']');
557                if (end > 0) {
558                    String tag = subject.substring(1, end);
559                    result = "[" + Utils.ellipsize(tag, 7) + "]" + subject.substring(end + 1);
560                }
561            }
562
563            return result;
564        }
565
566        /**
567         * A {@link DelayedTaskHandler} to throttle folder update to a reasonable rate.
568         */
569        private class FolderUpdateHandler extends DelayedTaskHandler {
570            public FolderUpdateHandler(int refreshDelay) {
571                super(Looper.myLooper(), refreshDelay);
572            }
573
574            @Override
575            protected void performTask() {
576                // Start the loader. The cached data will be returned if present.
577                if (mFolderLoader != null) {
578                    mFolderLoader.startLoading();
579                }
580            }
581        }
582    }
583}
584