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