WidgetService.java revision ce499a669a8008397e3fd92e3b0c5e44e0ae4ab6
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.ConversationInfo; 25import com.android.mail.providers.Folder; 26import com.android.mail.providers.UIProvider; 27import com.android.mail.providers.UIProvider.ConversationListQueryParameters; 28import com.android.mail.utils.AccountUtils; 29import com.android.mail.utils.DelayedTaskHandler; 30import com.android.mail.utils.LogTag; 31import com.android.mail.utils.LogUtils; 32import com.android.mail.utils.Utils; 33import com.google.android.common.html.parser.HtmlParser; 34import com.google.android.common.html.parser.HtmlTreeBuilder; 35 36import android.app.PendingIntent; 37import android.appwidget.AppWidgetManager; 38import android.content.Context; 39import android.content.CursorLoader; 40import android.content.Intent; 41import android.content.Loader; 42import android.content.Loader.OnLoadCompleteListener; 43import android.content.SharedPreferences; 44import android.content.res.Resources; 45import android.database.Cursor; 46import android.net.Uri; 47import android.os.Looper; 48import android.support.v4.app.TaskStackBuilder; 49import android.text.SpannableString; 50import android.text.SpannableStringBuilder; 51import android.text.TextUtils; 52import android.text.format.DateUtils; 53import android.text.style.CharacterStyle; 54import android.text.style.TextAppearanceSpan; 55import android.view.View; 56import android.widget.RemoteViews; 57import android.widget.RemoteViewsService; 58 59public class WidgetService extends RemoteViewsService { 60 /** 61 * Lock to avoid race condition between widgets. 62 */ 63 private static Object sWidgetLock = new Object(); 64 65 private static final String LOG_TAG = LogTag.getLogTag(); 66 67 @Override 68 public RemoteViewsFactory onGetViewFactory(Intent intent) { 69 return new MailFactory(getApplicationContext(), intent, this); 70 } 71 72 73 protected void configureValidAccountWidget(Context context, RemoteViews remoteViews, 74 int appWidgetId, Account account, Folder folder, String folderName) { 75 configureValidAccountWidget(context, remoteViews, appWidgetId, account, folder, folderName, 76 WidgetService.class); 77 } 78 79 /** 80 * Modifies the remoteView for the given account and folder. 81 */ 82 public static void configureValidAccountWidget(Context context, RemoteViews remoteViews, 83 int appWidgetId, Account account, Folder folder, String folderDisplayName, 84 Class<?> widgetService) { 85 remoteViews.setViewVisibility(R.id.widget_folder, View.VISIBLE); 86 87 // If the folder or account name are empty, we don't want to overwrite the valid data that 88 // had been saved previously. Since the launcher will save the state of the remote views 89 // we should rely on the fact that valid data has been saved. But we should still log this, 90 // as it shouldn't happen 91 if (TextUtils.isEmpty(folderDisplayName) || TextUtils.isEmpty(account.name)) { 92 LogUtils.e(LOG_TAG, new Error(), 93 "Empty folder or account name. account: %s, folder: %s", 94 account.name, folderDisplayName); 95 } 96 if (!TextUtils.isEmpty(folderDisplayName)) { 97 remoteViews.setTextViewText(R.id.widget_folder, folderDisplayName); 98 } 99 remoteViews.setViewVisibility(R.id.widget_account, View.VISIBLE); 100 101 if (!TextUtils.isEmpty(account.name)) { 102 remoteViews.setTextViewText(R.id.widget_account, account.name); 103 } 104 remoteViews.setViewVisibility(R.id.widget_unread_count, View.VISIBLE); 105 remoteViews.setViewVisibility(R.id.widget_compose, View.VISIBLE); 106 remoteViews.setViewVisibility(R.id.conversation_list, View.VISIBLE); 107 remoteViews.setViewVisibility(R.id.widget_folder_not_synced, View.GONE); 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 final SharedPreferences.Editor editor = Persistence.getPreferences(context).edit(); 164 editor.putString(WidgetProvider.WIDGET_ACCOUNT_PREFIX + appWidgetId, 165 createWidgetPreferenceValue(account, folder)); 166 editor.apply(); 167 } 168 169 private static String createWidgetPreferenceValue(Account account, Folder folder) { 170 return account.uri.toString() + 171 BaseWidgetProvider.ACCOUNT_FOLDER_PREFERENCE_SEPARATOR + folder.uri.toString(); 172 173 } 174 175 /** 176 * Returns true if this widget id has been configured and saved. 177 */ 178 public boolean isWidgetConfigured(Context context, int appWidgetId, Account account, 179 Folder folder) { 180 if (isAccountValid(context, account)) { 181 return Persistence.getPreferences(context).getString( 182 BaseWidgetProvider.WIDGET_ACCOUNT_PREFIX + appWidgetId, null) != null; 183 } 184 return false; 185 } 186 187 protected boolean isAccountValid(Context context, Account account) { 188 if (account != null) { 189 Account[] accounts = AccountUtils.getSyncingAccounts(context); 190 for (Account existing : accounts) { 191 if (account != null && existing != null && account.uri.equals(existing.uri)) { 192 return true; 193 } 194 } 195 } 196 return false; 197 } 198 199 /** 200 * Remote Views Factory for Mail Widget. 201 */ 202 protected static class MailFactory 203 implements RemoteViewsService.RemoteViewsFactory, OnLoadCompleteListener<Cursor> { 204 private static final int MAX_CONVERSATIONS_COUNT = 25; 205 private static final int MAX_SENDERS_LENGTH = 25; 206 207 private static final int FOLDER_LOADER_ID = 0; 208 private static final int CONVERSATION_CURSOR_LOADER_ID = 1; 209 210 private final Context mContext; 211 private final int mAppWidgetId; 212 private final Account mAccount; 213 private Folder mFolder; 214 private final WidgetConversationViewBuilder mWidgetConversationViewBuilder; 215 private CursorLoader mConversationCursorLoader; 216 private Cursor mConversationCursor; 217 private CursorLoader mFolderLoader; 218 private FolderUpdateHandler mFolderUpdateHandler; 219 private int mFolderCount; 220 private boolean mShouldShowViewMore; 221 private boolean mFolderInformationShown = false; 222 private WidgetService mService; 223 private String mSendersSplitToken; 224 private String mElidedPaddingToken; 225 private TextAppearanceSpan mUnreadStyle; 226 private TextAppearanceSpan mReadStyle; 227 228 public MailFactory(Context context, Intent intent, WidgetService service) { 229 mContext = context; 230 mAppWidgetId = intent.getIntExtra( 231 AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); 232 mAccount = Account.newinstance(intent.getStringExtra(WidgetProvider.EXTRA_ACCOUNT)); 233 mFolder = Folder.fromString(intent.getStringExtra(WidgetProvider.EXTRA_FOLDER)); 234 mWidgetConversationViewBuilder = new WidgetConversationViewBuilder(context, 235 mAccount); 236 mService = service; 237 } 238 239 @Override 240 public void onCreate() { 241 242 // Save the map between widgetId and account to preference 243 saveWidgetInformation(mContext, mAppWidgetId, mAccount, mFolder); 244 245 // If the account of this widget has been removed, we want to update the widget to 246 // "Tap to configure" mode. 247 if (!mService.isWidgetConfigured(mContext, mAppWidgetId, mAccount, mFolder)) { 248 BaseWidgetProvider.updateWidget(mContext, mAppWidgetId, mAccount, mFolder); 249 } 250 251 mFolderInformationShown = false; 252 253 // We want to limit the query result to 25 and don't want these queries to cause network 254 // traffic 255 // We also want this cursor to receive notifications on all changes. Any change that 256 // the user made locally, the default policy of the UI provider is to not send 257 // notifications for. But in this case, since the widget is not using the 258 // ConversationCursor instance that the UI is using, the widget would not be updated. 259 final Uri.Builder builder = mFolder.conversationListUri.buildUpon(); 260 final String maxConversations = Integer.toString(MAX_CONVERSATIONS_COUNT); 261 final Uri widgetConversationQueryUri = builder 262 .appendQueryParameter(ConversationListQueryParameters.LIMIT, maxConversations) 263 .appendQueryParameter(ConversationListQueryParameters.USE_NETWORK, 264 Boolean.FALSE.toString()) 265 .appendQueryParameter(ConversationListQueryParameters.ALL_NOTIFICATIONS, 266 Boolean.TRUE.toString()).build(); 267 268 final Resources res = mContext.getResources(); 269 mConversationCursorLoader = new CursorLoader(mContext, widgetConversationQueryUri, 270 UIProvider.CONVERSATION_PROJECTION, null, null, null); 271 mConversationCursorLoader.registerListener(CONVERSATION_CURSOR_LOADER_ID, this); 272 mConversationCursorLoader.setUpdateThrottle( 273 res.getInteger(R.integer.widget_refresh_delay_ms)); 274 mConversationCursorLoader.startLoading(); 275 mSendersSplitToken = res.getString(R.string.senders_split_token); 276 mElidedPaddingToken = res.getString(R.string.elided_padding_token); 277 mFolderLoader = new CursorLoader(mContext, mFolder.uri, UIProvider.FOLDERS_PROJECTION, 278 null, null, null); 279 mFolderLoader.registerListener(FOLDER_LOADER_ID, this); 280 mFolderUpdateHandler = new FolderUpdateHandler( 281 res.getInteger(R.integer.widget_folder_refresh_delay_ms)); 282 mFolderUpdateHandler.scheduleTask(); 283 284 } 285 286 @Override 287 public void onDestroy() { 288 synchronized (sWidgetLock) { 289 if (mConversationCursorLoader != null) { 290 mConversationCursorLoader.reset(); 291 mConversationCursorLoader.unregisterListener(this); 292 mConversationCursorLoader = null; 293 } 294 295 // The Loader should close the cursor, so just unset the reference 296 // to it here. 297 mConversationCursor = null; 298 } 299 300 if (mFolderLoader != null) { 301 mFolderLoader.reset(); 302 mFolderLoader.unregisterListener(this); 303 mFolderLoader = null; 304 } 305 } 306 307 @Override 308 public void onDataSetChanged() { 309 // We are not using this as signal to requery the cursor. The query will be started 310 // in the following ways: 311 // 1) The Service is started and the loader is started in onCreate() 312 // This will happen when the service is not running, and 313 // AppWidgetManager#notifyAppWidgetViewDataChanged() is called 314 // 2) The service is running, with a previously created loader. The loader is watching 315 // for updates from the existing cursor. If one is seen, the loader will load a new 316 // cursor in the background. 317 mFolderUpdateHandler.scheduleTask(); 318 } 319 320 /** 321 * Returns the number of items should be shown in the widget list. This method also updates 322 * the boolean that indicates whether the "show more" item should be shown. 323 * @return the number of items to be displayed in the list. 324 */ 325 @Override 326 public int getCount() { 327 synchronized (sWidgetLock) { 328 final int count = getConversationCount(); 329 final int cursorCount = mConversationCursor != null ? 330 mConversationCursor.getCount() : 0; 331 mShouldShowViewMore = count < cursorCount || count < mFolderCount; 332 return count + (mShouldShowViewMore ? 1 : 0); 333 } 334 } 335 336 /** 337 * Returns the number of conversations that should be shown in the widget. This method 338 * doesn't update the boolean that indicates that the "show more" item should be included 339 * in the list. 340 * @return 341 */ 342 private int getConversationCount() { 343 synchronized (sWidgetLock) { 344 final int cursorCount = mConversationCursor != null ? 345 mConversationCursor.getCount() : 0; 346 return Math.min(cursorCount, MAX_CONVERSATIONS_COUNT); 347 } 348 } 349 350 /** 351 * @return the {@link RemoteViews} for a specific position in the list. 352 */ 353 @Override 354 public RemoteViews getViewAt(int position) { 355 synchronized (sWidgetLock) { 356 // "View more conversations" view. 357 if (mConversationCursor == null || mConversationCursor.isClosed() 358 || (mShouldShowViewMore && position >= getConversationCount())) { 359 return getViewMoreConversationsView(); 360 } 361 362 if (!mConversationCursor.moveToPosition(position)) { 363 // If we ever fail to move to a position, return the 364 // "View More conversations" 365 // view. 366 LogUtils.e(LOG_TAG, "Failed to move to position %d in the cursor.", position); 367 return getViewMoreConversationsView(); 368 } 369 370 Conversation conversation = new Conversation(mConversationCursor); 371 // Split the senders and status from the instructions. 372 SpannableStringBuilder senderBuilder = new SpannableStringBuilder(); 373 SpannableStringBuilder statusBuilder = new SpannableStringBuilder(); 374 375 if (conversation.conversationInfo != null) { 376 senderBuilder = ellipsizeStyledSenders(conversation.conversationInfo, 377 MAX_SENDERS_LENGTH, SendersView.format(mContext, 378 conversation.conversationInfo, "", MAX_SENDERS_LENGTH, 379 new HtmlParser(), new HtmlTreeBuilder())); 380 } else { 381 senderBuilder.append(conversation.senders); 382 senderBuilder.setSpan(conversation.read ? getReadStyle() : getUnreadStyle(), 0, 383 senderBuilder.length(), 0); 384 } 385 // Get styled date. 386 CharSequence date = DateUtils.getRelativeTimeSpanString(mContext, 387 conversation.dateMs); 388 389 // Load up our remote view. 390 RemoteViews remoteViews = mWidgetConversationViewBuilder.getStyledView( 391 statusBuilder, date, conversation, mFolder, senderBuilder, 392 filterTag(conversation.subject)); 393 394 // On click intent. 395 remoteViews.setOnClickFillInIntent(R.id.widget_conversation, 396 Utils.createViewConversationIntent(conversation, mFolder, mAccount)); 397 398 return remoteViews; 399 } 400 } 401 402 private CharacterStyle getUnreadStyle() { 403 if (mUnreadStyle == null) { 404 mUnreadStyle = new TextAppearanceSpan(mContext, 405 R.style.SendersUnreadTextAppearance); 406 } 407 return CharacterStyle.wrap(mUnreadStyle); 408 } 409 410 private CharacterStyle getReadStyle() { 411 if (mReadStyle == null) { 412 mReadStyle = new TextAppearanceSpan(mContext, R.style.SendersReadTextAppearance); 413 } 414 return CharacterStyle.wrap(mReadStyle); 415 } 416 417 private SpannableStringBuilder ellipsizeStyledSenders(ConversationInfo info, int maxChars, 418 SpannableString[] styledSenders) { 419 SpannableStringBuilder builder = new SpannableStringBuilder(); 420 SpannableString prevSender = null; 421 for (SpannableString sender : styledSenders) { 422 if (sender == null) { 423 LogUtils.e(LOG_TAG, "null sender while iterating over styledSenders"); 424 continue; 425 } 426 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 427 if (SendersView.sElidedString.equals(sender.toString())) { 428 prevSender = sender; 429 sender = copyStyles(spans, mElidedPaddingToken + sender + mElidedPaddingToken); 430 } else if (builder.length() > 0 431 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 432 .toString()))) { 433 prevSender = sender; 434 sender = copyStyles(spans, mSendersSplitToken + sender); 435 } else { 436 prevSender = sender; 437 } 438 builder.append(sender); 439 } 440 return builder; 441 } 442 443 private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 444 SpannableString s = new SpannableString(newText); 445 if (spans != null && spans.length > 0) { 446 s.setSpan(spans[0], 0, s.length(), 0); 447 } 448 return s; 449 } 450 451 /** 452 * @return the "View more conversations" view. 453 */ 454 private RemoteViews getViewMoreConversationsView() { 455 RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); 456 view.setTextViewText( 457 R.id.loading_text, mContext.getText(R.string.view_more_conversations)); 458 view.setOnClickFillInIntent(R.id.widget_loading, 459 Utils.createViewFolderIntent(mFolder, mAccount)); 460 return view; 461 } 462 463 @Override 464 public RemoteViews getLoadingView() { 465 RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); 466 view.setTextViewText( 467 R.id.loading_text, mContext.getText(R.string.loading_conversation)); 468 return view; 469 } 470 471 @Override 472 public int getViewTypeCount() { 473 return 2; 474 } 475 476 @Override 477 public long getItemId(int position) { 478 return position; 479 } 480 481 @Override 482 public boolean hasStableIds() { 483 return false; 484 } 485 486 @Override 487 public void onLoadComplete(Loader<Cursor> loader, Cursor data) { 488 final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(mContext); 489 490 if (loader == mFolderLoader) { 491 if (!isDataValid(data)) { 492 return; 493 } 494 495 final int unreadCount = data.getInt(UIProvider.FOLDER_UNREAD_COUNT_COLUMN); 496 final String folderName = data.getString(UIProvider.FOLDER_NAME_COLUMN); 497 mFolderCount = data.getInt(UIProvider.FOLDER_TOTAL_COUNT_COLUMN); 498 499 final RemoteViews remoteViews = 500 new RemoteViews(mContext.getPackageName(), R.layout.widget); 501 502 if (!mFolderInformationShown && !TextUtils.isEmpty(folderName) && 503 !TextUtils.isEmpty(mAccount.name)) { 504 // We want to do a full update to the widget at least once, as the widget 505 // manager doesn't cache the state of the remote views when doing a partial 506 // widget update. This causes the folder name to be shown as blank if the state 507 // of the widget is restored. 508 mService.configureValidAccountWidget( 509 mContext, remoteViews, mAppWidgetId, mAccount, mFolder, folderName); 510 appWidgetManager.updateAppWidget(mAppWidgetId, remoteViews); 511 mFolderInformationShown = true; 512 } 513 514 // There is no reason to overwrite a valid non-null folder name with an empty string 515 if (!TextUtils.isEmpty(folderName)) { 516 remoteViews.setViewVisibility(R.id.widget_folder, View.VISIBLE); 517 remoteViews.setTextViewText(R.id.widget_folder, folderName); 518 } else { 519 LogUtils.e(LOG_TAG, "Empty folder name"); 520 } 521 remoteViews.setViewVisibility(R.id.widget_unread_count, View.VISIBLE); 522 remoteViews.setTextViewText(R.id.widget_unread_count, 523 Utils.getUnreadCountString(mContext, unreadCount)); 524 525 appWidgetManager.partiallyUpdateAppWidget(mAppWidgetId, remoteViews); 526 } else if (loader == mConversationCursorLoader) { 527 528 // We want to cache the new cursor 529 synchronized (sWidgetLock) { 530 if (!isDataValid(data)) { 531 mConversationCursor = null; 532 } else { 533 mConversationCursor = data; 534 } 535 } 536 appWidgetManager.notifyAppWidgetViewDataChanged( 537 mAppWidgetId, R.id.conversation_list); 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