WidgetProvider.java revision 6c5ee59c4fcd5cb2992a53234245df8faeaf8a5d
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.ResourceHelper; 22import com.android.email.Utility; 23import com.android.email.activity.MessageCompose; 24import com.android.email.activity.Welcome; 25import com.android.email.activity.setup.AccountSetupBasics; 26import com.android.email.data.ThrottlingCursorLoader; 27import com.android.email.provider.EmailContent.Account; 28import com.android.email.provider.EmailContent.AccountColumns; 29import com.android.email.provider.EmailContent.Mailbox; 30import com.android.email.provider.EmailContent.Message; 31import com.android.email.provider.EmailContent.MessageColumns; 32 33import android.app.Activity; 34import android.app.PendingIntent; 35import android.app.Service; 36import android.appwidget.AppWidgetManager; 37import android.appwidget.AppWidgetProvider; 38import android.content.ContentResolver; 39import android.content.ContentUris; 40import android.content.Context; 41import android.content.Intent; 42import android.content.Loader; 43import android.content.res.Resources; 44import android.database.Cursor; 45import android.graphics.Typeface; 46import android.net.Uri; 47import android.net.Uri.Builder; 48import android.os.AsyncTask; 49import android.os.Bundle; 50import android.text.Spannable; 51import android.text.SpannableString; 52import android.text.SpannableStringBuilder; 53import android.text.TextUtils; 54import android.text.format.DateUtils; 55import android.text.style.AbsoluteSizeSpan; 56import android.text.style.ForegroundColorSpan; 57import android.text.style.StyleSpan; 58import android.util.Log; 59import android.view.View; 60import android.widget.RemoteViews; 61import android.widget.RemoteViewsService; 62 63import java.io.FileDescriptor; 64import java.io.PrintWriter; 65import java.util.Collection; 66import java.util.List; 67import java.util.Map; 68import java.util.concurrent.ConcurrentHashMap; 69 70public class WidgetProvider extends AppWidgetProvider { 71 private static final String TAG = "WidgetProvider"; 72 73 /** 74 * When handling clicks in a widget ListView, a single PendingIntent template is provided to 75 * RemoteViews, and the individual "on click" actions are distinguished via a "fillInIntent" 76 * on each list element; when a click is received, this "fillInIntent" is merged with the 77 * PendingIntent using Intent.fillIn(). Since this mechanism does NOT preserve the Extras 78 * Bundle, we instead encode information about the action (e.g. view, reply, etc.) and its 79 * arguments (e.g. messageId, mailboxId, etc.) in an Uri which is added to the Intent via 80 * Intent.setDataAndType() 81 * 82 * The mime type MUST be set in the Intent, even though we do not use it; therefore, it's value 83 * is entirely arbitrary. 84 * 85 * Our "command" Uri is NOT used by the system in any manner, and is therefore constrained only 86 * in the requirement that it be syntactically valid. 87 * 88 * We use the following convention for our commands: 89 * widget://command/<command>/<arg1>[/<arg2>] 90 */ 91 private static final String WIDGET_DATA_MIME_TYPE = "com.android.email/widget_data"; 92 private static final Uri COMMAND_URI = Uri.parse("widget://command"); 93 94 // Command names and Uri's built upon COMMAND_URI 95 private static final String COMMAND_NAME_SWITCH_LIST_VIEW = "switch_list_view"; 96 private static final Uri COMMAND_URI_SWITCH_LIST_VIEW = 97 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_SWITCH_LIST_VIEW).build(); 98 private static final String COMMAND_NAME_VIEW_MESSAGE = "view_message"; 99 private static final Uri COMMAND_URI_VIEW_MESSAGE = 100 COMMAND_URI.buildUpon().appendPath(COMMAND_NAME_VIEW_MESSAGE).build(); 101 102 private static final int TOTAL_COUNT_UNKNOWN = -1; 103 private static final int MAX_MESSAGE_LIST_COUNT = 25; 104 105 private static final String[] NO_ARGUMENTS = new String[0]; 106 private static final String SORT_TIMESTAMP_DESCENDING = MessageColumns.TIMESTAMP + " DESC"; 107 private static final String SORT_ID_ASCENDING = AccountColumns.ID + " ASC"; 108 private static final String[] ID_NAME_PROJECTION = {Account.RECORD_ID, Account.DISPLAY_NAME}; 109 private static final int ID_NAME_COLUMN_ID = 0; 110 private static final int ID_NAME_COLUMN_NAME = 1; 111 112 113 // Map holding our instantiated widgets, accessed by widget id 114 private final static Map<Integer, EmailWidget> sWidgetMap 115 = new ConcurrentHashMap<Integer, EmailWidget>(); 116 private static AppWidgetManager sWidgetManager; 117 private static Context sContext; 118 private static ContentResolver sResolver; 119 120 private static int sSenderFontSize; 121 private static int sSubjectFontSize; 122 private static int sDateFontSize; 123 private static int sDefaultTextColor; 124 private static int sLightTextColor; 125 private static String sSubjectSnippetDivider; 126 private static String sConfigureText; 127 128 /** 129 * Types of views that we're prepared to show in the widget - all mail, unread mail, and starred 130 * mail; we rotate between them. Each ViewType is composed of a selection string and a title. 131 */ 132 /* package */ enum ViewType { 133 ALL_INBOX(Message.INBOX_SELECTION, NO_ARGUMENTS, R.string.widget_all_mail), 134 UNREAD(Message.UNREAD_SELECTION, NO_ARGUMENTS, R.string.widget_unread), 135 STARRED(Message.ALL_FAVORITE_SELECTION, NO_ARGUMENTS, R.string.widget_starred), 136 ACCOUNT(Message.PER_ACCOUNT_INBOX_SELECTION, new String[1], 0); 137 138 /* package */ final String selection; 139 /* package */ final String[] selectionArgs; 140 private final int titleResource; 141 private String title; 142 143 ViewType(String _selection, String[] _selectionArgs, int _titleResource) { 144 selection = _selection; 145 selectionArgs = _selectionArgs; 146 titleResource = _titleResource; 147 } 148 149 public String getTitle(Context context) { 150 if (title == null && titleResource != 0) { 151 title = context.getString(titleResource); 152 } 153 return title; 154 } 155 156 @Override 157 public String toString() { 158 StringBuilder sb = new StringBuilder("ViewType:selection="); 159 sb.append("\""); 160 sb.append(selection); 161 sb.append("\""); 162 if (selectionArgs != null && selectionArgs.length > 0) { 163 sb.append(",args=("); 164 for (String arg : selectionArgs) { 165 sb.append(arg); 166 sb.append(", "); 167 } 168 sb.append(")"); 169 } 170 171 return sb.toString(); 172 } 173 } 174 175 static class EmailWidget implements RemoteViewsService.RemoteViewsFactory { 176 // The widget identifier 177 private final int mWidgetId; 178 179 // The cursor underlying the message list for this widget; this must only be modified while 180 // holding mCursorLock 181 private volatile Cursor mCursor; 182 // A lock on our cursor, which is used in the UI thread while inflating views, and by 183 // our Loader in the background 184 private final Object mCursorLock = new Object(); 185 // Number of records in the cursor 186 private int mCursorCount = TOTAL_COUNT_UNKNOWN; 187 // The widget's loader (derived from ThrottlingCursorLoader) 188 private ViewCursorLoader mLoader; 189 private final ResourceHelper mResourceHelper; 190 // Number of defined accounts 191 private int mAccountCount = TOTAL_COUNT_UNKNOWN; 192 193 // The current view type (all mail, unread, or starred for now) 194 /*package*/ ViewType mViewType = ViewType.STARRED; 195 196 // The projection to be used by the WidgetLoader 197 public static final String[] WIDGET_PROJECTION = new String[] { 198 EmailContent.RECORD_ID, MessageColumns.DISPLAY_NAME, MessageColumns.TIMESTAMP, 199 MessageColumns.SUBJECT, MessageColumns.FLAG_READ, MessageColumns.FLAG_FAVORITE, 200 MessageColumns.FLAG_ATTACHMENT, MessageColumns.MAILBOX_KEY, MessageColumns.SNIPPET, 201 MessageColumns.ACCOUNT_KEY, MessageColumns.FLAGS 202 }; 203 public static final int WIDGET_COLUMN_ID = 0; 204 public static final int WIDGET_COLUMN_DISPLAY_NAME = 1; 205 public static final int WIDGET_COLUMN_TIMESTAMP = 2; 206 public static final int WIDGET_COLUMN_SUBJECT = 3; 207 public static final int WIDGET_COLUMN_FLAG_READ = 4; 208 public static final int WIDGET_COLUMN_FLAG_FAVORITE = 5; 209 public static final int WIDGET_COLUMN_FLAG_ATTACHMENT = 6; 210 public static final int WIDGET_COLUMN_MAILBOX_KEY = 7; 211 public static final int WIDGET_COLUMN_SNIPPET = 8; 212 public static final int WIDGET_COLUMN_ACCOUNT_KEY = 9; 213 public static final int WIDGET_COLUMN_FLAGS = 10; 214 215 public EmailWidget(int _widgetId) { 216 super(); 217 if (Email.DEBUG) { 218 Log.d(TAG, "Creating EmailWidget with id = " + _widgetId); 219 } 220 mWidgetId = _widgetId; 221 mLoader = new ViewCursorLoader(); 222 if (sSubjectSnippetDivider == null) { 223 // Initialize string, color, dimension resources 224 Resources res = sContext.getResources(); 225 sSubjectSnippetDivider = 226 res.getString(R.string.message_list_subject_snippet_divider); 227 sSenderFontSize = res.getDimensionPixelSize(R.dimen.widget_senders_font_size); 228 sSubjectFontSize = res.getDimensionPixelSize(R.dimen.widget_subject_font_size); 229 sDateFontSize = res.getDimensionPixelSize(R.dimen.widget_date_font_size); 230 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 231 sDefaultTextColor = res.getColor(R.color.widget_default_text_color); 232 sLightTextColor = res.getColor(R.color.widget_light_text_color); 233 sConfigureText = res.getString(R.string.widget_other_views); 234 } 235 mResourceHelper = ResourceHelper.getInstance(sContext); 236 } 237 238 /** 239 * Task for updating widget data (eg: the header, view list items, etc...) 240 * If parameter to {@link #execute(Boolean...)} is <code>true</code>, the current 241 * view is validated against the current set of accounts. And if the current view 242 * is determined to be invalid, the view will automatically progress to the next 243 * valid view. 244 */ 245 final class WidgetUpdateTask extends AsyncTask<Boolean, Void, Boolean> { 246 @Override 247 protected Boolean doInBackground(Boolean... validateView) { 248 mAccountCount = EmailContent.count(sContext, EmailContent.Account.CONTENT_URI); 249 // If displaying invalid view, switch to the next view 250 return !validateView[0] || isViewValid(); 251 } 252 253 @Override 254 protected void onPostExecute(Boolean isValidView) { 255 updateHeader(); 256 if (!isValidView) { 257 new WidgetViewSwitcher(EmailWidget.this).execute(); 258 } 259 } 260 } 261 262 /** 263 * The ThrottlingCursorLoader does all of the heavy lifting in managing the data loading 264 * task; all we need is to register a listener so that we're notified when the load is 265 * complete. 266 */ 267 final class ViewCursorLoader extends ThrottlingCursorLoader { 268 protected ViewCursorLoader() { 269 super(sContext, Message.CONTENT_URI, WIDGET_PROJECTION, mViewType.selection, 270 mViewType.selectionArgs, SORT_TIMESTAMP_DESCENDING); 271 registerListener(0, new OnLoadCompleteListener<Cursor>() { 272 @Override 273 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 274 synchronized (mCursorLock) { 275 // Save away the cursor 276 mCursor = cursor; 277 // Reset the notification Uri to our Message table notifier URI 278 mCursor.setNotificationUri(sResolver, Message.NOTIFIER_URI); 279 // Save away the count (for display) 280 mCursorCount = mCursor.getCount(); 281 if (Email.DEBUG) { 282 Log.d(TAG, "onLoadComplete, count = " + cursor.getCount()); 283 } 284 } 285 RemoteViews views = 286 new RemoteViews(sContext.getPackageName(), R.layout.widget); 287 setupTitleAndCount(views); 288 sWidgetManager.partiallyUpdateAppWidget(mWidgetId, views); 289 sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 290 } 291 }); 292 } 293 294 /** 295 * Stop any pending load, reset selection parameters, and start loading 296 * Must be called from the UI thread 297 * @param viewType the current ViewType 298 */ 299 private void load(ViewType viewType) { 300 reset(); 301 setSelection(viewType.selection); 302 setSelectionArgs(viewType.selectionArgs); 303 startLoading(); 304 } 305 } 306 307 /** 308 * Initialize to first appropriate view (depending on the number of accounts) 309 */ 310 private void init() { 311 // Just update the account count & header; no need to validate the view 312 new WidgetUpdateTask().execute(false); 313 new WidgetViewSwitcher(this).execute(); 314 } 315 316 /** 317 * Reset cursor and cursor count, notify widget that list data is invalid, and start loading 318 * with our current ViewType 319 */ 320 private void loadView() { 321 synchronized(mCursorLock) { 322 mCursorCount = TOTAL_COUNT_UNKNOWN; 323 mCursor = null; 324 sWidgetManager.notifyAppWidgetViewDataChanged(mWidgetId, R.id.message_list); 325 mLoader.load(mViewType); 326 } 327 } 328 329 /** 330 * Switch to the next widget view (all -> account1 -> ... -> account n -> unread -> starred) 331 * This must be called on a background thread 332 */ 333 public synchronized void switchToNextView() { 334 switch(mViewType) { 335 // If we're in starred and there is more than one account, go to "all mail" 336 // Otherwise, fall through to the accounts themselves 337 case STARRED: 338 if (EmailContent.count(sContext, Account.CONTENT_URI) > 1) { 339 mViewType = ViewType.ALL_INBOX; 340 break; 341 } 342 //$FALL-THROUGH$ 343 case ALL_INBOX: 344 ViewType.ACCOUNT.selectionArgs[0] = "0"; 345 //$FALL-THROUGH$ 346 case ACCOUNT: 347 // Find the next account (or, if none, default to UNREAD) 348 String idString = ViewType.ACCOUNT.selectionArgs[0]; 349 Cursor c = sResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id>?", 350 new String[] {idString}, SORT_ID_ASCENDING); 351 try { 352 if (c.moveToFirst()) { 353 mViewType = ViewType.ACCOUNT; 354 mViewType.selectionArgs[0] = c.getString(ID_NAME_COLUMN_ID); 355 mViewType.title = c.getString(ID_NAME_COLUMN_NAME); 356 } else { 357 mViewType = ViewType.UNREAD; 358 } 359 } finally { 360 c.close(); 361 } 362 break; 363 case UNREAD: 364 mViewType = ViewType.STARRED; 365 break; 366 } 367 } 368 369 /** 370 * Returns whether the current view is valid. The following rules determine if a view is 371 * considered valid: 372 * 1. If the view is either {@link ViewType#STARRED} or {@link ViewType#UNREAD}, always 373 * returns <code>true</code>. 374 * 2. If the view is {@link ViewType#ALL_INBOX}, returns <code>true</code> if more than 375 * one account is defined. Otherwise, returns <code>false</code>. 376 * 3. If the view is {@link ViewType#ACCOUNT}, returns <code>true</code> if the account 377 * is defined. Otherwise, returns <code>false</code>. 378 */ 379 private boolean isViewValid() { 380 switch(mViewType) { 381 case ALL_INBOX: 382 // "all inbox" is valid only if there is more than one account 383 return (EmailContent.count(sContext, Account.CONTENT_URI) > 1); 384 case ACCOUNT: 385 // Ensure current account still exists 386 String idString = ViewType.ACCOUNT.selectionArgs[0]; 387 Cursor c = sResolver.query(Account.CONTENT_URI, ID_NAME_PROJECTION, "_id=?", 388 new String[] {idString}, SORT_ID_ASCENDING); 389 try { 390 return c.moveToFirst(); 391 } finally { 392 c.close(); 393 } 394 } 395 return true; 396 } 397 398 /** 399 * Convenience method for creating an onClickPendingIntent that executes a command via 400 * our command Uri. Used for the "next view" command; appends the widget id to the command 401 * Uri. 402 * 403 * @param views The RemoteViews we're inflating 404 * @param buttonId the id of the button view 405 * @param data the command Uri 406 */ 407 private void setCommandIntent(RemoteViews views, int buttonId, Uri data) { 408 Intent intent = new Intent(sContext, WidgetService.class); 409 intent.setDataAndType(ContentUris.withAppendedId(data, mWidgetId), 410 WIDGET_DATA_MIME_TYPE); 411 PendingIntent pendingIntent = PendingIntent.getService(sContext, 0, intent, 412 PendingIntent.FLAG_UPDATE_CURRENT); 413 views.setOnClickPendingIntent(buttonId, pendingIntent); 414 } 415 416 /** 417 * Convenience method for creating an onClickPendingIntent that launches another activity 418 * directly. 419 * 420 * @param views The RemoteViews we're inflating 421 * @param buttonId the id of the button view 422 * @param intent The intent to be used when launching the activity 423 */ 424 private void setActivityIntent(RemoteViews views, int buttonId, Intent intent) { 425 PendingIntent pendingIntent = PendingIntent.getActivity(sContext, 0, intent, 0); 426 views.setOnClickPendingIntent(buttonId, pendingIntent); 427 } 428 429 /** 430 * Convenience method for constructing a fillInIntent for a given list view element. 431 * Appends the command and any arguments to a base Uri. 432 * 433 * @param views the RemoteViews we are inflating 434 * @param viewId the id of the view 435 * @param baseUri the base uri for the command 436 * @param args any arguments to the command 437 */ 438 private void setFillInIntent(RemoteViews views, int viewId, Uri baseUri, String ... args) { 439 Intent intent = new Intent(); 440 Builder builder = baseUri.buildUpon(); 441 for (String arg: args) { 442 builder.appendPath(arg); 443 } 444 intent.setDataAndType(builder.build(), WIDGET_DATA_MIME_TYPE); 445 views.setOnClickFillInIntent(viewId, intent); 446 } 447 448 private void setupTitleAndCount(RemoteViews views) { 449 // Set up the title (view type + count of messages) 450 views.setTextViewText(R.id.widget_title, mViewType.getTitle(sContext)); 451 views.setTextViewText(R.id.widget_tap, sConfigureText); 452 String count = ""; 453 if (mCursorCount != TOTAL_COUNT_UNKNOWN) { 454 count = Integer.toString(mCursor.getCount()); 455 } 456 views.setTextViewText(R.id.widget_count, count); 457 } 458 /** 459 * Update the "header" of the widget (i.e. everything that doesn't include the scrolling 460 * message list) 461 */ 462 private void updateHeader() { 463 if (Email.DEBUG) { 464 Log.d(TAG, "updateWidget " + mWidgetId); 465 } 466 467 // Get the widget layout 468 RemoteViews views = new RemoteViews(sContext.getPackageName(), R.layout.widget); 469 470 // Set up the list with an adapter 471 Intent intent = new Intent(sContext, WidgetService.class); 472 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mWidgetId); 473 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))); 474 views.setRemoteAdapter(mWidgetId, R.id.message_list, intent); 475 476 setupTitleAndCount(views); 477 478 if (mAccountCount == 0) { 479 // Hide compose icon & show "touch to configure" text 480 views.setViewVisibility(R.id.widget_compose, View.INVISIBLE); 481 views.setViewVisibility(R.id.message_list, View.GONE); 482 views.setViewVisibility(R.id.tap_to_configure, View.VISIBLE); 483 // Create click intent for "touch to configure" target 484 intent = Welcome.createOpenAccountInboxIntent(sContext, -1); 485 setActivityIntent(views, R.id.tap_to_configure, intent); 486 } else { 487 // Show compose icon & message list 488 views.setViewVisibility(R.id.widget_compose, View.VISIBLE); 489 views.setViewVisibility(R.id.message_list, View.VISIBLE); 490 views.setViewVisibility(R.id.tap_to_configure, View.GONE); 491 // Create click intent for "compose email" target 492 intent = MessageCompose.getMessageComposeIntent(sContext, -1); 493 setActivityIntent(views, R.id.widget_compose, intent); 494 } 495 // Create click intent for "view rotation" target 496 setCommandIntent(views, R.id.widget_logo, COMMAND_URI_SWITCH_LIST_VIEW); 497 498 // Use a bare intent for our template; we need to fill everything in 499 intent = new Intent(sContext, WidgetService.class); 500 PendingIntent pendingIntent = 501 PendingIntent.getService(sContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 502 views.setPendingIntentTemplate(R.id.message_list, pendingIntent); 503 504 // And finally update the widget 505 sWidgetManager.updateAppWidget(mWidgetId, views); 506 } 507 508 /** 509 * Add size and color styling to text 510 * 511 * @param text the text to style 512 * @param size the font size for this text 513 * @param color the color for this text 514 * @return a CharSequence quitable for use in RemoteViews.setTextViewText() 515 */ 516 private CharSequence addStyle(CharSequence text, int size, int color) { 517 SpannableStringBuilder builder = new SpannableStringBuilder(text); 518 builder.setSpan( 519 new AbsoluteSizeSpan(size), 0, text.length(), 520 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 521 if (color != 0) { 522 builder.setSpan(new ForegroundColorSpan(color), 0, text.length(), 523 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 524 } 525 return builder; 526 } 527 528 /** 529 * Create styled text for our combination subject and snippet 530 * 531 * @param subject the message's subject (or null) 532 * @param snippet the message's snippet (or null) 533 * @param read whether or not the message is read 534 * @return a CharSequence suitable for use in RemoteViews.setTextViewText() 535 */ 536 private CharSequence getStyledSubjectSnippet (String subject, String snippet, 537 boolean read) { 538 SpannableStringBuilder ssb = new SpannableStringBuilder(); 539 boolean hasSubject = false; 540 if (!TextUtils.isEmpty(subject)) { 541 SpannableString ss = new SpannableString(subject); 542 ss.setSpan(new StyleSpan(read ? Typeface.NORMAL : Typeface.BOLD), 0, ss.length(), 543 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 544 ss.setSpan(new ForegroundColorSpan(sDefaultTextColor), 0, ss.length(), 545 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 546 ssb.append(ss); 547 hasSubject = true; 548 } 549 if (!TextUtils.isEmpty(snippet)) { 550 if (hasSubject) { 551 ssb.append(sSubjectSnippetDivider); 552 } 553 SpannableString ss = new SpannableString(snippet); 554 ss.setSpan(new ForegroundColorSpan(sLightTextColor), 0, snippet.length(), 555 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 556 ssb.append(ss); 557 } 558 return addStyle(ssb, sSubjectFontSize, 0); 559 } 560 561 /* (non-Javadoc) 562 * @see android.widget.RemoteViewsService.RemoteViewsFactory#getViewAt(int) 563 */ 564 public RemoteViews getViewAt(int position) { 565 // Use the cursor to set up the widget 566 synchronized (mCursorLock) { 567 if (mCursor == null || mCursor.isClosed() || !mCursor.moveToPosition(position)) { 568 return getLoadingView(); 569 } 570 RemoteViews views = 571 new RemoteViews(sContext.getPackageName(), R.layout.widget_list_item); 572 boolean isUnread = mCursor.getInt(WIDGET_COLUMN_FLAG_READ) != 1; 573 int drawableId = R.drawable.widget_read_conversation_selector; 574 if (isUnread) { 575 drawableId = R.drawable.widget_unread_conversation_selector; 576 } 577 views.setInt(R.id.widget_message, "setBackgroundResource", drawableId); 578 579 // Add style to sender 580 SpannableStringBuilder from = 581 new SpannableStringBuilder(mCursor.getString(WIDGET_COLUMN_DISPLAY_NAME)); 582 from.setSpan( 583 isUnread ? new StyleSpan(Typeface.BOLD) : new StyleSpan(Typeface.NORMAL), 0, 584 from.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 585 CharSequence styledFrom = addStyle(from, sSenderFontSize, sDefaultTextColor); 586 views.setTextViewText(R.id.widget_from, styledFrom); 587 588 long timestamp = mCursor.getLong(WIDGET_COLUMN_TIMESTAMP); 589 // Get a nicely formatted date string (relative to today) 590 String date = DateUtils.getRelativeTimeSpanString(sContext, timestamp).toString(); 591 // Add style to date 592 CharSequence styledDate = addStyle(date, sDateFontSize, sDefaultTextColor); 593 views.setTextViewText(R.id.widget_date, styledDate); 594 595 // Add style to subject/snippet 596 String subject = mCursor.getString(WIDGET_COLUMN_SUBJECT); 597 String snippet = mCursor.getString(WIDGET_COLUMN_SNIPPET); 598 CharSequence subjectAndSnippet = 599 getStyledSubjectSnippet(subject, snippet, !isUnread); 600 views.setTextViewText(R.id.widget_subject, subjectAndSnippet); 601 602 int messageFlags = mCursor.getInt(WIDGET_COLUMN_FLAGS); 603 boolean hasInvite = (messageFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0; 604 views.setViewVisibility(R.id.widget_invite, hasInvite ? View.VISIBLE : View.GONE); 605 606 boolean hasAttachment = mCursor.getInt(WIDGET_COLUMN_FLAG_ATTACHMENT) != 0; 607 views.setViewVisibility(R.id.widget_attachment, 608 hasAttachment ? View.VISIBLE : View.GONE); 609 610 if (mViewType == ViewType.ACCOUNT) { 611 views.setViewVisibility(R.id.color_chip, View.INVISIBLE); 612 } else { 613 long accountId = mCursor.getLong(WIDGET_COLUMN_ACCOUNT_KEY); 614 int colorId = mResourceHelper.getAccountColorId(accountId); 615 if (colorId != ResourceHelper.UNDEFINED_RESOURCE_ID) { 616 // Color defined by resource ID, so, use it 617 views.setViewVisibility(R.id.color_chip, View.VISIBLE); 618 views.setImageViewResource(R.id.color_chip, colorId); 619 } else { 620 // Color not defined by resource ID, nothing we can do, so, hide the chip 621 views.setViewVisibility(R.id.color_chip, View.INVISIBLE); 622 } 623 } 624 625 // Set button intents for view, reply, and delete 626 String messageId = mCursor.getString(WIDGET_COLUMN_ID); 627 String mailboxId = mCursor.getString(WIDGET_COLUMN_MAILBOX_KEY); 628 setFillInIntent(views, R.id.widget_message, COMMAND_URI_VIEW_MESSAGE, messageId, 629 mailboxId); 630 631 return views; 632 } 633 } 634 635 @Override 636 public int getCount() { 637 if (mCursor == null) return 0; 638 return Math.min(mCursor.getCount(), MAX_MESSAGE_LIST_COUNT); 639 } 640 641 @Override 642 public long getItemId(int position) { 643 return position; 644 } 645 646 @Override 647 public RemoteViews getLoadingView() { 648 RemoteViews view = new RemoteViews(sContext.getPackageName(), R.layout.widget_loading); 649 view.setTextViewText(R.id.loading_text, sContext.getString(R.string.widget_loading)); 650 return view; 651 } 652 653 @Override 654 public int getViewTypeCount() { 655 // Regular list view and the "loading" view 656 return 2; 657 } 658 659 @Override 660 public boolean hasStableIds() { 661 return true; 662 } 663 664 @Override 665 public void onDataSetChanged() { 666 } 667 668 private void onDeleted() { 669 if (mLoader != null) { 670 mLoader.stopLoading(); 671 } 672 sWidgetMap.remove(mWidgetId); 673 } 674 675 @Override 676 public void onDestroy() { 677 if (mLoader != null) { 678 mLoader.stopLoading(); 679 } 680 sWidgetMap.remove(mWidgetId); 681 } 682 683 @Override 684 public void onCreate() { 685 } 686 } 687 688 private static synchronized void update(Context context, int[] appWidgetIds) { 689 for (int widgetId: appWidgetIds) { 690 getOrCreateWidget(context, widgetId).updateHeader(); 691 } 692 } 693 694 /** 695 * Updates all active widgets. If no widgets are active, does nothing. 696 */ 697 public static synchronized void updateAllWidgets() { 698 // Ignore if the widget is not active 699 if (sContext != null && sWidgetMap.size() > 0) { 700 Collection<EmailWidget> widgetSet = sWidgetMap.values(); 701 for (EmailWidget widget: widgetSet) { 702 // Anything could have changed; update widget & validate the current view 703 widget.new WidgetUpdateTask().execute(true); 704 } 705 } 706 } 707 708 /** 709 * Force a context for widgets (used by unit tests) 710 * @param context the Context to set 711 */ 712 /*package*/ static void setContextForTest(Context context) { 713 sContext = context; 714 sResolver = context.getContentResolver(); 715 sWidgetManager = AppWidgetManager.getInstance(context); 716 } 717 718 /*package*/ static EmailWidget getOrCreateWidget(Context context, int widgetId) { 719 // Lazily initialize these 720 if (sContext == null) { 721 sContext = context.getApplicationContext(); 722 sWidgetManager = AppWidgetManager.getInstance(context); 723 sResolver = context.getContentResolver(); 724 } 725 EmailWidget widget = sWidgetMap.get(widgetId); 726 if (widget == null) { 727 if (Email.DEBUG) { 728 Log.d(TAG, "Creating EmailWidget for id #" + widgetId); 729 } 730 widget = new EmailWidget(widgetId); 731 widget.init(); 732 sWidgetMap.put(widgetId, widget); 733 } 734 return widget; 735 } 736 737 @Override 738 public void onDisabled(Context context) { 739 super.onDisabled(context); 740 if (Email.DEBUG) { 741 Log.d(TAG, "onDisabled"); 742 } 743 context.stopService(new Intent(context, WidgetService.class)); 744 } 745 746 @Override 747 public void onEnabled(final Context context) { 748 super.onEnabled(context); 749 if (Email.DEBUG) { 750 Log.d(TAG, "onEnabled"); 751 } 752 } 753 754 @Override 755 public void onReceive(final Context context, Intent intent) { 756 String action = intent.getAction(); 757 if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(action)) { 758 Bundle extras = intent.getExtras(); 759 if (extras != null) { 760 final int[] appWidgetIds = extras.getIntArray(AppWidgetManager.EXTRA_APPWIDGET_IDS); 761 if (appWidgetIds != null && appWidgetIds.length > 0) { 762 update(context, appWidgetIds); 763 } 764 } 765 } else if (AppWidgetManager.ACTION_APPWIDGET_DELETED.equals(action)) { 766 Bundle extras = intent.getExtras(); 767 if (extras != null && extras.containsKey(AppWidgetManager.EXTRA_APPWIDGET_ID)) { 768 final int widgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID); 769 // Find the widget in the map 770 EmailWidget widget = sWidgetMap.get(widgetId); 771 if (widget != null) { 772 // Stop loading and remove the widget from the map 773 widget.onDeleted(); 774 } 775 } 776 } 777 } 778 779 /** 780 * Utility class to handle switching widget views; in the background, we access the database 781 * to determine account status, etc. In the foreground, we start up the Loader with new 782 * parameters 783 */ 784 /*package*/ static class WidgetViewSwitcher extends AsyncTask<Void, Void, Void> { 785 private final EmailWidget mWidget; 786 private boolean mLoadAfterSwitch = true; 787 788 public WidgetViewSwitcher(EmailWidget widget) { 789 mWidget = widget; 790 } 791 792 /*package*/ void disableLoadAfterSwitchForTest() { 793 mLoadAfterSwitch = false; 794 } 795 796 @Override 797 protected Void doInBackground(Void... params) { 798 mWidget.switchToNextView(); 799 return null; 800 } 801 802 @Override 803 protected void onPostExecute(Void param) { 804 if (isCancelled()) { 805 return; 806 } 807 if (mLoadAfterSwitch) { 808 mWidget.loadView(); 809 } 810 } 811 } 812 813 /** 814 * We use the WidgetService for two purposes: 815 * 1) To provide a widget factory for RemoteViews, and 816 * 2) To process our command Uri's (i.e. take actions on user clicks) 817 */ 818 public static class WidgetService extends RemoteViewsService { 819 @Override 820 public RemoteViewsFactory onGetViewFactory(Intent intent) { 821 // Which widget do we want (nice alliteration, huh?) 822 int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1); 823 if (widgetId == -1) return null; 824 // Find the existing widget or create it 825 return getOrCreateWidget(this, widgetId); 826 } 827 828 @Override 829 public void startActivity(Intent intent) { 830 // Since we're not calling startActivity from an Activity, we need the new task flag 831 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); 832 super.startActivity(intent); 833 } 834 835 @Override 836 public int onStartCommand(Intent intent, int flags, int startId) { 837 Uri data = intent.getData(); 838 if (data == null) return Service.START_NOT_STICKY; 839 List<String> pathSegments = data.getPathSegments(); 840 // Our path segments are <command>, <arg1> [, <arg2>] 841 // First, a quick check of Uri validity 842 if (pathSegments.size() < 2) { 843 throw new IllegalArgumentException(); 844 } 845 String command = pathSegments.get(0); 846 // Ignore unknown action names 847 try { 848 final long arg1 = Long.parseLong(pathSegments.get(1)); 849 if (COMMAND_NAME_VIEW_MESSAGE.equals(command)) { 850 // "view", <message id>, <mailbox id> 851 final long mailboxId = Long.parseLong(pathSegments.get(2)); 852 final long messageId = arg1; 853 Utility.runAsync(new Runnable() { 854 @Override 855 public void run() { 856 openMessage(mailboxId, messageId); 857 } 858 }); 859 } else if (COMMAND_NAME_SWITCH_LIST_VIEW.equals(command)) { 860 // "next_view", <widget id> 861 EmailWidget widget = sWidgetMap.get((int)arg1); 862 if (widget != null) { 863 WidgetViewSwitcher switcher = new WidgetViewSwitcher(widget); 864 switcher.execute(); 865 } 866 } 867 } catch (NumberFormatException e) { 868 // Shouldn't happen as we construct all of the Uri's 869 } 870 return Service.START_NOT_STICKY; 871 } 872 873 private void openMessage(long mailboxId, long messageId) { 874 Mailbox mailbox = Mailbox.restoreMailboxWithId(this, mailboxId); 875 if (mailbox == null) return; 876 startActivity(Welcome.createOpenMessageIntent(this, mailbox.mAccountKey, mailboxId, 877 messageId)); 878 } 879 880 @Override 881 protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) { 882 int n = 0; 883 for (EmailWidget widget : sWidgetMap.values()) { 884 writer.println("Widget #" + (++n)); 885 writer.println(" ViewType=" + widget.mViewType); 886 } 887 } 888 } 889} 890