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