EmailWidget.java revision a1f20122c9af99430f78a171e2ad552e0ea2cc63
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 private static Object sWidgetLock = new Object(); 104 105 private final Context mContext; 106 private final AppWidgetManager mWidgetManager; 107 108 // The widget identifier 109 private final int mWidgetId; 110 111 // The widget's loader (derived from ThrottlingCursorLoader) 112 private final EmailWidgetLoader mLoader; 113 private final ResourceHelper mResourceHelper; 114 115 /** The account ID of this widget. May be {@link Account#ACCOUNT_ID_COMBINED_VIEW}. */ 116 private long mAccountId = Account.NO_ACCOUNT; 117 /** The display name of this account */ 118 private String mAccountName; 119 /** The display name of this mailbox */ 120 private String mMailboxName; 121 122 /** 123 * The cursor for the messages, with some extra info such as the number of accounts. 124 * 125 * Note this cursor can be closed any time by the loader. Always use {@link #isCursorValid()} 126 * before touching its contents. 127 */ 128 private EmailWidgetLoader.WidgetCursor mCursor; 129 130 public EmailWidget(Context context, int _widgetId) { 131 super(); 132 if (Email.DEBUG) { 133 Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); 134 } 135 mContext = context.getApplicationContext(); 136 mWidgetManager = AppWidgetManager.getInstance(mContext); 137 138 mWidgetId = _widgetId; 139 mLoader = new EmailWidgetLoader(mContext); 140 mLoader.registerListener(0, this); 141 if (sSubjectSnippetDivider == null) { 142 // Initialize string, color, dimension resources 143 Resources res = mContext.getResources(); 144 sSubjectSnippetDivider = 145 res.getString(R.string.message_list_subject_snippet_divider); 146 sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size); 147 sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size); 148 sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size); 149 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 150 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 151 sLightTextColor = res.getColor(R.color.widget_light_text_color); 152 } 153 mResourceHelper = ResourceHelper.getInstance(mContext); 154 } 155 156 /** 157 * Start loading the data. At this point nothing on the widget changes -- the current view 158 * will remain valid until the loader loads the latest data. 159 */ 160 public void start() { 161 long accountId = WidgetManager.loadAccountIdPref(mContext, mWidgetId); 162 long mailboxId = WidgetManager.loadMailboxIdPref(mContext, mWidgetId); 163 // Legacy support; if preferences haven't been saved for this widget, load something 164 if (accountId == Account.NO_ACCOUNT) { 165 accountId = Account.ACCOUNT_ID_COMBINED_VIEW; 166 mailboxId = Mailbox.QUERY_ALL_INBOXES; 167 } 168 mAccountId = accountId; 169 mLoader.load(mAccountId, mailboxId); 170 } 171 172 /** 173 * Resets the data in the widget and forces a reload. 174 */ 175 public void reset() { 176 mLoader.reset(); 177 start(); 178 } 179 180 private boolean isCursorValid() { 181 return mCursor != null && !mCursor.isClosed(); 182 } 183 184 /** 185 * Called when the loader finished loading data. Update the widget. 186 */ 187 @Override 188 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 189 // Save away the cursor 190 synchronized (sWidgetLock) { 191 mCursor = (EmailWidgetLoader.WidgetCursor) cursor; 192 mAccountName = mCursor.getAccountName(); 193 mMailboxName = mCursor.getMailboxName(); 194 } 195 updateHeader(); 196 mWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 197 } 198 199 /** 200 * Convenience method for creating an onClickPendingIntent that launches another activity 201 * directly. 202 * 203 * @param views The RemoteViews we're inflating 204 * @param buttonId the id of the button view 205 * @param intent The intent to be used when launching the activity 206 */ 207 private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) { 208 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // just in case intent comes without it 209 PendingIntent pendingIntent = 210 PendingIntent.getActivity(mContext, (int) mAccountId, intent, 211 PendingIntent.FLAG_UPDATE_CURRENT); 212 views.setOnClickPendingIntent(buttonId, pendingIntent); 213 } 214 215 /** 216 * Convenience method for constructing a fillInIntent for a given list view element. 217 * Appends the command and any arguments to a base Uri. 218 * 219 * @param views the RemoteViews we are inflating 220 * @param viewId the id of the view 221 * @param baseUri the base uri for the command 222 * @param args any arguments to the command 223 */ 224 private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String messageId, 225 String mailboxId) { 226 Intent intent = null; 227 try { 228 intent = getOpenMessageIntent(mContext, Long.parseLong(messageId), 229 Long.parseLong(mailboxId)); 230 } catch (NumberFormatException e) { 231 if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 232 Log.d(TAG, "#setFillInIntent(); invalid messageId: " + messageId + 233 " or mailboxId: " + mailboxId); 234 } 235 } 236 views.setOnClickFillInIntent(viewId, intent); 237 } 238 239 private Intent getOpenMessageIntent(final Context context, final long messageId, 240 final long mailboxId) { 241 Mailbox mailbox = Mailbox.restoreMailboxWithId(context, mailboxId); 242 return Welcome.createOpenMessageIntent(context, mailbox.mAccountKey, 243 mailboxId, messageId); 244 } 245 246 private void setTextViewTextAndDesc(RemoteViews views, final int id, String text) { 247 views.setTextViewText(id, text); 248 views.setContentDescription(id, text); 249 } 250 251 private void setupTitleAndCount(RemoteViews views) { 252 // Set up the title (view type + count of messages) 253 setTextViewTextAndDesc(views, R.id.widget_title, mMailboxName); 254 views.setViewVisibility(R.id.widget_tap, View.VISIBLE); 255 setTextViewTextAndDesc(views, R.id.widget_tap, mAccountName); 256 String count = ""; 257 synchronized (sWidgetLock) { 258 if (isCursorValid()) { 259 count = UiUtilities 260 .getMessageCountForUi(mContext, mCursor.getMessageCount(), false); 261 } 262 } 263 setTextViewTextAndDesc(views, R.id.widget_count, count); 264 } 265 266 /** 267 * Update the "header" of the widget (i.e. everything that doesn't include the scrolling 268 * message list) 269 */ 270 private void updateHeader() { 271 if (Email.DEBUG) { 272 Log.d(TAG, "#updateHeader(); widgetId: " + mWidgetId); 273 } 274 275 // Get the widget layout 276 RemoteViews views = 277 new RemoteViews(mContext.getPackageName(), R.layout.widget); 278 279 // Set up the list with an adapter 280 Intent intent = new Intent(mContext, WidgetService.class); 281 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); 282 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); 283 views.setRemoteAdapter(R.id.message_list, intent); 284 285 setupTitleAndCount(views); 286 287 if (isCursorValid()) { 288 // Show compose icon & message list 289 if (mAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 290 // Don't allow compose for "combined" view 291 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); 292 } else { 293 views.setViewVisibility(R.id.widget_compose, View.VISIBLE); 294 } 295 views.setViewVisibility(R.id.message_list, View.VISIBLE); 296 views.setViewVisibility(R.id.tap_to_configure, View.GONE); 297 // Create click intent for "compose email" target 298 intent = MessageCompose.getMessageComposeIntent(mContext, mAccountId); 299 intent.putExtra(MessageCompose.EXTRA_FROM_WIDGET, true); 300 setActivityIntent(views, R.id.widget_compose, intent); 301 // Create click intent for logo to open inbox 302 intent = Welcome.createOpenAccountInboxIntent(mContext, mAccountId); 303 setActivityIntent(views, R.id.widget_header, intent); 304 } else { 305 // TODO This really should never happen ... probably can remove the else block 306 // Hide compose icon & show "touch to configure" text 307 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); 308 views.setViewVisibility(R.id.message_list, View.GONE); 309 views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE); 310 // Create click intent for "touch to configure" target 311 intent = Welcome.createOpenAccountInboxIntent(mContext, -1); 312 setActivityIntent(views, R.id.tap_to_configure, intent); 313 } 314 315 // Use a bare intent for our template; we need to fill everything in 316 intent = new Intent(mContext, Welcome.class); 317 PendingIntent pendingIntent = PendingIntent.getActivity(mContext, 0, intent, 318 PendingIntent.FLAG_UPDATE_CURRENT); 319 views.setPendingIntentTemplate(R.id.message_list, pendingIntent); 320 321 // And finally update the widget 322 mWidgetManager.updateAppWidget(mWidgetId, views); 323 } 324 325 /** 326 * Add size and color styling to text 327 * 328 * @param text the text to style 329 * @param size the font size for this text 330 * @param color the color for this text 331 * @return a CharSequence quitable for use in RemoteViews.setTextViewText() 332 */ 333 private CharSequence addStyle(CharSequence text, int size, int color) { 334 SpannableStringBuilder builder = new SpannableStringBuilder(text); 335 builder.setSpan( 336 new AbsoluteSizeSpan(size), 0, text.length(), 337 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 338 if (color != 0) { 339 builder.setSpan(new ForegroundColorSpan(color), 0, text.length(), 340 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 341 } 342 return builder; 343 } 344 345 /** 346 * Create styled text for our combination subject and snippet 347 * 348 * @param subject the message's subject (or null) 349 * @param snippet the message's snippet (or null) 350 * @param read whether or not the message is read 351 * @return a CharSequence suitable for use in RemoteViews.setTextViewText() 352 */ 353 private CharSequence getStyledSubjectSnippet(String subject, String snippet, boolean read) { 354 SpannableStringBuilder ssb = new SpannableStringBuilder(); 355 boolean hasSubject = false; 356 if (!TextUtils.isEmpty(subject)) { 357 SpannableString ss = new SpannableString(subject); 358 ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), 359 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 360 ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(), 361 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 362 ssb.append(ss); 363 hasSubject = true; 364 } 365 if (!TextUtils.isEmpty(snippet)) { 366 if (hasSubject) { 367 ssb.append(sSubjectSnippetDivider); 368 } 369 SpannableString ss = new SpannableString(snippet); 370 ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(), 371 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 372 ssb.append(ss); 373 } 374 return addStyle(ssb, sSubjectFontSize, 0); 375 } 376 377 @Override 378 public RemoteViews getViewAt(int position) { 379 synchronized (sWidgetLock) { 380 // Use the cursor to set up the widget 381 if (!isCursorValid() || !mCursor.moveToPosition(position)) { 382 return getLoadingView(); 383 } 384 RemoteViews views = new RemoteViews(mContext.getPackageName(), 385 R.layout.widget_list_item); 386 boolean isUnread = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_READ) != 1; 387 int drawableId = R.drawable.conversation_read_selector; 388 if (isUnread) { 389 drawableId = R.drawable.conversation_unread_selector; 390 } 391 views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); 392 393 // Add style to sender 394 String rawSender = mCursor.isNull(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME) ? 395 "" : mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_DISPLAY_NAME); 396 SpannableStringBuilder from = new SpannableStringBuilder(rawSender); 397 from.setSpan(isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 398 0, from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 399 CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor); 400 views.setTextViewText(R.id.widget_from, styledFrom); 401 views.setContentDescription(R.id.widget_from, rawSender); 402 long timestamp = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_TIMESTAMP); 403 // Get a nicely formatted date string (relative to today) 404 String date = DateUtils.getRelativeTimeSpanString(mContext, timestamp).toString(); 405 // Add style to date 406 CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor); 407 views.setTextViewText(R.id.widget_date, styledDate); 408 views.setContentDescription(R.id.widget_date, date); 409 410 // Add style to subject/snippet 411 String subject = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SUBJECT); 412 String snippet = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_SNIPPET); 413 CharSequence subjectAndSnippet = getStyledSubjectSnippet(subject, snippet, !isUnread); 414 views.setTextViewText(R.id.widget_subject, subjectAndSnippet); 415 views.setContentDescription(R.id.widget_subject, subject); 416 417 int messageFlags = mCursor.getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAGS); 418 boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0; 419 views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE); 420 421 boolean hasAttachment = mCursor 422 .getInt(EmailWidgetLoader.WIDGET_COLUMN_FLAG_ATTACHMENT) != 0; 423 views.setViewVisibility(R.id.widget_attachment, hasAttachment ? View.VISIBLE 424 : View.GONE); 425 426 if (mAccountId != Account.ACCOUNT_ID_COMBINED_VIEW) { 427 views.setViewVisibility(R.id.color_chip, View.INVISIBLE); 428 } else { 429 long accountId = mCursor.getLong(EmailWidgetLoader.WIDGET_COLUMN_ACCOUNT_KEY); 430 int colorId = mResourceHelper.getAccountColorId(accountId); 431 if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) { 432 // Color defined by resource ID, so, use it 433 views.setViewVisibility(R.id.color_chip, View.VISIBLE); 434 views.setImageViewResource(R.id.color_chip, colorId); 435 } else { 436 // Color not defined by resource ID, nothing we can do, so, 437 // hide the chip 438 views.setViewVisibility(R.id.color_chip, View.INVISIBLE); 439 } 440 } 441 442 // Set button intents for view, reply, and delete 443 String messageId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_ID); 444 String mailboxId = mCursor.getString(EmailWidgetLoader.WIDGET_COLUMN_MAILBOX_KEY); 445 setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId, 446 mailboxId); 447 448 return views; 449 } 450 } 451 452 @Override 453 public int getCount() { 454 if (!isCursorValid()) 455 return 0; 456 synchronized (sWidgetLock) { 457 return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT); 458 } 459 } 460 461 @Override 462 public long getItemId(int position) { 463 return position; 464 } 465 466 @Override 467 public RemoteViews getLoadingView() { 468 RemoteViews view = new RemoteViews(mContext.getPackageName(), R.layout.widget_loading); 469 view.setTextViewText(R.id.loading_text, mContext.getString(R.string.widget_loading)); 470 return view; 471 } 472 473 @Override 474 public int getViewTypeCount() { 475 // Regular list view and the "loading" view 476 return 2; 477 } 478 479 @Override 480 public boolean hasStableIds() { 481 return true; 482 } 483 484 @Override 485 public void onDataSetChanged() { 486 // Note: we are not doing anything special in onDataSetChanged(). Since this service has 487 // a reference to a loader that will keep itself updated, if the service is running, it 488 // shouldn't be necessary to for the query to be run again. If the service hadn't been 489 // running, the act of starting the service will also start the loader. 490 } 491 492 public void onDeleted() { 493 if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 494 Log.d(TAG, "#onDeleted(); widgetId: " + mWidgetId); 495 } 496 497 if (mLoader != null) { 498 mLoader.reset(); 499 } 500 } 501 502 @Override 503 public void onDestroy() { 504 if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 505 Log.d(TAG, "#onDestroy(); widgetId: " + mWidgetId); 506 } 507 508 if (mLoader != null) { 509 mLoader.reset(); 510 } 511 } 512 513 @Override 514 public void onCreate() { 515 if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) { 516 Log.d(TAG, "#onCreate(); widgetId: " + mWidgetId); 517 } 518 } 519 520 @Override 521 public String toString() { 522 return "View=" + mAccountName; 523 } 524} 525