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