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