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