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