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