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