EmailWidget.java revision 2fbb3db5d86210d03175ce77ff08c989a96c5864
1/* 2 * Copyright (C) 2011 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.widget; 18 19import com.android.email.Email; 20import com.android.email.R; 21import com.android.email.ResourceHelper; 22import com.android.email.activity.MessageCompose; 23import com.android.email.activity.UiUtilities; 24import com.android.email.activity.Welcome; 25import com.android.email.provider.WidgetProvider.WidgetService; 26import com.android.emailcommon.provider.EmailContent.Mailbox; 27import com.android.emailcommon.provider.EmailContent.Message; 28import com.android.emailcommon.utility.Utility; 29 30import android.app.PendingIntent; 31import android.appwidget.AppWidgetManager; 32import android.content.ContentUris; 33import android.content.Context; 34import android.content.Intent; 35import android.content.Loader; 36import android.content.Loader.OnLoadCompleteListener; 37import android.content.res.Resources; 38import android.database.Cursor; 39import android.graphics.Typeface; 40import android.net.Uri; 41import android.net.Uri.Builder; 42import android.os.AsyncTask; 43import android.text.Spannable; 44import android.text.SpannableString; 45import android.text.SpannableStringBuilder; 46import android.text.TextUtils; 47import android.text.format.DateUtils; 48import android.text.style.AbsoluteSizeSpan; 49import android.text.style.ForegroundColorSpan; 50import android.text.style.StyleSpan; 51import android.util.Log; 52import android.view.View; 53import android.widget.RemoteViews; 54import android.widget.RemoteViewsService; 55 56import java.util.List; 57 58/** 59 * The email widget. 60 * 61 * Threading notes: 62 * - All methods must be called on the UI thread, except for {@link WidgetUpdater#doInBackground}. 63 * - {@link WidgetUpdater#doInBackground} must not read/write any members of {@link EmailWidget}. 64 * - (So no synchronizations are required in this class) 65 */ 66public class EmailWidget implements RemoteViewsService.RemoteViewsFactory, 67 OnLoadCompleteListener<Cursor> { 68 public static final String TAG = "EmailWidget"; 69 70 /** 71 * When handling clicks in a widget ListView, a single PendingIntent template is provided to 72 * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent" 73 * on each list element; when a click is received, this "fillInIntent" is merged with the 74 * PendingIntent using Intent.fillIn(). Since this mechanism does NOT preserve the Extras 75 * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its 76 * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via 77 * Intent.setDataAndType() 78 * 79 * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value 80 * is entirely arbitrary. 81 * 82 * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only 83 * in the requirement that it be syntactically valid. 84 * 85 * We use the following convention for our commands: 86 * widget://command/<command>/<arg1>[/<arg2>] 87 */ 88 private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data"; 89 90 private static final Uri COMMAND_URI = Uri.parse("widget://command"); 91 92 // Command names and Uri's built upon COMMAND_URI 93 private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view"; 94 private static final Uri COMMAND_URI_SWITCH_LIST_VIEW = 95 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build(); 96 private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message"; 97 private static final Uri COMMAND_URI_VIEW_MESSAGE = 98 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); 99 100 private static final int MAX_MESSAGE_LIST_COUNT = 25; 101 102 private static String sSubjectSnippetDivider; 103 private static String sConfigureText; 104 private static int sSenderFontSize; 105 private static int sSubjectFontSize; 106 private static int sDateFontSize; 107 private static int sDefaultTextColor; 108 private static int sLightTextColor; 109 110 private final Context mContext; 111 private final AppWidgetManager mWidgetManager; 112 113 // The widget identifier 114 private final int mWidgetId; 115 116 // The widget's loader (derived from ThrottlingCursorLoader) 117 private final EmailWidgetLoader mLoader; 118 private final ResourceHelper mResourceHelper; 119 120 /** 121 * The cursor for the messages, with some extra info such as the number of accounts. 122 * 123 * Note this cursor can be closed any time by the loader. Always use {@link #isCursorValid()} 124 * before touching its contents. 125 */ 126 private EmailWidgetLoader.CursorWithCounts mCursor; 127 128 /** The current view type */ 129 /* package */ WidgetView mWidgetView = WidgetView.UNINITIALIZED_VIEW; 130 131 public EmailWidget(Context context, int _widgetId) { 132 super(); 133 if (Email.DEBUG) { 134 Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); 135 } 136 mContext = context.getApplicationContext(); 137 mWidgetManager = AppWidgetManager.getInstance(mContext); 138 139 mWidgetId = _widgetId; 140 mLoader = new EmailWidgetLoader(mContext); 141 mLoader.registerListener(0, this); 142 if (sSubjectSnippetDivider == null) { 143 // Initialize string, color, dimension resources 144 Resources res = mContext.getResources(); 145 sSubjectSnippetDivider = 146 res.getString(R.string.message_list_subject_snippet_divider); 147 sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size); 148 sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size); 149 sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size); 150 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 151 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 152 sLightTextColor = res.getColor(R.color.widget_light_text_color); 153 sConfigureText = res.getString(R.string.widget_other_views); 154 } 155 mResourceHelper = ResourceHelper.getInstance(mContext); 156 } 157 158 public void start() { 159 // The default view is UNINITIALIZED_VIEW, and we switch to the next one, which should 160 // be the initial view. (the first view shown to the user.) 161 switchView(); 162 } 163 164 private boolean isCursorValid() { 165 return mCursor != null && !mCursor.isClosed(); 166 } 167 168 /** 169 * Called when the loader finished loading data. Update the widget. 170 */ 171 @Override 172 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 173 // Save away the cursor 174 mCursor = (EmailWidgetLoader.CursorWithCounts) cursor; 175 mWidgetView = mLoader.getLoadingWidgetView(); 176 177 RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget); 178 updateHeader(); 179 setupTitleAndCount(views); 180 mWidgetManager.partiallyUpdateAppWidget(mWidgetId, views); 181 mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 182 } 183 184 /** 185 * Start loading the data. At this point nothing on the widget changes -- the current view 186 * will remain valid until the loader loads the latest data. 187 */ 188 private void loadView(WidgetView view) { 189 mLoader.load(view); 190 } 191 192 /** 193 * Convenience method for creating an onClickPendingIntent that executes a command via 194 * our command Uri. Used for the "next view" command; appends the widget id to the command 195 * Uri. 196 * 197 * @param views The RemoteViews we're inflating 198 * @param buttonId the id of the button view 199 * @param data the command Uri 200 */ 201 private void setCommandIntent(RemoteViews views, int buttonId, Uri data) { 202 Intent intent = new Intent(mContext, WidgetService.class); 203 intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), WIDGET_DATA_MIME_TYPE); 204 PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent, 205 PendingIntent.FLAG_UPDATE_CURRENT); 206 views.setOnClickPendingIntent(buttonId, pendingIntent); 207 } 208 209 /** 210 * Convenience method for creating an onClickPendingIntent that launches another activity 211 * directly. 212 * 213 * @param views The RemoteViews we're inflating 214 * @param buttonId the id of the button view 215 * @param intent The intent to be used when launching the activity 216 */ 217 private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) { 218 PendingIntent pendingIntent = 219 PendingIntent.getActivity(mContext, 0, intent, 0); 220 views.setOnClickPendingIntent(buttonId, pendingIntent); 221 } 222 223 /** 224 * Convenience method for constructing a fillInIntent for a given list view element. 225 * Appends the command and any arguments to a base Uri. 226 * 227 * @param views the RemoteViews we are inflating 228 * @param viewId the id of the view 229 * @param baseUri the base uri for the command 230 * @param args any arguments to the command 231 */ 232 private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) { 233 Intent intent = new Intent(); 234 Builder builder = baseUri.buildUpon(); 235 for (String arg: args) { 236 builder.appendPath(arg); 237 } 238 intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE); 239 views.setOnClickFillInIntent(viewId, intent); 240 } 241 242 /** 243 * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to 244 * handle intents created by remote views. 245 */ 246 public static boolean processIntent(Context context, Intent intent) { 247 final Uri data = intent.getData(); 248 if (data == null) { 249 return false; 250 } 251 List<String> pathSegments = data.getPathSegments(); 252 // Our path segments are <command>, <arg1> [, <arg2>] 253 // First, a quick check of Uri validity 254 if (pathSegments.size() < 2) { 255 throw new IllegalArgumentException(); 256 } 257 String command = pathSegments.get(0); 258 // Ignore unknown action names 259 try { 260 final long arg1 = Long.parseLong(pathSegments.get(1)); 261 if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) { 262 // "view", <message id>, <mailbox id> 263 openMessage(context, Long.parseLong(pathSegments.get(2)), arg1); 264 } else if (EmailWidget.COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) { 265 // "next_view", <widget id> 266 EmailWidget widget = WidgetManager.getInstance().get((int)arg1); 267 if (widget != null) { 268 widget.switchView(); 269 } 270 } 271 } catch (NumberFormatException e) { 272 // Shouldn't happen as we construct all of the Uri's 273 return false; 274 } 275 return true; 276 } 277 278 private static void openMessage(final Context context, final long mailboxId, 279 final long messageId) { 280 Utility.runAsync(new Runnable() { 281 @Override 282 public void run() { 283 Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); 284 if (mailbox == null) return; 285 context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey, 286 mailboxId, messageId)); 287 } 288 }); 289 } 290 291 private void setupTitleAndCount(RemoteViews views) { 292 // Set up the title (view type + count of messages) 293 views.setTextViewText(R.id.widget_title, mWidgetView.getTitle(mContext)); 294 views.setTextViewText(R.id.widget_tap, sConfigureText); 295 String count = ""; 296 if (isCursorValid()) { 297 count = UiUtilities.getMessageCountForUi(mContext, mCursor.getMessageCount(), false); 298 } 299 views.setTextViewText(R.id.widget_count, count); 300 } 301 /** 302 * Update the "header" of the widget (i.e. everything that doesn't include the scrolling 303 * message list) 304 */ 305 private void updateHeader() { 306 if (Email.DEBUG) { 307 Log.d(TAG, "updateWidget " + mWidgetId); 308 } 309 310 // Get the widget layout 311 RemoteViews views = 312 new RemoteViews(mContext.getPackageName(), R.layout.widget); 313 314 // Set up the list with an adapter 315 Intent intent = new Intent(mContext, WidgetService.class); 316 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); 317 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); 318 views.setRemoteAdapter(mWidgetId, R.id.message_list, intent); 319 320 setupTitleAndCount(views); 321 322 if (!isCursorValid() || mCursor.getAccountCount() == 0) { 323 // Hide compose icon & show "touch to configure" text 324 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); 325 views.setViewVisibility(R.id.message_list, View.GONE); 326 views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE); 327 // Create click intent for "touch to configure" target 328 intent = Welcome.createOpenAccountInboxIntent(mContext, -1); 329 setActivityIntent(views, R.id.tap_to_configure, intent); 330 } else { 331 // Show compose icon & message list 332 views.setViewVisibility(R.id.widget_compose, View.VISIBLE); 333 views.setViewVisibility(R.id.message_list, View.VISIBLE); 334 views.setViewVisibility(R.id.tap_to_configure, View.GONE); 335 // Create click intent for "compose email" target 336 intent = MessageCompose.getMessageComposeIntent(mContext, -1); 337 setActivityIntent(views, R.id.widget_compose, intent); 338 } 339 // Create click intent for "view rotation" target 340 setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW); 341 342 // Use a bare intent for our template; we need to fill everything in 343 intent = new Intent(mContext, WidgetService.class); 344 PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent, 345 PendingIntent.FLAG_UPDATE_CURRENT); 346 views.setPendingIntentTemplate(R.id.message_list, pendingIntent); 347 348 // And finally update the widget 349 mWidgetManager.updateAppWidget(mWidgetId, views); 350 } 351 352 /** 353 * Add size and color styling to text 354 * 355 * @param text the text to style 356 * @param size the font size for this text 357 * @param color the color for this text 358 * @return a CharSequence quitable for use in RemoteViews.setTextViewText() 359 */ 360 private CharSequence addStyle(CharSequence text, int size, int color) { 361 SpannableStringBuilder builder = new SpannableStringBuilder(text); 362 builder.setSpan( 363 new AbsoluteSizeSpan(size), 0, text.length(), 364 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 365 if (color != 0) { 366 builder.setSpan(new ForegroundColorSpan(color), 0, text.length(), 367 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 368 } 369 return builder; 370 } 371 372 /** 373 * Create styled text for our combination subject and snippet 374 * 375 * @param subject the message's subject (or null) 376 * @param snippet the message's snippet (or null) 377 * @param read whether or not the message is read 378 * @return a CharSequence suitable for use in RemoteViews.setTextViewText() 379 */ 380 private CharSequence getStyledSubjectSnippet (String subject, String snippet, 381 boolean read) { 382 SpannableStringBuilder ssb = new SpannableStringBuilder(); 383 boolean hasSubject = false; 384 if (!TextUtils.isEmpty(subject)) { 385 SpannableString ss = new SpannableString(subject); 386 ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), 387 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 388 ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(), 389 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 390 ssb.append(ss); 391 hasSubject = true; 392 } 393 if (!TextUtils.isEmpty(snippet)) { 394 if (hasSubject) { 395 ssb.append(sSubjectSnippetDivider); 396 } 397 SpannableString ss = new SpannableString(snippet); 398 ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(), 399 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 400 ssb.append(ss); 401 } 402 return addStyle(ssb, sSubjectFontSize, 0); 403 } 404 405 @Override 406 public RemoteViews getViewAt(int position) { 407 // Use the cursor to set up the widget 408 if (!isCursorValid() || !mCursor.moveToPosition(position)) { 409 return getLoadingView(); 410 } 411 RemoteViews views = 412 new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item); 413 boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1; 414 int drawableId = R.drawable.widget_read_conversation_selector; 415 if (isUnread) { 416 drawableId = R.drawable.widget_unread_conversation_selector; 417 } 418 views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); 419 420 // Add style to sender 421 SpannableStringBuilder from = 422 new SpannableStringBuilder(mCursor.getString( 423 EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME)); 424 from.setSpan( 425 isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0, 426 from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 427 CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor); 428 views.setTextViewText(R.id.widget_from, styledFrom); 429 430 long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP); 431 // Get a nicely formatted date string (relative to today) 432 String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); 433 // Add style to date 434 CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor); 435 views.setTextViewText(R.id.widget_date, styledDate); 436 437 // Add style to subject/snippet 438 String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT); 439 String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET); 440 CharSequence subjectAndSnippet = 441 getStyledSubjectSnippet(subject, snippet, !isUnread); 442 views.setTextViewText(R.id.widget_subject, subjectAndSnippet); 443 444 int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS); 445 boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0; 446 views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE); 447 448 boolean hasAttachment = 449 mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0; 450 views.setViewVisibility(R.id.widget_attachment, 451 hasAttachment ? View.VISIBLE : View.GONE); 452 453 if (mCursor.getAccountCount() <= 1 || mWidgetView.isPerAccount()) { 454 views.setViewVisibility(R.id.color_chip, View.INVISIBLE); 455 } else { 456 long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY); 457 int colorId = mResourceHelper.getAccountColorId(accountId); 458 if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) { 459 // Color defined by resource ID, so, use it 460 views.setViewVisibility(R.id.color_chip, View.VISIBLE); 461 views.setImageViewResource(R.id.color_chip, colorId); 462 } else { 463 // Color not defined by resource ID, nothing we can do, so, hide the chip 464 views.setViewVisibility(R.id.color_chip, View.INVISIBLE); 465 } 466 } 467 468 // Set button intents for view, reply, and delete 469 String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID); 470 String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY); 471 setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, 472 messageId, mailboxId); 473 474 return views; 475 } 476 477 @Override 478 public int getCount() { 479 if (!isCursorValid()) return 0; 480 return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT); 481 } 482 483 @Override 484 public long getItemId(int position) { 485 return position; 486 } 487 488 @Override 489 public RemoteViews getLoadingView() { 490 RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); 491 view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading)); 492 return view; 493 } 494 495 @Override 496 public int getViewTypeCount() { 497 // Regular list view and the "loading" view 498 return 2; 499 } 500 501 @Override 502 public boolean hasStableIds() { 503 return true; 504 } 505 506 @Override 507 public void onDataSetChanged() { 508 } 509 510 public void onDeleted() { 511 if (mLoader != null) { 512 mLoader.reset(); 513 } 514 WidgetManager.getInstance().remove(mWidgetId); 515 } 516 517 @Override 518 public void onDestroy() { 519 if (mLoader != null) { 520 mLoader.reset(); 521 } 522 WidgetManager.getInstance().remove(mWidgetId); 523 } 524 525 @Override 526 public void onCreate() { 527 } 528 529 /** 530 * Update the widget. If the current view is invalid, switch to the next view, then update. 531 */ 532 /* package */ void validateAndUpdate() { 533 new WidgetUpdater(false).execute(); 534 } 535 536 /** 537 * Switch to the next view. 538 */ 539 /* package */ void switchView() { 540 new WidgetUpdater(true).execute(); 541 } 542 543 /** 544 * Update the widget. If {@code switchToNextView} is set true, or the current view is invalid, 545 * switch to the next view. 546 */ 547 private class WidgetUpdater extends AsyncTask<Void, Void, WidgetView> { 548 private final WidgetView mCurrentView; 549 private final boolean mSwitchToNextView; 550 551 public WidgetUpdater(boolean switchToNextView) { 552 mCurrentView = mWidgetView; 553 mSwitchToNextView = switchToNextView; 554 } 555 556 @Override 557 protected WidgetView doInBackground(Void... params) { 558 if (mSwitchToNextView || !mCurrentView.isValid(mContext)) { 559 return mCurrentView.getNext(mContext); 560 } else { 561 return mCurrentView; // Reload the same view. 562 } 563 } 564 565 @Override 566 protected void onPostExecute(WidgetView nextView) { 567 if (nextView != null) { 568 loadView(nextView); 569 } 570 } 571 } 572}