WidgetProvider.java revision ebf0f18cbad20d39900d5ed165fff9978d929e5f
1/* 2 * Copyright (C) 2010 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 */ 16 17package com.android.email.provider; 18 19import com.android.email.Email; 20import com.android.email.R; 21import com.android.email.Utility; 22import com.android.email.activity.MessageCompose; 23import com.android.email.activity.Welcome; 24import com.android.email.data.ThrottlingCursorLoader; 25import com.android.email.provider.EmailContent.Mailbox; 26import com.android.email.provider.EmailContent.Message; 27import com.android.email.provider.EmailContent.MessageColumns; 28 29import android.app.Activity; 30import android.app.PendingIntent; 31import android.app.Service; 32import android.appwidget.AppWidgetManager; 33import android.appwidget.AppWidgetProvider; 34import android.content.ContentResolver; 35import android.content.ContentUris; 36import android.content.Context; 37import android.content.Intent; 38import android.content.Loader; 39import android.database.Cursor; 40import android.graphics.Typeface; 41import android.graphics.Paint.Align; 42import android.net.Uri; 43import android.net.Uri.Builder; 44import android.os.Bundle; 45import android.text.Spannable; 46import android.text.SpannableString; 47import android.text.TextPaint; 48import android.text.TextUtils; 49import android.text.TextUtils.TruncateAt; 50import android.text.format.DateUtils; 51import android.text.style.StyleSpan; 52import android.util.Log; 53import android.widget.RemoteViews; 54import android.widget.RemoteViewsService; 55 56import java.util.HashMap; 57import java.util.List; 58 59public class WidgetProvider extends AppWidgetProvider { 60 private static final String TAG = "WidgetProvider"; 61 62 /** 63 * When handling clicks in a widget ListView, a single PendingIntent template is provided to 64 * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent" 65 * on each list element; when a click is received, this "fillInIntent" is merged with the 66 * PendingIntent using Intent.fillIn(). Since this mechanism does NOT preserve the Extras 67 * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its 68 * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via 69 * Intent.setDataAndType() 70 * 71 * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value 72 * is entirely arbitrary. 73 * 74 * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only 75 * in the requirement that it be syntactically valid. 76 * 77 * We use the following convention for our commands: 78 * widget://command/<command>/<arg1>[/<arg2>] 79 */ 80 private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data"; 81 private static final Uri COMMAND_URI = Uri.parse("widget://command"); 82 83 // Command names and Uri's built upon COMMAND_URI 84 private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view"; 85 private static final Uri COMMAND_URI_SWITCH_LIST_VIEW = 86 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build(); 87 private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message"; 88 private static final Uri COMMAND_URI_VIEW_MESSAGE = 89 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); 90 91 private static final int TOTAL_COUNT_UNKNOWN = -1; 92 private static final int MAX_MESSAGE_LIST_COUNT = 25; 93 94 private static final String SORT_DESCENDING = MessageColumns.TIMESTAMP + " DESC"; 95 96 // Map holding our instantiated widgets, accessed by widget id 97 private static HashMap<Integer, EmailWidget> sWidgetMap = new HashMap<Integer, EmailWidget>(); 98 private static AppWidgetManager sWidgetManager; 99 private static Context sContext; 100 private static ContentResolver sResolver; 101 private static TextPaint sDatePaint = new TextPaint(); 102 103 /** 104 * Types of views that we're prepared to show in the widget - all mail, unread mail, and starred 105 * mail; we rotate between them. Each ViewType is composed of a selection string and a title. 106 */ 107 public enum ViewType { 108 ALL_MAIL(null, R.string.widget_all_mail), 109 UNREAD(MessageColumns.FLAG_READ + "=0", R.string.widget_unread), 110 STARRED(MessageColumns.FLAG_FAVORITE + "=1", R.string.widget_starred); 111 112 private final String selection; 113 private final int titleResource; 114 private String title; 115 116 ViewType(String _selection, int _titleResource) { 117 selection = _selection; 118 titleResource = _titleResource; 119 } 120 121 public String getTitle(Context context) { 122 if (title == null) { 123 title = context.getString(titleResource); 124 } 125 return title; 126 } 127 } 128 129 static class EmailWidget implements RemoteViewsService.RemoteViewsFactory { 130 // The widget identifier 131 private final int mWidgetId; 132 133 // The cursor underlying the message list for this widget; this must only be modified while 134 // holding mCursorLock 135 private volatile Cursor mCursor; 136 // A lock on our cursor, which is used in the UI thread while inflating views, and by 137 // our Loader in the background 138 private final Object mCursorLock = new Object(); 139 // Number of records in the cursor 140 private int mCursorCount = TOTAL_COUNT_UNKNOWN; 141 // The widget's loader (derived from ThrottlingCursorLoader) 142 private WidgetLoader mLoader; 143 144 // The current view type (all mail, unread, or starred for now) 145 private ViewType mViewType = ViewType.ALL_MAIL; 146 147 // The projection to be used by the WidgetLoader 148 public static final String[] WIDGET_PROJECTION = new String[] { 149 EmailContent.RECORD_ID, MessageColumns.DISPLAY_NAME, MessageColumns.TIMESTAMP, 150 MessageColumns.SUBJECT, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, 151 MessageColumns.FLAG_ATTACHMENT, MessageColumns.MAILBOX_KEY, MessageColumns.SNIPPET, 152 MessageColumns.ACCOUNT_KEY 153 }; 154 public static final int WIDGET_COLUMN_ID = 0; 155 public static final int WIDGET_COLUMN_DISPLAY_NAME = 1; 156 public static final int WIDGET_COLUMN_TIMESTAMP = 2; 157 public static final int WIDGET_COLUMN_SUBJECT = 3; 158 public static final int WIDGET_COLUMN_FLAG_READ = 4; 159 public static final int WIDGET_COLUMN_FLAG_FAVORITE = 5; 160 public static final int WIDGET_COLUMN_FLAG_ATTACHMENT = 6; 161 public static final int WIDGET_COLUMN_MAILBOX_KEY = 7; 162 public static final int WIDGET_COLUMN_SNIPPET = 8; 163 public static final int WIDGET_COLUMN_ACCOUNT_KEY = 9; 164 165 public EmailWidget(int _widgetId) { 166 super(); 167 if (Email.DEBUG) { 168 Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); 169 } 170 mWidgetId = _widgetId; 171 mLoader = new WidgetLoader(); 172 if (sDatePaint == null) { 173 sDatePaint = new TextPaint(); 174 sDatePaint.setTypeface(Typeface.DEFAULT); 175 sDatePaint.setTextSize(14); 176 sDatePaint.setAntiAlias(true); 177 sDatePaint.setTextAlign(Align.RIGHT); 178 } 179 } 180 181 /** 182 * The ThrottlingCursorLoader does all of the heavy lifting in managing the data loading 183 * task; all we need is to register a listener so that we're notified when the load is 184 * complete. 185 */ 186 final class WidgetLoader extends ThrottlingCursorLoader { 187 protected WidgetLoader() { 188 super(sContext, Message.CONTENT_URI, WIDGET_PROJECTION, mViewType.selection, null, 189 SORT_DESCENDING); 190 registerListener(0, new OnLoadCompleteListener<Cursor>() { 191 @Override 192 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 193 synchronized (mCursorLock) { 194 // Save away the cursor 195 mCursor = cursor; 196 // Reset the notification Uri to our Message table notifier URI 197 mCursor.setNotificationUri(sResolver, Message.NOTIFIER_URI); 198 // Save away the count (for display) 199 mCursorCount = mCursor.getCount(); 200 if (Email.DEBUG) { 201 Log.d(TAG, "onLoadComplete, count = " + cursor.getCount()); 202 } 203 } 204 RemoteViews views = 205 new RemoteViews(sContext.getPackageName(), R.layout.widget); 206 views.setTextViewText(R.id.widget_title, 207 mViewType.getTitle(sContext) + " (" + mCursorCount + ")"); 208 sWidgetManager.partiallyUpdateAppWidget(mWidgetId, views); 209 sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 210 } 211 }); 212 startLoading(); 213 } 214 215 /** 216 * Convenience method that stops existing loading (if any), sets a (possibly new) 217 * selection criterion, and starts loading 218 * 219 * @param selection a valid query selection argument 220 */ 221 void startLoadingWithSelection(String selection) { 222 stopLoading(); 223 setSelection(selection); 224 startLoading(); 225 } 226 } 227 228 /** 229 * Switch to the next widget view (cycles all -> unread -> starred) 230 */ 231 public void switchToNextView() { 232 switch(mViewType) { 233 case ALL_MAIL: 234 mViewType = ViewType.UNREAD; 235 break; 236 case UNREAD: 237 mViewType = ViewType.STARRED; 238 break; 239 case STARRED: 240 mViewType = ViewType.ALL_MAIL; 241 break; 242 } 243 synchronized(mCursorLock) { 244 mCursorCount = TOTAL_COUNT_UNKNOWN; 245 invalidateCursorLocked(); 246 mLoader.startLoadingWithSelection(mViewType.selection); 247 } 248 } 249 250 /** 251 * Invalidates the current cursor and tells the UI that the underlying data has changed. 252 * This method must be called while holding mCursorLock 253 */ 254 private void invalidateCursorLocked() { 255 mCursor = null; 256 sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 257 } 258 259 private void setStyleSpan(SpannableString str, int typeface) { 260 int length = str.length(); 261 str.setSpan(new StyleSpan(typeface), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 262 } 263 264 private CharSequence formattedText(String str, int typeface) { 265 if (str == null) { 266 return ""; 267 } 268 SpannableString ss = new SpannableString(str); 269 setStyleSpan(ss, typeface); 270 return ss; 271 } 272 273 private CharSequence formattedTextFromCursor(Cursor c, int column, int typeface) { 274 return formattedText(mCursor.getString(column), typeface); 275 } 276 277 278 /** 279 * Convenience method for creating an onClickPendingIntent that executes a command via 280 * our command Uri. Used for the "next view" command; appends the widget id to the command 281 * Uri. 282 * 283 * @param views The RemoteViews we're inflating 284 * @param buttonId the id of the button view 285 * @param data the command Uri 286 */ 287 private void setCommandIntent(RemoteViews views, int buttonId, Uri data) { 288 Intent intent = new Intent(sContext, WidgetService.class); 289 intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), 290 WIDGET_DATA_MIME_TYPE); 291 PendingIntent pendingIntent = PendingIntent.getService(sContext, 0, intent, 292 PendingIntent.FLAG_UPDATE_CURRENT); 293 views.setOnClickPendingIntent(buttonId, pendingIntent); 294 } 295 296 /** 297 * Convenience method for creating an onClickPendingIntent that launches another activity 298 * directly. Used for the "Compose" button 299 * 300 * @param views The RemoteViews we're inflating 301 * @param buttonId the id of the button view 302 * @param activityClass the class of the activity to be launched 303 */ 304 private void setActivityIntent(RemoteViews views, int buttonId, 305 Class<? extends Activity> activityClass) { 306 Intent intent = new Intent(sContext, activityClass); 307 PendingIntent pendingIntent = PendingIntent.getActivity(sContext, 0, intent, 0); 308 views.setOnClickPendingIntent(buttonId, pendingIntent); 309 } 310 311 /** 312 * Convenience method for constructing a fillInIntent for a given list view element. 313 * Appends the command and any arguments to a base Uri. 314 * 315 * @param views the RemoteViews we are inflating 316 * @param viewId the id of the view 317 * @param baseUri the base uri for the command 318 * @param args any arguments to the command 319 */ 320 private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) { 321 Intent intent = new Intent(); 322 Builder builder = baseUri.buildUpon(); 323 for (String arg: args) { 324 builder.appendPath(arg); 325 } 326 intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE); 327 views.setOnClickFillInIntent(viewId, intent); 328 } 329 330 /** 331 * Update the "header" of the widget (i.e. everything that doesn't include the scrolling 332 * message list) 333 */ 334 private void updateHeader() { 335 if (Email.DEBUG) { 336 Log.d(TAG, "updateWidget " + mWidgetId); 337 } 338 339 // Get the widget layout 340 RemoteViews views = new RemoteViews(sContext.getPackageName(), R.layout.widget); 341 342 // Set up the list with an adapter 343 Intent intent = new Intent(sContext, WidgetService.class); 344 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); 345 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); 346 views.setRemoteAdapter(R.id.message_list, intent); 347 348 // Set up the title (view type + count of messages) 349 views.setTextViewText(R.id.widget_title, 350 mViewType.getTitle(sContext) + " (" + mCursorCount + ")"); 351 352 // Set up "new" button (compose new message) and "next view" button 353 setActivityIntent(views, R.id.widget_compose, MessageCompose.class); 354 setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW); 355 356 // Use a bare intent for our template; we need to fill everything in 357 intent = new Intent(sContext, WidgetService.class); 358 PendingIntent pendingIntent = 359 PendingIntent.getService(sContext, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); 360 views.setPendingIntentTemplate(R.id.message_list, pendingIntent); 361 362 // And finally update the widget 363 sWidgetManager.updateAppWidget(mWidgetId, views); 364 } 365 366 /* (non-Javadoc) 367 * @see android.widget.RemoteViewsService.RemoteViewsFactory#getViewAt(int) 368 */ 369 public RemoteViews getViewAt(int position) { 370 // Use the cursor to set up the widget 371 synchronized (mCursorLock) { 372 if (mCursor == null || !mCursor.moveToPosition(position)) { 373 return getLoadingView(); 374 } 375 RemoteViews views = 376 new RemoteViews(sContext.getPackageName(), R.layout.widget_list_item); 377 378 // Typeface for from, subject, and date (normal/bold) depends on whether the message 379 // is read/unread 380 int typeface = (mCursor.getInt(WIDGET_COLUMN_FLAG_READ) == 0) ? Typeface.BOLD 381 : Typeface.NORMAL; 382 views.setTextViewText(R.id.widget_from, 383 formattedTextFromCursor(mCursor, WIDGET_COLUMN_DISPLAY_NAME, typeface)); 384 views.setTextViewText(R.id.widget_subject, 385 formattedTextFromCursor(mCursor, WIDGET_COLUMN_SUBJECT, typeface)); 386 387 long timestamp = mCursor.getLong(WIDGET_COLUMN_TIMESTAMP); 388 // Get a nicely formatted date string (relative to today) 389 String date = DateUtils.getRelativeTimeSpanString(sContext, timestamp).toString(); 390 views.setTextViewText(R.id.widget_date, TextUtils.ellipsize(date, sDatePaint, 64, 391 TruncateAt.END)); 392 393 // Set button intents for view, reply, and delete 394 String messageId = mCursor.getString(WIDGET_COLUMN_ID); 395 String mailboxId = mCursor.getString(WIDGET_COLUMN_MAILBOX_KEY); 396 setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId, 397 mailboxId); 398 399 return views; 400 } 401 } 402 403 @Override 404 public int getCount() { 405 if (mCursor == null) return 0; 406 return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT); 407 } 408 409 @Override 410 public long getItemId(int position) { 411 return position; 412 } 413 414 @Override 415 public RemoteViews getLoadingView() { 416 RemoteViews view = new RemoteViews(sContext.getPackageName(), R.layout.widget_loading); 417 view.setTextViewText(R.id.loading_text, sContext.getString(R.string.widget_loading)); 418 return view; 419 } 420 421 @Override 422 public int getViewTypeCount() { 423 // Regular list view and the "loading" view 424 return 2; 425 } 426 427 @Override 428 public boolean hasStableIds() { 429 return true; 430 } 431 432 @Override 433 public void onDataSetChanged() { 434 } 435 436 @Override 437 public void onDestroy() { 438 if (mLoader != null) { 439 mLoader.stopLoading(); 440 } 441 sWidgetMap.remove(mWidgetId); 442 } 443 444 @Override 445 public void onCreate() { 446 } 447 } 448 449 private static synchronized void update(Context context, int[] appWidgetIds) { 450 for (int widgetId: appWidgetIds) { 451 getOrCreateWidget(context, widgetId).updateHeader(); 452 } 453 } 454 455 private static EmailWidget getOrCreateWidget(Context context, int widgetId) { 456 // Lazily initialize these 457 if (sContext == null) { 458 if (context == null) { // STOPSHIP remove this check 459 throw new RuntimeException("context == null!"); 460 } 461 sContext = context.getApplicationContext(); 462 if (sContext == null) { // STOPSHIP remove this check 463 throw new RuntimeException("getApplicationContext() returned null!"); 464 } 465 sWidgetManager = AppWidgetManager.getInstance(context); 466 sResolver = context.getContentResolver(); 467 } 468 EmailWidget widget = sWidgetMap.get(widgetId); 469 if (widget == null) { 470 if (Email.DEBUG) { 471 Log.d(TAG, "Creating EmailWidget for id #" + widgetId); 472 } 473 widget = new EmailWidget(widgetId); 474 sWidgetMap.put(widgetId, widget); 475 } 476 return widget; 477 } 478 479 @Override 480 public void onDisabled(Context context) { 481 super.onDisabled(context); 482 if (Email.DEBUG) { 483 Log.d(TAG, "onDisabled"); 484 } 485 context.stopService(new Intent(context, WidgetService.class)); 486 } 487 488 @Override 489 public void onEnabled(final Context context) { 490 super.onEnabled(context); 491 if (Email.DEBUG) { 492 Log.d(TAG, "onEnabled"); 493 } 494 context.startService(new Intent(context, WidgetService.class)); 495 } 496 497 @Override 498 public void onReceive(final Context context, Intent intent) { 499 String action = intent.getAction(); 500 if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) { 501 Bundle extras = intent.getExtras(); 502 if (extras != null) { 503 final int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS); 504 if (appWidgetIds != null && appWidgetIds.length > 0) { 505 context.startService(new Intent(context, WidgetService.class)); 506 update(context, appWidgetIds); 507 } 508 } 509 } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) { 510 Bundle extras = intent.getExtras(); 511 if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) { 512 final int widgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID); 513 // Find the widget in the map 514 EmailWidget widget = sWidgetMap.get(widgetId); 515 if (widget != null) { 516 // Stop loading and remove the widget from the map 517 widget.onDestroy(); 518 } 519 } 520 } 521 } 522 523 /** 524 * We use the WidgetService for two purposes: 525 * 1) To provide a widget factory for RemoteViews, and 526 * 2) To process our command Uri's (i.e. take actions on user clicks) 527 */ 528 public static class WidgetService extends RemoteViewsService { 529 @Override 530 public RemoteViewsFactory onGetViewFactory(Intent intent) { 531 // Which widget do we want (nice alliteration, huh?) 532 int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1); 533 if (widgetId == -1) return null; 534 // Find the existing widget or create it 535 return getOrCreateWidget(this, widgetId); 536 } 537 538 @Override 539 public void startActivity(Intent intent) { 540 // Since we're not calling startActivity from an Activity, we need the new task flag 541 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); 542 super.startActivity(intent); 543 } 544 545 @Override 546 public int onStartCommand(Intent intent, int flags, int startId) { 547 Uri data = intent.getData(); 548 if (Email.DEBUG) { 549 Log.d(TAG, "Executing: " + data); 550 } 551 if (data == null) return Service.START_NOT_STICKY; 552 List<String> pathSegments = data.getPathSegments(); 553 // Our path segments are <command>, <arg1> [, <arg2>] 554 // First, a quick check of Uri validity 555 if (pathSegments.size() < 2) { 556 throw new IllegalArgumentException(); 557 } 558 String command = pathSegments.get(0); 559 // Ignore unknown action names 560 try { 561 long arg1 = Long.parseLong(pathSegments.get(1)); 562 if (COMMAND_NAME_VIEW_MESSAGE.equals(command)) { 563 // "view", <message id>, <mailbox id> 564 final long mailboxId = Long.parseLong(pathSegments.get(2)); 565 final long messageId = arg1; 566 Utility.runAsync(new Runnable() { 567 @Override 568 public void run() { 569 openMessage(mailboxId, messageId); 570 } 571 }); 572 } else if (COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) { 573 // "next_view", <widget id> 574 EmailWidget widget = sWidgetMap.get((int)arg1); 575 if (widget != null) { 576 widget.switchToNextView(); 577 } 578 } 579 } catch (NumberFormatException e) { 580 // Shouldn't happen as we construct all of the Uri's 581 } 582 return Service.START_NOT_STICKY; 583 } 584 585 private void openMessage(long mailboxId, long messageId) { 586 // TODO Use narrower projection. 587 Mailbox mailbox = Mailbox.restoreMailboxWithId(this, mailboxId); 588 if (mailbox == null) { 589 return; 590 } 591 startActivity(Welcome.createOpenMessageIntent(this, mailbox.mAccountKey, mailboxId, 592 messageId)); 593 } 594 } 595} 596