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