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