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