EmailWidget.java revision d94522c6d9b3afad6b6796bc58d5a31b11d7b16d
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 android.app.PendingIntent; 20import android.appwidget.AppWidgetManager; 21import android.content.Context; 22import android.content.Intent; 23import android.content.Loader; 24import android.content.Loader.OnLoadCompleteListener; 25import android.content.res.Resources; 26import android.database.Cursor; 27import android.graphics.Typeface; 28import android.net.Uri; 29import android.net.Uri.Builder; 30import android.text.Spannable; 31import android.text.SpannableString; 32import android.text.SpannableStringBuilder; 33import android.text.TextUtils; 34import android.text.format.DateUtils; 35import android.text.style.AbsoluteSizeSpan; 36import android.text.style.ForegroundColorSpan; 37import android.text.style.StyleSpan; 38import android.util.Log; 39import android.view.View; 40import android.widget.RemoteViews; 41import android.widget.RemoteViewsService; 42 43import com.android.email.Email; 44import com.android.email.R; 45import com.android.email.ResourceHelper; 46import com.android.email.activity.MessageCompose; 47import com.android.email.activity.UiUtilities; 48import com.android.email.activity.Welcome; 49import com.android.email.provider.WidgetProvider.WidgetService; 50import com.android.emailcommon.Logging; 51import com.android.emailcommon.provider.Account; 52import com.android.emailcommon.provider.EmailContent.Message; 53import com.android.emailcommon.provider.Mailbox; 54import com.android.emailcommon.utility.EmailAsyncTask; 55 56import java.util.List; 57 58/** 59 * The email widget. 60 * <p><em>NOTE</em>: All methods must be called on the UI thread so synchronization is NOT required 61 * in this class) 62 */ 63public class EmailWidget implements RemoteViewsService.RemoteViewsFactory, 64 OnLoadCompleteListener<Cursor> { 65 public static final String TAG = "EmailWidget"; 66 67 /** 68 * When handling clicks in a widget ListView, a single PendingIntent template is provided to 69 * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent" 70 * on each list element; when a click is received, this "fillInIntent" is merged with the 71 * PendingIntent using Intent.fillIn(). Since this mechanism does NOT preserve the Extras 72 * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its 73 * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via 74 * Intent.setDataAndType() 75 * 76 * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value 77 * is entirely arbitrary. 78 * 79 * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only 80 * in the requirement that it be syntactically valid. 81 * 82 * We use the following convention for our commands: 83 * widget://command/<command>/<arg1>[/<arg2>] 84 */ 85 private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data"; 86 87 private static final Uri COMMAND_URI = Uri.parse("widget://command"); 88 89 // Command names and Uri's built upon COMMAND_URI 90 private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message"; 91 private static final Uri COMMAND_URI_VIEW_MESSAGE = 92 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); 93 94 // TODO Can this be moved to the loader and made a database 'LIMIT'? 95 private static final int MAX_MESSAGE_LIST_COUNT = 25; 96 97 private static String sSubjectSnippetDivider; 98 private static int sSenderFontSize; 99 private static int sSubjectFontSize; 100 private static int sDateFontSize; 101 private static int sDefaultTextColor; 102 private static int sLightTextColor; 103 104 private final Context mContext; 105 private final AppWidgetManager mWidgetManager; 106 107 // The widget identifier 108 private final int mWidgetId; 109 110 // The widget's loader (derived from ThrottlingCursorLoader) 111 private final EmailWidgetLoader mLoader; 112 private final ResourceHelper mResourceHelper; 113 114 /** The account ID of this widget. May be {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */ 115 private long mAccountId = Account.NO_ACCOUNT; 116 /** The display name of this account */ 117 private String mAccountName; 118 /** The display name of this mailbox */ 119 private String mMailboxName; 120 121 /** 122 * The cursor for the messages, with some extra info such as the number of accounts. 123 * 124 * Note this cursor can be closed any time by the loader. Always use {@link #isCursorValid()} 125 * before touching its contents. 126 */ 127 private EmailWidgetLoader.WidgetCursor mCursor; 128 129 public EmailWidget(Context context, int _widgetId) { 130 super(); 131 if (Email.DEBUG) { 132 Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); 133 } 134 mContext = context.getApplicationContext(); 135 mWidgetManager = AppWidgetManager.getInstance(mContext); 136 137 mWidgetId = _widgetId; 138 mLoader = new EmailWidgetLoader(mContext); 139 mLoader.registerListener(0, this); 140 if (sSubjectSnippetDivider == null) { 141 // Initialize string, color, dimension resources 142 Resources res = mContext.getResources(); 143 sSubjectSnippetDivider = 144 res.getString(R.string.message_list_subject_snippet_divider); 145 sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size); 146 sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size); 147 sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size); 148 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 149 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 150 sLightTextColor = res.getColor(R.color.widget_light_text_color); 151 } 152 mResourceHelper = ResourceHelper.getInstance(mContext); 153 } 154 155 /** 156 * Start loading the data. At this point nothing on the widget changes -- the current view 157 * will remain valid until the loader loads the latest data. 158 */ 159 public void start() { 160 long accountId = WidgetManager.loadAccountIdPref(mContext, mWidgetId); 161 long mailboxId = WidgetManager.loadMailboxIdPref(mContext, mWidgetId); 162 // Legacy support; if preferences haven't been saved for this widget, load something 163 if (accountId == Account.NO_ACCOUNT) { 164 accountId = Account.ACCOUNT_ID_COMBINED_VIEW; 165 mailboxId = Mailbox.QUERY_ALL_INBOXES; 166 } 167 mAccountId = accountId; 168 mLoader.load(mAccountId, mailboxId); 169 } 170 171 /** 172 * Resets the data in the widget and forces a reload. 173 */ 174 public void reset() { 175 mLoader.reset(); 176 start(); 177 } 178 179 private boolean isCursorValid() { 180 return mCursor != null && !mCursor.isClosed(); 181 } 182 183 /** 184 * Called when the loader finished loading data. Update the widget. 185 */ 186 @Override 187 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 188 mCursor = (EmailWidgetLoader.WidgetCursor) cursor; // Save away the cursor 189 mAccountName = mCursor.getAccountName(); 190 mMailboxName = mCursor.getMailboxName(); 191 updateHeader(); 192 mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 193 } 194 195 /** 196 * Convenience method for creating an onClickPendingIntent that launches another activity 197 * directly. 198 * 199 * @param views The RemoteViews we're inflating 200 * @param buttonId the id of the button view 201 * @param intent The intent to be used when launching the activity 202 */ 203 private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) { 204 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // just in case intent comes without it 205 PendingIntent pendingIntent = 206 PendingIntent.getActivity(mContext, (int) mAccountId, intent, 207 PendingIntent.FLAG_UPDATE_CURRENT); 208 views.setOnClickPendingIntent(buttonId, pendingIntent); 209 } 210 211 /** 212 * Convenience method for constructing a fillInIntent for a given list view element. 213 * Appends the command and any arguments to a base Uri. 214 * 215 * @param views the RemoteViews we are inflating 216 * @param viewId the id of the view 217 * @param baseUri the base uri for the command 218 * @param args any arguments to the command 219 */ 220 private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) { 221 Intent intent = new Intent(); 222 Builder builder = baseUri.buildUpon(); 223 for (String arg: args) { 224 builder.appendPath(arg); 225 } 226 intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE); 227 views.setOnClickFillInIntent(viewId, intent); 228 } 229 230 /** 231 * Called back by {@link com.android.email.provider.WidgetProvider.WidgetService} to 232 * handle intents created by remote views. 233 */ 234 public static boolean processIntent(Context context, Intent intent) { 235 final Uri data = intent.getData(); 236 if (data == null) { 237 return false; 238 } 239 List<String> pathSegments = data.getPathSegments(); 240 // Our path segments are <command>, <arg1> [, <arg2>] 241 // First, a quick check of Uri validity 242 if (pathSegments.size() < 2) { 243 throw new IllegalArgumentException(); 244 } 245 String command = pathSegments.get(0); 246 // Ignore unknown action names 247 try { 248 final long arg1 = Long.parseLong(pathSegments.get(1)); 249 if (EmailWidget.COMMAND_NAME_VIEW_MESSAGE.equals(command)) { 250 // "view", <message id>, <mailbox id> 251 openMessage(context, Long.parseLong(pathSegments.get(2)), arg1); 252 } 253 } catch (NumberFormatException e) { 254 // Shouldn't happen as we construct all of the Uri's 255 return false; 256 } 257 return true; 258 } 259 260 private static void openMessage(final Context context, final long mailboxId, 261 final long messageId) { 262 EmailAsyncTask.runAsyncParallel(new Runnable() { 263 @Override 264 public void run() { 265 Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); 266 if (mailbox == null) return; 267 context.startActivity(Welcome.createOpenMessageIntent(context, mailbox.mAccountKey, 268 mailboxId, messageId)); 269 } 270 }); 271 } 272 273 private void setTextViewTextAndDesc(RemoteViews views, final int id, String text) { 274 views.setTextViewText(id, text); 275 views.setContentDescription(id, text); 276 } 277 278 private void setupTitleAndCount(RemoteViews views) { 279 // Set up the title (view type + count of messages) 280 setTextViewTextAndDesc(views, R.id.widget_title, mMailboxName); 281 views.setViewVisibility(R.id.widget_tap, View.VISIBLE); 282 setTextViewTextAndDesc(views, R.id.widget_tap, mAccountName); 283 String count = ""; 284 if (isCursorValid()) { 285 count = UiUtilities.getMessageCountForUi(mContext, mCursor.getMessageCount(), false); 286 } 287 setTextViewTextAndDesc(views, R.id.widget_count, count); 288 } 289 290 /** 291 * Update the "header" of the widget (i.e. everything that doesn't include the scrolling 292 * message list) 293 */ 294 private void updateHeader() { 295 if (Email.DEBUG) { 296 Log.d(TAG, "#updateHeader(); widgetId: " + mWidgetId); 297 } 298 299 // Get the widget layout 300 RemoteViews views = 301 new RemoteViews(mContext.getPackageName(), R.layout.widget); 302 303 // Set up the list with an adapter 304 Intent intent = new Intent(mContext, WidgetService.class); 305 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); 306 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); 307 views.setRemoteAdapter(R.id.message_list, intent); 308 309 setupTitleAndCount(views); 310 311 if (isCursorValid()) { 312 // Show compose icon & message list 313 if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 314 // Don't allow compose for "combined" view 315 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); 316 } else { 317 views.setViewVisibility(R.id.widget_compose, View.VISIBLE); 318 } 319 views.setViewVisibility(R.id.message_list, View.VISIBLE); 320 views.setViewVisibility(R.id.tap_to_configure, View.GONE); 321 // Create click intent for "compose email" target 322 intent = MessageCompose.getMessageComposeIntent(mContext, mAccountId); 323 intent.putExtra(MessageCompose.EXTRA_FROM_WIDGET, true); 324 setActivityIntent(views, R.id.widget_compose, intent); 325 // Create click intent for logo to open inbox 326 intent = Welcome.createOpenAccountInboxIntent(mContext, mAccountId); 327 setActivityIntent(views, R.id.widget_logo, intent); 328 } else { 329 // TODO This really should never happen ... probably can remove the else block 330 // Hide compose icon & show "touch to configure" text 331 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); 332 views.setViewVisibility(R.id.message_list, View.GONE); 333 views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE); 334 // Create click intent for "touch to configure" target 335 intent = Welcome.createOpenAccountInboxIntent(mContext, -1); 336 setActivityIntent(views, R.id.tap_to_configure, intent); 337 } 338 339 // Use a bare intent for our template; we need to fill everything in 340 intent = new Intent(mContext, WidgetService.class); 341 PendingIntent pendingIntent = PendingIntent.getService(mContext, 0, intent, 342 PendingIntent.FLAG_UPDATE_CURRENT); 343 views.setPendingIntentTemplate(R.id.message_list, pendingIntent); 344 345 // And finally update the widget 346 mWidgetManager.updateAppWidget(mWidgetId, views); 347 } 348 349 /** 350 * Add size and color styling to text 351 * 352 * @param text the text to style 353 * @param size the font size for this text 354 * @param color the color for this text 355 * @return a CharSequence quitable for use in RemoteViews.setTextViewText() 356 */ 357 private CharSequence addStyle(CharSequence text, int size, int color) { 358 SpannableStringBuilder builder = new SpannableStringBuilder(text); 359 builder.setSpan( 360 new AbsoluteSizeSpan(size), 0, text.length(), 361 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 362 if (color != 0) { 363 builder.setSpan(new ForegroundColorSpan(color), 0, text.length(), 364 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 365 } 366 return builder; 367 } 368 369 /** 370 * Create styled text for our combination subject and snippet 371 * 372 * @param subject the message's subject (or null) 373 * @param snippet the message's snippet (or null) 374 * @param read whether or not the message is read 375 * @return a CharSequence suitable for use in RemoteViews.setTextViewText() 376 */ 377 private CharSequence getStyledSubjectSnippet(String subject, String snippet, boolean read) { 378 SpannableStringBuilder ssb = new SpannableStringBuilder(); 379 boolean hasSubject = false; 380 if (!TextUtils.isEmpty(subject)) { 381 SpannableString ss = new SpannableString(subject); 382 ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), 383 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 384 ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(), 385 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 386 ssb.append(ss); 387 hasSubject = true; 388 } 389 if (!TextUtils.isEmpty(snippet)) { 390 if (hasSubject) { 391 ssb.append(sSubjectSnippetDivider); 392 } 393 SpannableString ss = new SpannableString(snippet); 394 ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(), 395 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 396 ssb.append(ss); 397 } 398 return addStyle(ssb, sSubjectFontSize, 0); 399 } 400 401 @Override 402 public RemoteViews getViewAt(int position) { 403 // Use the cursor to set up the widget 404 if (!isCursorValid() || !mCursor.moveToPosition(position)) { 405 return getLoadingView(); 406 } 407 RemoteViews views = 408 new RemoteViews(mContext.getPackageName(), R.layout.widget_list_item); 409 boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1; 410 int drawableId = R.drawable.message_list_read_selector; 411 if (isUnread) { 412 drawableId = R.drawable.message_list_unread_selector; 413 } 414 views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); 415 416 // Add style to sender 417 String rawSender = 418 mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME) 419 ? "" // an empty string 420 : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME); 421 SpannableStringBuilder from = new SpannableStringBuilder(rawSender); 422 from.setSpan( 423 isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0, 424 from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 425 CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor); 426 views.setTextViewText(R.id.widget_from, styledFrom); 427 views.setContentDescription(R.id.widget_from, rawSender); 428 429 long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP); 430 // Get a nicely formatted date string (relative to today) 431 String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); 432 // Add style to date 433 CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor); 434 views.setTextViewText(R.id.widget_date, styledDate); 435 views.setContentDescription(R.id.widget_date, date); 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 = getStyledSubjectSnippet(subject, snippet, !isUnread); 441 views.setTextViewText(R.id.widget_subject, subjectAndSnippet); 442 views.setContentDescription(R.id.widget_subject, subject); 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 (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) { 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 (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 512 Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId); 513 } 514 515 if (mLoader != null) { 516 mLoader.reset(); 517 } 518 } 519 520 @Override 521 public void onDestroy() { 522 if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 523 Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId); 524 } 525 526 if (mLoader != null) { 527 mLoader.reset(); 528 } 529 } 530 531 @Override 532 public void onCreate() { 533 if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 534 Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId); 535 } 536 } 537 538 @Override 539 public String toString() { 540 return "View=" + mAccountName; 541 } 542} 543