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