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