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