WidgetService.java revision c3efca18f09904f4ce39395169559c5d82bd3d06
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 60import org.json.JSONException; 61 62public class WidgetService extends RemoteViewsService { 63 /** 64 * Lock to avoid race condition between widgets. 65 */ 66 private static Object sWidgetLock = new Object(); 67 68 @Override 69 public RemoteViewsFactory onGetViewFactory(Intent intent) { 70 return new MailFactory(getApplicationContext(), intent, this); 71 } 72 73 74 protected void configureValidAccountWidget(Context context, RemoteViews remoteViews, 75 int appWidgetId, Account account, Folder folder, String folderName) { 76 configureValidAccountWidget(context, remoteViews, appWidgetId, account, folder, folderName, 77 WidgetService.class); 78 } 79 80 /** 81 * Modifies the remoteView for the given account and folder. 82 */ 83 public static void configureValidAccountWidget(Context context, RemoteViews remoteViews, 84 int appWidgetId, Account account, Folder folder, String folderDisplayName, 85 Class<?> widgetService) { 86 remoteViews.setViewVisibility(R.id.widget_folder, View.VISIBLE); 87 remoteViews.setTextViewText(R.id.widget_folder, folderDisplayName); 88 remoteViews.setViewVisibility(R.id.widget_account, View.VISIBLE); 89 remoteViews.setTextViewText(R.id.widget_account, account.name); 90 remoteViews.setViewVisibility(R.id.widget_unread_count, View.VISIBLE); 91 remoteViews.setViewVisibility(R.id.widget_compose, View.VISIBLE); 92 remoteViews.setViewVisibility(R.id.conversation_list, View.VISIBLE); 93 remoteViews.setViewVisibility(R.id.widget_folder_not_synced, View.GONE); 94 95 WidgetService.configureValidWidgetIntents(context, remoteViews, appWidgetId, account, 96 folder, folderDisplayName, widgetService); 97 } 98 99 public static void configureValidWidgetIntents(Context context, RemoteViews remoteViews, 100 int appWidgetId, Account account, Folder folder, String folderDisplayName, 101 Class<?> serviceClass) { 102 remoteViews.setViewVisibility(R.id.widget_configuration, View.GONE); 103 104 105 // Launch an intent to avoid ANRs 106 final Intent intent = new Intent(context, serviceClass); 107 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); 108 intent.putExtra(BaseWidgetProvider.EXTRA_ACCOUNT, account.serialize()); 109 intent.putExtra(BaseWidgetProvider.EXTRA_FOLDER, folder.serialize()); 110 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); 111 remoteViews.setRemoteAdapter(R.id.conversation_list, intent); 112 // Open mail app when click on header 113 final Intent mailIntent = Utils.createViewFolderIntent(folder, account); 114 PendingIntent clickIntent = PendingIntent.getActivity(context, 0, mailIntent, 115 PendingIntent.FLAG_UPDATE_CURRENT); 116 remoteViews.setOnClickPendingIntent(R.id.widget_header, clickIntent); 117 118 // On click intent for Compose 119 final Intent composeIntent = new Intent(); 120 composeIntent.setAction(Intent.ACTION_SEND); 121 composeIntent.putExtra(Utils.EXTRA_ACCOUNT, account); 122 composeIntent.setData(account.composeIntentUri); 123 composeIntent.putExtra(ComposeActivity.EXTRA_FROM_EMAIL_TASK, true); 124 if (account.composeIntentUri != null) { 125 composeIntent.putExtra(Utils.EXTRA_COMPOSE_URI, account.composeIntentUri); 126 } 127 128 // Build a task stack that forces the conversation list on the stack before the compose 129 // activity. 130 final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context); 131 clickIntent = taskStackBuilder.addNextIntent(mailIntent) 132 .addNextIntent(composeIntent) 133 .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); 134 remoteViews.setOnClickPendingIntent(R.id.widget_compose, clickIntent); 135 136 // On click intent for Conversation 137 final Intent conversationIntent = new Intent(); 138 conversationIntent.setAction(Intent.ACTION_VIEW); 139 clickIntent = PendingIntent.getActivity(context, 0, conversationIntent, 140 PendingIntent.FLAG_UPDATE_CURRENT); 141 remoteViews.setPendingIntentTemplate(R.id.conversation_list, clickIntent); 142 } 143 144 /** 145 * Persists the information about the specified widget. 146 */ 147 public static void saveWidgetInformation(Context context, int appWidgetId, Account account, 148 Folder folder) { 149 final SharedPreferences.Editor editor = Persistence.getPreferences(context).edit(); 150 editor.putString(WidgetProvider.WIDGET_ACCOUNT_PREFIX + appWidgetId, 151 createWidgetPreferenceValue(account, folder)); 152 editor.apply(); 153 } 154 155 private static String createWidgetPreferenceValue(Account account, Folder folder) { 156 return account.uri.toString() + 157 BaseWidgetProvider.ACCOUNT_FOLDER_PREFERENCE_SEPARATOR + folder.uri.toString(); 158 159 } 160 161 /** 162 * Returns true if this widget id has been configured and saved. 163 */ 164 public boolean isWidgetConfigured(Context context, int appWidgetId, Account account, 165 Folder folder) { 166 if (isAccountValid(context, account)) { 167 return Persistence.getPreferences(context).getString( 168 BaseWidgetProvider.WIDGET_ACCOUNT_PREFIX + appWidgetId, null) != null; 169 } 170 return false; 171 } 172 173 protected boolean isAccountValid(Context context, Account account) { 174 if (account != null) { 175 Account[] accounts = AccountUtils.getSyncingAccounts(context); 176 for (Account existing : accounts) { 177 if (account != null && existing != null && account.uri.equals(existing.uri)) { 178 return true; 179 } 180 } 181 } 182 return false; 183 } 184 185 /** 186 * Remote Views Factory for Mail Widget. 187 */ 188 protected static class MailFactory 189 implements RemoteViewsService.RemoteViewsFactory, OnLoadCompleteListener<Cursor> { 190 private static final int MAX_CONVERSATIONS_COUNT = 25; 191 private static final int MAX_SENDERS_LENGTH = 25; 192 193 private static final int FOLDER_LOADER_ID = 0; 194 private static final int CONVERSATION_CURSOR_LOADER_ID = 1; 195 196 private static final String LOG_TAG = LogTag.getLogTag(); 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 int mSenderFormatVersion; 212 private String mSendersSplitToken; 213 214 public MailFactory(Context context, Intent intent, WidgetService service) { 215 mContext = context; 216 mAppWidgetId = intent.getIntExtra( 217 AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); 218 mAccount = Account.newinstance(intent.getStringExtra(WidgetProvider.EXTRA_ACCOUNT)); 219 try { 220 mFolder = Folder.fromJSONString(intent.getStringExtra(WidgetProvider.EXTRA_FOLDER)); 221 } catch (JSONException e) { 222 mFolder = null; 223 LogUtils.wtf(LOG_TAG, e, "unable to parse folder for widget"); 224 } 225 mWidgetConversationViewBuilder = new WidgetConversationViewBuilder(context, 226 mAccount); 227 mService = service; 228 } 229 230 @Override 231 public void onCreate() { 232 233 // Save the map between widgetId and account to preference 234 saveWidgetInformation(mContext, mAppWidgetId, mAccount, mFolder); 235 236 // If the account of this widget has been removed, we want to update the widget to 237 // "Tap to configure" mode. 238 if (!mService.isWidgetConfigured(mContext, mAppWidgetId, mAccount, mFolder)) { 239 BaseWidgetProvider.updateWidget(mContext, mAppWidgetId, mAccount, mFolder); 240 } 241 242 // We want to limit the query result to 25 and don't want these queries to cause network 243 // traffic 244 // We also want this cursor to receive notifications on all changes. Any change that 245 // the user made locally, the default policy of the UI provider is to not send 246 // notifications for. But in this case, since the widget is not using the 247 // ConversationCursor instance that the UI is using, the widget would not be updated. 248 final Uri.Builder builder = mFolder.conversationListUri.buildUpon(); 249 final String maxConversations = Integer.toString(MAX_CONVERSATIONS_COUNT); 250 final Uri widgetConversationQueryUri = builder 251 .appendQueryParameter(ConversationListQueryParameters.LIMIT, maxConversations) 252 .appendQueryParameter(ConversationListQueryParameters.USE_NETWORK, 253 Boolean.FALSE.toString()) 254 .appendQueryParameter(ConversationListQueryParameters.ALL_NOTIFICATIONS, 255 Boolean.TRUE.toString()).build(); 256 257 final Resources res = mContext.getResources(); 258 mConversationCursorLoader = new CursorLoader(mContext, widgetConversationQueryUri, 259 UIProvider.CONVERSATION_PROJECTION, null, null, null); 260 mConversationCursorLoader.registerListener(CONVERSATION_CURSOR_LOADER_ID, this); 261 mConversationCursorLoader.setUpdateThrottle( 262 res.getInteger(R.integer.widget_refresh_delay_ms)); 263 mConversationCursorLoader.startLoading(); 264 mSendersSplitToken = res.getString(R.string.senders_split_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, 366 SendersView.format(mContext, conversation.conversationInfo)); 367 } else { 368 SendersView.SendersInfo sendersInfo = new SendersView.SendersInfo( 369 conversation.senders); 370 mSenderFormatVersion = sendersInfo.version; 371 if (mSenderFormatVersion == SendersView.MERGED_FORMATTING) { 372 Utils.getStyledSenderSnippet(mContext, sendersInfo.text, senderBuilder, 373 statusBuilder, MAX_SENDERS_LENGTH, false, false, false); 374 } else { 375 senderBuilder.append(sendersInfo.text); 376 } 377 } 378 // Get styled date. 379 CharSequence date = DateUtils.getRelativeTimeSpanString(mContext, 380 conversation.dateMs); 381 382 // Load up our remote view. 383 RemoteViews remoteViews = mWidgetConversationViewBuilder.getStyledView( 384 senderBuilder, statusBuilder, date, filterTag(conversation.subject), 385 conversation.snippet, conversation.getRawFolders(), 386 conversation.hasAttachments, conversation.read, mFolder); 387 388 // On click intent. 389 remoteViews.setOnClickFillInIntent(R.id.widget_conversation, 390 Utils.createViewConversationIntent(conversation, mFolder, mAccount)); 391 392 return remoteViews; 393 } 394 } 395 396 private SpannableStringBuilder ellipsizeStyledSenders(ConversationInfo info, int maxChars, 397 SpannableString[] styledSenders) { 398 SpannableStringBuilder builder = new SpannableStringBuilder(); 399 int totalChars = 0; 400 boolean ellipsize = false; 401 SpannableString ellipsizedText; 402 int width; 403 SpannableStringBuilder messageInfoString = createMessageInfo(info); 404 // Paint the message info string to see if we lose space. 405 int messageInfoChars = messageInfoString.length(); 406 totalChars += messageInfoChars; 407 408 for (SpannableString sender : styledSenders) { 409 // No more width available, we'll only show fixed fragments. 410 if (ellipsize) { 411 break; 412 } 413 // New line and ellipsize text if needed. 414 ellipsizedText = null; 415 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 416 width = sender.length(); 417 if (totalChars + width > maxChars) { 418 ellipsize = true; 419 } 420 if (ellipsize) { 421 ellipsizedText = copyStyles(spans, 422 TextUtils.ellipsize(sender, new TextPaint(), width, TruncateAt.END)); 423 width = ellipsizedText.length(); 424 } 425 totalChars += width; 426 427 final CharSequence fragmentDisplayText; 428 if (ellipsizedText != null) { 429 fragmentDisplayText = ellipsizedText; 430 } else { 431 // Prepend the dividing token, unless this is the first 432 // sender. 433 if (builder.length() > 0) { 434 fragmentDisplayText = copyStyles(spans, mSendersSplitToken + sender); 435 } else { 436 fragmentDisplayText = sender; 437 } 438 } 439 builder.append(fragmentDisplayText); 440 } 441 if (messageInfoString.length() > 0) { 442 builder.append(messageInfoString); 443 } 444 return builder; 445 } 446 447 private SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 448 SpannableString s = new SpannableString(newText); 449 if (spans != null && spans.length > 0) { 450 s.setSpan(spans[0], 0, s.length(), 0); 451 } 452 return s; 453 } 454 455 private SpannableStringBuilder createMessageInfo(ConversationInfo conversationInfo) { 456 SpannableStringBuilder messageInfo = new SpannableStringBuilder(); 457 if (conversationInfo != null) { 458 int count = conversationInfo.messageCount; 459 if (count > 0) { 460 messageInfo.append(" "); 461 } 462 if (count > 1) { 463 messageInfo.append(count + ""); 464 } 465 } 466 return messageInfo; 467 } 468 469 /** 470 * @return the "View more conversations" view. 471 */ 472 private RemoteViews getViewMoreConversationsView() { 473 RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); 474 view.setTextViewText( 475 R.id.loading_text, mContext.getText(R.string.view_more_conversations)); 476 view.setOnClickFillInIntent(R.id.widget_loading, 477 Utils.createViewFolderIntent(mFolder, mAccount)); 478 return view; 479 } 480 481 @Override 482 public RemoteViews getLoadingView() { 483 RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); 484 view.setTextViewText( 485 R.id.loading_text, mContext.getText(R.string.loading_conversation)); 486 return view; 487 } 488 489 @Override 490 public int getViewTypeCount() { 491 return 2; 492 } 493 494 @Override 495 public long getItemId(int position) { 496 return position; 497 } 498 499 @Override 500 public boolean hasStableIds() { 501 return false; 502 } 503 504 @Override 505 public void onLoadComplete(Loader<Cursor> loader, Cursor data) { 506 final AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(mContext); 507 508 if (loader == mFolderLoader) { 509 if (!isDataValid(data)) { 510 return; 511 } 512 513 final int unreadCount = data.getInt(UIProvider.FOLDER_UNREAD_COUNT_COLUMN); 514 final String folderName = data.getString(UIProvider.FOLDER_NAME_COLUMN); 515 mFolderCount = data.getInt(UIProvider.FOLDER_TOTAL_COUNT_COLUMN); 516 517 final RemoteViews remoteViews = 518 new RemoteViews(mContext.getPackageName(), R.layout.widget); 519 520 if (!mFolderInformationShown && !TextUtils.isEmpty(folderName)) { 521 // We want to do a full update to the widget at least once, as the widget 522 // manager doesn't cache the state of the remote views when doing a partial 523 // widget update. This causes the folder name to be shown as blank if the state 524 // of the widget is restored. 525 mService.configureValidAccountWidget( 526 mContext, remoteViews, mAppWidgetId, mAccount, mFolder, folderName); 527 appWidgetManager.updateAppWidget(mAppWidgetId, remoteViews); 528 mFolderInformationShown = true; 529 } 530 531 remoteViews.setViewVisibility(R.id.widget_folder, View.VISIBLE); 532 remoteViews.setTextViewText(R.id.widget_folder, folderName); 533 remoteViews.setViewVisibility(R.id.widget_unread_count, View.VISIBLE); 534 remoteViews.setTextViewText(R.id.widget_unread_count, 535 Utils.getUnreadCountString(mContext, unreadCount)); 536 537 appWidgetManager.partiallyUpdateAppWidget(mAppWidgetId, remoteViews); 538 } else if (loader == mConversationCursorLoader) { 539 540 // We want to cache the new cursor 541 synchronized (sWidgetLock) { 542 if (!isDataValid(data)) { 543 mConversationCursor = null; 544 } else { 545 mConversationCursor = data; 546 } 547 } 548 appWidgetManager.notifyAppWidgetViewDataChanged( 549 mAppWidgetId, R.id.conversation_list); 550 } 551 } 552 553 /** 554 * Returns a boolean indicating whether this cursor has valid data. 555 * Note: This seeks to the first position in the cursor 556 */ 557 private boolean isDataValid(Cursor cursor) { 558 return cursor != null && !cursor.isClosed() && cursor.moveToFirst(); 559 } 560 561 /** 562 * If the subject contains the tag of a mailing-list (text surrounded with []), return the 563 * subject with that tag ellipsized, e.g. "[android-gmail-team] Hello" -> "[andr...] Hello" 564 */ 565 private static String filterTag(String subject) { 566 String result = subject; 567 if (subject.length() > 0 && subject.charAt(0) == '[') { 568 int end = subject.indexOf(']'); 569 if (end > 0) { 570 String tag = subject.substring(1, end); 571 result = "[" + Utils.ellipsize(tag, 7) + "]" + subject.substring(end + 1); 572 } 573 } 574 575 return result; 576 } 577 578 /** 579 * A {@link DelayedTaskHandler} to throttle folder update to a reasonable rate. 580 */ 581 private class FolderUpdateHandler extends DelayedTaskHandler { 582 public FolderUpdateHandler(int refreshDelay) { 583 super(Looper.myLooper(), refreshDelay); 584 } 585 586 @Override 587 protected void performTask() { 588 // Start the loader. The cached data will be returned if present. 589 if (mFolderLoader != null) { 590 mFolderLoader.startLoading(); 591 } 592 } 593 } 594 } 595} 596