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