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