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