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