NotificationUtils.java revision dab8a94a939988e99597845e89cfe3fd1da0ab88
1/* 2 * Copyright (C) 2013 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 */ 16package com.android.mail.utils; 17 18import com.google.android.common.html.parser.HTML; 19import com.google.android.common.html.parser.HTML4; 20import com.google.android.common.html.parser.HtmlDocument; 21import com.google.android.common.html.parser.HtmlTree; 22import com.google.common.collect.Lists; 23import com.google.common.collect.Maps; 24import com.google.common.collect.Sets; 25 26import android.app.Notification; 27import android.app.NotificationManager; 28import android.app.PendingIntent; 29import android.content.ContentResolver; 30import android.content.ContentUris; 31import android.content.ContentValues; 32import android.content.Context; 33import android.content.Intent; 34import android.content.res.Resources; 35import android.database.Cursor; 36import android.graphics.Bitmap; 37import android.graphics.BitmapFactory; 38import android.net.Uri; 39import android.provider.ContactsContract; 40import android.provider.ContactsContract.CommonDataKinds.Email; 41import android.provider.ContactsContract.Contacts.Photo; 42import android.support.v4.app.NotificationCompat; 43import android.text.Html; 44import android.text.Spannable; 45import android.text.SpannableString; 46import android.text.SpannableStringBuilder; 47import android.text.Spanned; 48import android.text.TextUtils; 49import android.text.TextUtils.SimpleStringSplitter; 50import android.text.style.CharacterStyle; 51import android.text.style.TextAppearanceSpan; 52import android.util.Pair; 53 54import com.android.mail.EmailAddress; 55import com.android.mail.MailIntentService; 56import com.android.mail.R; 57import com.android.mail.browse.MessageCursor; 58import com.android.mail.browse.SendersView; 59import com.android.mail.preferences.AccountPreferences; 60import com.android.mail.preferences.FolderPreferences; 61import com.android.mail.preferences.MailPrefs; 62import com.android.mail.providers.Account; 63import com.android.mail.providers.Conversation; 64import com.android.mail.providers.Folder; 65import com.android.mail.providers.Message; 66import com.android.mail.providers.UIProvider; 67import com.android.mail.utils.NotificationActionUtils.NotificationAction; 68 69import java.io.ByteArrayInputStream; 70import java.util.ArrayList; 71import java.util.Arrays; 72import java.util.Collection; 73import java.util.List; 74import java.util.Map; 75import java.util.Set; 76import java.util.concurrent.ConcurrentHashMap; 77 78public class NotificationUtils { 79 public static final String LOG_TAG = LogTag.getLogTag(); 80 81 /** Contains a list of <(account, label), unread conversations> */ 82 private static NotificationMap sActiveNotificationMap = null; 83 84 /** 85 * Cached default large icon for notifications when the sender doesn't have 86 * a contact photo. 87 */ 88 public static Bitmap sDefaultSingleNotifIcon; 89 90 /** 91 * Cached default large icon for notifications for multiple new conversations. 92 */ 93 public static Bitmap sMultipleNotifIcon; 94 95 private static TextAppearanceSpan sNotificationUnreadStyleSpan; 96 private static CharacterStyle sNotificationReadStyleSpan; 97 98 private static final Map<Integer, Integer> sPriorityToLength = Maps.newHashMap(); 99 private static final SimpleStringSplitter SENDER_LIST_SPLITTER = 100 new SimpleStringSplitter(Utils.SENDER_LIST_SEPARATOR); 101 private static String[] sSenderFragments = new String[8]; 102 103 /** A factory that produces a plain text converter that removes elided text. */ 104 private static final HtmlTree.PlainTextConverterFactory MESSAGE_CONVERTER_FACTORY = 105 new HtmlTree.PlainTextConverterFactory() { 106 @Override 107 public HtmlTree.PlainTextConverter createInstance() { 108 return new MailMessagePlainTextConverter(); 109 } 110 }; 111 112 /** 113 * Clears all notifications in response to the user tapping "Clear" in the status bar. 114 */ 115 public static void clearAllNotfications(Context context) { 116 LogUtils.v(LOG_TAG, "Clearing all notifications."); 117 final NotificationMap notificationMap = getNotificationMap(context); 118 notificationMap.clear(); 119 notificationMap.saveNotificationMap(context); 120 } 121 122 /** 123 * Returns the notification map, creating it if necessary. 124 */ 125 private static synchronized NotificationMap getNotificationMap(Context context) { 126 if (sActiveNotificationMap == null) { 127 sActiveNotificationMap = new NotificationMap(); 128 129 // populate the map from the cached data 130 sActiveNotificationMap.loadNotificationMap(context); 131 } 132 return sActiveNotificationMap; 133 } 134 135 /** 136 * Class representing the existing notifications, and the number of unread and 137 * unseen conversations that triggered each. 138 */ 139 private static class NotificationMap 140 extends ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>> { 141 142 private static final String NOTIFICATION_PART_SEPARATOR = " "; 143 private static final int NUM_NOTIFICATION_PARTS= 4; 144 145 /** 146 * Retuns the unread count for the given NotificationKey. 147 */ 148 public Integer getUnread(NotificationKey key) { 149 final Pair<Integer, Integer> value = get(key); 150 return value != null ? value.first : null; 151 } 152 153 /** 154 * Retuns the unread unseen count for the given NotificationKey. 155 */ 156 public Integer getUnseen(NotificationKey key) { 157 final Pair<Integer, Integer> value = get(key); 158 return value != null ? value.second : null; 159 } 160 161 /** 162 * Store the unread and unseen value for the given NotificationKey 163 */ 164 public void put(NotificationKey key, int unread, int unseen) { 165 final Pair<Integer, Integer> value = 166 new Pair<Integer, Integer>(Integer.valueOf(unread), Integer.valueOf(unseen)); 167 put(key, value); 168 } 169 170 /** 171 * Populates the notification map with previously cached data. 172 */ 173 public synchronized void loadNotificationMap(final Context context) { 174 final MailPrefs mailPrefs = MailPrefs.get(context); 175 final Set<String> notificationSet = mailPrefs.getActiveNotificationSet(); 176 if (notificationSet != null) { 177 for (String notificationEntry : notificationSet) { 178 // Get the parts of the string that make the notification entry 179 final String[] notificationParts = 180 TextUtils.split(notificationEntry, NOTIFICATION_PART_SEPARATOR); 181 if (notificationParts.length == NUM_NOTIFICATION_PARTS) { 182 final Uri accountUri = Uri.parse(notificationParts[0]); 183 final Cursor accountCursor = context.getContentResolver().query( 184 accountUri, UIProvider.ACCOUNTS_PROJECTION, null, null, null); 185 final Account account; 186 try { 187 if (accountCursor.moveToFirst()) { 188 account = new Account(accountCursor); 189 } else { 190 continue; 191 } 192 } finally { 193 accountCursor.close(); 194 } 195 196 final Uri folderUri = Uri.parse(notificationParts[1]); 197 final Cursor folderCursor = context.getContentResolver().query( 198 folderUri, UIProvider.FOLDERS_PROJECTION, null, null, null); 199 final Folder folder; 200 try { 201 if (folderCursor.moveToFirst()) { 202 folder = new Folder(folderCursor); 203 } else { 204 continue; 205 } 206 } finally { 207 folderCursor.close(); 208 } 209 210 final NotificationKey key = new NotificationKey(account, folder); 211 final Integer unreadValue = Integer.valueOf(notificationParts[2]); 212 final Integer unseenValue = Integer.valueOf(notificationParts[3]); 213 final Pair<Integer, Integer> unreadUnseenValue = 214 new Pair<Integer, Integer>(unreadValue, unseenValue); 215 put(key, unreadUnseenValue); 216 } 217 } 218 } 219 } 220 221 /** 222 * Cache the notification map. 223 */ 224 public synchronized void saveNotificationMap(Context context) { 225 final Set<String> notificationSet = Sets.newHashSet(); 226 final Set<NotificationKey> keys = keySet(); 227 for (NotificationKey key : keys) { 228 final Pair<Integer, Integer> value = get(key); 229 final Integer unreadCount = value.first; 230 final Integer unseenCount = value.second; 231 if (value != null && unreadCount != null && unseenCount != null) { 232 final String[] partValues = new String[] { 233 key.account.uri.toString(), key.folder.uri.toString(), 234 unreadCount.toString(), unseenCount.toString()}; 235 notificationSet.add(TextUtils.join(NOTIFICATION_PART_SEPARATOR, partValues)); 236 } 237 } 238 final MailPrefs mailPrefs = MailPrefs.get(context); 239 mailPrefs.cacheActiveNotificationSet(notificationSet); 240 } 241 } 242 243 /** 244 * @return the title of this notification with each account and the number of unread and unseen 245 * conversations for it. Also remove any account in the map that has 0 unread. 246 */ 247 private static String createNotificationString(NotificationMap notifications) { 248 StringBuilder result = new StringBuilder(); 249 int i = 0; 250 Set<NotificationKey> keysToRemove = Sets.newHashSet(); 251 for (NotificationKey key : notifications.keySet()) { 252 Integer unread = notifications.getUnread(key); 253 Integer unseen = notifications.getUnseen(key); 254 if (unread == null || unread.intValue() == 0) { 255 keysToRemove.add(key); 256 } else { 257 if (i > 0) result.append(", "); 258 result.append(key.toString() + " (" + unread + ", " + unseen + ")"); 259 i++; 260 } 261 } 262 263 for (NotificationKey key : keysToRemove) { 264 notifications.remove(key); 265 } 266 267 return result.toString(); 268 } 269 270 /** 271 * Get all notifications for all accounts and cancel them. 272 **/ 273 public static void cancelAllNotifications(Context context) { 274 NotificationManager nm = (NotificationManager) context.getSystemService( 275 Context.NOTIFICATION_SERVICE); 276 nm.cancelAll(); 277 clearAllNotfications(context); 278 } 279 280 /** 281 * Get all notifications for all accounts, cancel them, and repost. 282 * This happens when locale changes. 283 **/ 284 public static void cancelAndResendNotifications(Context context) { 285 resendNotifications(context, true); 286 } 287 288 /** 289 * Get all notifications for all accounts, optionally cancel them, and repost. 290 * This happens when locale changes. 291 **/ 292 public static void resendNotifications(Context context, final boolean cancelExisting) { 293 if (cancelExisting) { 294 NotificationManager nm = 295 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 296 nm.cancelAll(); 297 } 298 // Re-validate the notifications. 299 final NotificationMap notificationMap = getNotificationMap(context); 300 final Set<NotificationKey> keys = notificationMap.keySet(); 301 for (NotificationKey notification : keys) { 302 final Folder folder = notification.folder; 303 final int notificationId = getNotificationId(notification.account.name, folder); 304 305 final NotificationAction undoableAction = 306 NotificationActionUtils.sUndoNotifications.get(notificationId); 307 if (undoableAction == null) { 308 validateNotifications(context, folder, notification.account, true, false, 309 notification); 310 } else { 311 // Create an undo notification 312 NotificationActionUtils.createUndoNotification(context, undoableAction); 313 } 314 } 315 } 316 317 /** 318 * Validate the notifications for the specified account. 319 */ 320 public static void validateAccountNotifications(Context context, String account) { 321 List<NotificationKey> notificationsToCancel = Lists.newArrayList(); 322 // Iterate through the notification map to see if there are any entries that correspond to 323 // labels that are not in the sync set. 324 final NotificationMap notificationMap = getNotificationMap(context); 325 Set<NotificationKey> keys = notificationMap.keySet(); 326 final AccountPreferences accountPreferences = new AccountPreferences(context, account); 327 final boolean enabled = accountPreferences.areNotificationsEnabled(); 328 if (!enabled) { 329 // Cancel all notifications for this account 330 for (NotificationKey notification : keys) { 331 if (notification.account.name.equals(account)) { 332 notificationsToCancel.add(notification); 333 } 334 } 335 } else { 336 // Iterate through the notification map to see if there are any entries that 337 // correspond to labels that are not in the notification set. 338 for (NotificationKey notification : keys) { 339 if (notification.account.name.equals(account)) { 340 // If notification is not enabled for this label, remember this NotificationKey 341 // to later cancel the notification, and remove the entry from the map 342 final Folder folder = notification.folder; 343 final boolean isInbox = 344 notification.account.settings.defaultInbox.equals(folder.uri); 345 final FolderPreferences folderPreferences = new FolderPreferences( 346 context, notification.account.name, folder, isInbox); 347 348 if (!folderPreferences.areNotificationsEnabled()) { 349 notificationsToCancel.add(notification); 350 } 351 } 352 } 353 } 354 355 // Cancel & remove the invalid notifications. 356 if (notificationsToCancel.size() > 0) { 357 NotificationManager nm = (NotificationManager) context.getSystemService( 358 Context.NOTIFICATION_SERVICE); 359 for (NotificationKey notification : notificationsToCancel) { 360 final Folder folder = notification.folder; 361 final int notificationId = getNotificationId(notification.account.name, folder); 362 nm.cancel(notificationId); 363 notificationMap.remove(notification); 364 NotificationActionUtils.sUndoNotifications.remove(notificationId); 365 NotificationActionUtils.sNotificationTimestamps.delete(notificationId); 366 } 367 notificationMap.saveNotificationMap(context); 368 } 369 } 370 371 /** 372 * Display only one notification. 373 */ 374 public static void setNewEmailIndicator(Context context, final int unreadCount, 375 final int unseenCount, final Account account, final Folder folder, 376 final boolean getAttention) { 377 boolean ignoreUnobtrusiveSetting = false; 378 379 final int notificationId = getNotificationId(account.name, folder); 380 381 // Update the notification map 382 final NotificationMap notificationMap = getNotificationMap(context); 383 final NotificationKey key = new NotificationKey(account, folder); 384 if (unreadCount == 0) { 385 notificationMap.remove(key); 386 ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)) 387 .cancel(notificationId); 388 } else { 389 if (!notificationMap.containsKey(key)) { 390 // This account previously didn't have any unread mail; ignore the "unobtrusive 391 // notifications" setting and play sound and/or vibrate the device even if a 392 // notification already exists (bug 2412348). 393 ignoreUnobtrusiveSetting = true; 394 } 395 notificationMap.put(key, unreadCount, unseenCount); 396 } 397 notificationMap.saveNotificationMap(context); 398 399 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 400 LogUtils.v(LOG_TAG, "New email: %s mapSize: %d getAttention: %b", 401 createNotificationString(notificationMap), notificationMap.size(), 402 getAttention); 403 } 404 405 if (NotificationActionUtils.sUndoNotifications.get(notificationId) == null) { 406 validateNotifications(context, folder, account, getAttention, ignoreUnobtrusiveSetting, 407 key); 408 } 409 } 410 411 /** 412 * Validate the notifications notification. 413 */ 414 private static void validateNotifications(Context context, final Folder folder, 415 final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting, 416 NotificationKey key) { 417 418 NotificationManager nm = (NotificationManager) 419 context.getSystemService(Context.NOTIFICATION_SERVICE); 420 421 final NotificationMap notificationMap = getNotificationMap(context); 422 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 423 LogUtils.v(LOG_TAG, "Validating Notification: %s mapSize: %d folder: %s " 424 + "getAttention: %b", createNotificationString(notificationMap), 425 notificationMap.size(), folder.name, getAttention); 426 } 427 // The number of unread messages for this account and label. 428 final Integer unread = notificationMap.getUnread(key); 429 final int unreadCount = unread != null ? unread.intValue() : 0; 430 final Integer unseen = notificationMap.getUnseen(key); 431 int unseenCount = unseen != null ? unseen.intValue() : 0; 432 433 Cursor cursor = null; 434 435 try { 436 final Uri.Builder uriBuilder = folder.conversationListUri.buildUpon(); 437 uriBuilder.appendQueryParameter( 438 UIProvider.SEEN_QUERY_PARAMETER, Boolean.FALSE.toString()); 439 cursor = context.getContentResolver().query(uriBuilder.build(), 440 UIProvider.CONVERSATION_PROJECTION, null, null, null); 441 final int cursorUnseenCount = cursor.getCount(); 442 443 // Make sure the unseen count matches the number of items in the cursor. But, we don't 444 // want to overwrite a 0 unseen count that was specified in the intent 445 if (unseenCount != 0 && unseenCount != cursorUnseenCount) { 446 LogUtils.d(LOG_TAG, 447 "Unseen count doesn't match cursor count. unseen: %d cursor count: %d", 448 unseenCount, cursorUnseenCount); 449 unseenCount = cursorUnseenCount; 450 } 451 452 // For the purpose of the notifications, the unseen count should be capped at the num of 453 // unread conversations. 454 if (unseenCount > unreadCount) { 455 unseenCount = unreadCount; 456 } 457 458 final int notificationId = getNotificationId(account.name, folder); 459 460 if (unseenCount == 0) { 461 nm.cancel(notificationId); 462 return; 463 } 464 465 // We now have all we need to create the notification and the pending intent 466 PendingIntent clickIntent; 467 468 NotificationCompat.Builder notification = new NotificationCompat.Builder(context); 469 notification.setSmallIcon(R.drawable.stat_notify_email); 470 notification.setTicker(account.name); 471 472 final long when; 473 474 final long oldWhen = 475 NotificationActionUtils.sNotificationTimestamps.get(notificationId); 476 if (oldWhen != 0) { 477 when = oldWhen; 478 } else { 479 when = System.currentTimeMillis(); 480 } 481 482 notification.setWhen(when); 483 484 // The timestamp is now stored in the notification, so we can remove it from here 485 NotificationActionUtils.sNotificationTimestamps.delete(notificationId); 486 487 // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a 488 // notification. Also this intent gets fired when the user taps on a notification as 489 // the AutoCancel flag has been set 490 final Intent cancelNotificationIntent = 491 new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS); 492 cancelNotificationIntent.setPackage(context.getPackageName()); 493 cancelNotificationIntent.putExtra(MailIntentService.ACCOUNT_EXTRA, account); 494 cancelNotificationIntent.putExtra(MailIntentService.FOLDER_EXTRA, folder); 495 496 notification.setDeleteIntent(PendingIntent.getService( 497 context, notificationId, cancelNotificationIntent, 0)); 498 499 // Ensure that the notification is cleared when the user selects it 500 notification.setAutoCancel(true); 501 502 boolean eventInfoConfigured = false; 503 504 final boolean isInbox = account.settings.defaultInbox.equals(folder.uri); 505 final FolderPreferences folderPreferences = 506 new FolderPreferences(context, account.name, folder, isInbox); 507 508 if (isInbox) { 509 final AccountPreferences accountPreferences = 510 new AccountPreferences(context, account.name); 511 moveNotificationSetting(accountPreferences, folderPreferences); 512 } 513 514 if (!folderPreferences.areNotificationsEnabled()) { 515 // Don't notify 516 return; 517 } 518 519 if (unreadCount > 0) { 520 // How can I order this properly? 521 if (cursor.moveToNext()) { 522 Intent notificationIntent = createViewConversationIntent(account, folder, null); 523 524 // Launch directly to the conversation, if the 525 // number of unseen conversations == 1 526 if (unseenCount == 1) { 527 notificationIntent = createViewConversationIntent(account, folder, cursor); 528 } 529 530 if (notificationIntent == null) { 531 LogUtils.e(LOG_TAG, "Null intent when building notification"); 532 return; 533 } 534 535 clickIntent = PendingIntent.getActivity(context, -1, notificationIntent, 0); 536 configureLatestEventInfoFromConversation(context, account, folderPreferences, 537 notification, cursor, clickIntent, notificationIntent, 538 account.name, unreadCount, unseenCount, folder, when); 539 eventInfoConfigured = true; 540 } 541 } 542 543 final boolean vibrate = folderPreferences.isNotificationVibrateEnabled(); 544 final String ringtoneUri = folderPreferences.getNotificationRingtoneUri(); 545 final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled(); 546 547 if (!ignoreUnobtrusiveSetting && account != null && notifyOnce) { 548 // If the user has "unobtrusive notifications" enabled, only alert the first time 549 // new mail is received in this account. This is the default behavior. See 550 // bugs 2412348 and 2413490. 551 notification.setOnlyAlertOnce(true); 552 } 553 554 if (account != null) { 555 LogUtils.d(LOG_TAG, "Account: %s vibrate: %s", account.name, 556 Boolean.toString(folderPreferences.isNotificationVibrateEnabled())); 557 } 558 559 int defaults = 0; 560 561 /* 562 * We do not want to notify if this is coming back from an Undo notification, hence the 563 * oldWhen check. 564 */ 565 if (getAttention && account != null && oldWhen == 0) { 566 final AccountPreferences accountPreferences = 567 new AccountPreferences(context, account.name); 568 if (accountPreferences.areNotificationsEnabled()) { 569 if (vibrate) { 570 defaults |= Notification.DEFAULT_VIBRATE; 571 } 572 573 notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null 574 : Uri.parse(ringtoneUri)); 575 LogUtils.d(LOG_TAG, "New email in %s vibrateWhen: %s, playing notification: %s", 576 account.name, vibrate, ringtoneUri); 577 } 578 } 579 580 if (eventInfoConfigured) { 581 defaults |= Notification.DEFAULT_LIGHTS; 582 notification.setDefaults(defaults); 583 584 if (oldWhen != 0) { 585 // We do not want to display the ticker again if we are re-displaying this 586 // notification (like from an Undo notification) 587 notification.setTicker(null); 588 } 589 590 nm.notify(notificationId, notification.build()); 591 } 592 } finally { 593 if (cursor != null) { 594 cursor.close(); 595 } 596 } 597 } 598 599 /** 600 * @return an {@link Intent} which, if launched, will display the corresponding conversation 601 */ 602 private static Intent createViewConversationIntent(final Account account, final Folder folder, 603 final Cursor cursor) { 604 if (folder == null || account == null) { 605 LogUtils.e(LOG_TAG, "Null account or folder. account: %s folder: %s", 606 account, folder); 607 return null; 608 } 609 610 final Intent intent; 611 612 if (cursor == null) { 613 intent = Utils.createViewFolderIntent(folder, account); 614 } else { 615 // A conversation cursor has been specified, so this intent is intended to be go 616 // directly to the one new conversation 617 618 // Get the Conversation object 619 final Conversation conversation = new Conversation(cursor); 620 intent = Utils.createViewConversationIntent(conversation, folder, account); 621 } 622 623 return intent; 624 } 625 626 private static Bitmap getDefaultNotificationIcon(Context context, boolean multipleNew) { 627 final Bitmap icon; 628 if (multipleNew) { 629 if (sMultipleNotifIcon == null) { 630 sMultipleNotifIcon = BitmapFactory.decodeResource(context.getResources(), 631 R.drawable.ic_notification_multiple_mail_holo_dark); 632 } 633 icon = sMultipleNotifIcon; 634 } else { 635 if (sDefaultSingleNotifIcon == null) { 636 sDefaultSingleNotifIcon = BitmapFactory.decodeResource(context.getResources(), 637 R.drawable.ic_contact_picture); 638 } 639 icon = sDefaultSingleNotifIcon; 640 } 641 return icon; 642 } 643 644 private static void configureLatestEventInfoFromConversation(final Context context, 645 final Account account, final FolderPreferences folderPreferences, 646 final NotificationCompat.Builder notification, final Cursor conversationCursor, 647 final PendingIntent clickIntent, final Intent notificationIntent, 648 final String notificationAccount, final int unreadCount, final int unseenCount, 649 final Folder folder, final long when) { 650 final Resources res = context.getResources(); 651 652 LogUtils.w(LOG_TAG, "Showing notification with unreadCount of %d and " 653 + "unseenCount of %d", unreadCount, unseenCount); 654 655 String notificationTicker = null; 656 657 // Boolean indicating that this notification is for a non-inbox label. 658 final boolean isInbox = account.settings.defaultInbox.equals(folder.uri); 659 660 // Notification label name for user label notifications. 661 final String notificationLabelName = isInbox ? null : folder.name; 662 663 if (unseenCount > 1) { 664 // Build the string that describes the number of new messages 665 final String newMessagesString = res.getString(R.string.new_messages, unseenCount); 666 667 // Use the default notification icon 668 notification.setLargeIcon( 669 getDefaultNotificationIcon(context, true /* multiple new messages */)); 670 671 // The ticker initially start as the new messages string. 672 notificationTicker = newMessagesString; 673 674 // The title of the notification is the new messages string 675 notification.setContentTitle(newMessagesString); 676 677 if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) { 678 // For a new-style notification 679 final int maxNumDigestItems = context.getResources().getInteger( 680 R.integer.max_num_notification_digest_items); 681 682 // The body of the notification is the account name, or the label name. 683 notification.setSubText(isInbox ? notificationAccount : notificationLabelName); 684 685 final NotificationCompat.InboxStyle digest = 686 new NotificationCompat.InboxStyle(notification); 687 688 digest.setBigContentTitle(newMessagesString); 689 690 int numDigestItems = 0; 691 do { 692 final Conversation conversation = new Conversation(conversationCursor); 693 694 if (!conversation.read) { 695 boolean multipleUnreadThread = false; 696 // TODO(cwren) extract this pattern into a helper 697 698 Cursor cursor = null; 699 MessageCursor messageCursor = null; 700 try { 701 final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon(); 702 uriBuilder.appendQueryParameter( 703 UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName); 704 cursor = context.getContentResolver().query(uriBuilder.build(), 705 UIProvider.MESSAGE_PROJECTION, null, null, null); 706 messageCursor = new MessageCursor(cursor); 707 708 String from = null; 709 String fromAddress = ""; 710 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) { 711 final Message message = messageCursor.getMessage(); 712 fromAddress = message.getFrom(); 713 from = getDisplayableSender(fromAddress); 714 } 715 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) { 716 final Message message = messageCursor.getMessage(); 717 if (!message.read && 718 !fromAddress.contentEquals(message.getFrom())) { 719 multipleUnreadThread = true; 720 break; 721 } 722 } 723 final SpannableStringBuilder sendersBuilder; 724 if (multipleUnreadThread) { 725 final int sendersLength = 726 res.getInteger(R.integer.swipe_senders_length); 727 728 sendersBuilder = getStyledSenders(context, conversationCursor, 729 sendersLength, notificationAccount); 730 } else { 731 sendersBuilder = new SpannableStringBuilder(from); 732 } 733 final CharSequence digestLine = getSingleMessageInboxLine(context, 734 sendersBuilder.toString(), 735 conversation.subject, 736 conversation.snippet); 737 digest.addLine(digestLine); 738 numDigestItems++; 739 } finally { 740 if (messageCursor != null) { 741 messageCursor.close(); 742 } 743 if (cursor != null) { 744 cursor.close(); 745 } 746 } 747 } 748 } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext()); 749 } else { 750 // The body of the notification is the account name, or the label name. 751 notification.setContentText( 752 isInbox ? notificationAccount : notificationLabelName); 753 } 754 } else { 755 // For notifications for a single new conversation, we want to get the information from 756 // the conversation 757 758 // Move the cursor to the most recent unread conversation 759 seekToLatestUnreadConversation(conversationCursor); 760 761 final Conversation conversation = new Conversation(conversationCursor); 762 763 Cursor cursor = null; 764 MessageCursor messageCursor = null; 765 boolean multipleUnseenThread = false; 766 String from = null; 767 try { 768 cursor = context.getContentResolver().query(conversation.messageListUri, 769 UIProvider.MESSAGE_PROJECTION, null, null, null); 770 messageCursor = new MessageCursor(cursor); 771 // Use the information from the last sender in the conversation that triggered 772 // this notification. 773 774 String fromAddress = ""; 775 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) { 776 final Message message = messageCursor.getMessage(); 777 fromAddress = message.getFrom(); 778 from = getDisplayableSender(fromAddress); 779 notification.setLargeIcon( 780 getContactIcon(context, getSenderAddress(fromAddress))); 781 } 782 783 // Assume that the last message in this conversation is unread 784 int firstUnseenMessagePos = messageCursor.getPosition(); 785 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) { 786 final Message message = messageCursor.getMessage(); 787 final boolean unseen = !message.seen; 788 if (unseen) { 789 firstUnseenMessagePos = messageCursor.getPosition(); 790 if (!multipleUnseenThread 791 && !fromAddress.contentEquals(message.getFrom())) { 792 multipleUnseenThread = true; 793 } 794 } 795 } 796 797 if (Utils.isRunningJellybeanOrLater()) { 798 // For a new-style notification 799 800 if (multipleUnseenThread) { 801 // The title of a single conversation is the list of senders. 802 int sendersLength = res.getInteger(R.integer.swipe_senders_length); 803 804 final SpannableStringBuilder sendersBuilder = getStyledSenders( 805 context, conversationCursor, sendersLength, notificationAccount); 806 807 notification.setContentTitle(sendersBuilder); 808 // For a single new conversation, the ticker is based on the sender's name. 809 notificationTicker = sendersBuilder.toString(); 810 } else { 811 // The title of a single message the sender. 812 notification.setContentTitle(from); 813 // For a single new conversation, the ticker is based on the sender's name. 814 notificationTicker = from; 815 } 816 817 // The notification content will be the subject of the conversation. 818 notification.setContentText( 819 getSingleMessageLittleText(context, conversation.subject)); 820 821 // The notification subtext will be the subject of the conversation for inbox 822 // notifications, or will based on the the label name for user label 823 // notifications. 824 notification.setSubText(isInbox ? notificationAccount : notificationLabelName); 825 826 if (multipleUnseenThread) { 827 notification.setLargeIcon(getDefaultNotificationIcon(context, true)); 828 } 829 final NotificationCompat.BigTextStyle bigText = 830 new NotificationCompat.BigTextStyle(notification); 831 832 // Seek the message cursor to the first unread message 833 messageCursor.moveToPosition(firstUnseenMessagePos); 834 final Message message = messageCursor.getMessage(); 835 bigText.bigText(getSingleMessageBigText(context, 836 conversation.subject, message)); 837 838 final Set<String> notificationActions = 839 folderPreferences.getNotificationActions(); 840 841 final int notificationId = getNotificationId(notificationAccount, folder); 842 843 NotificationActionUtils.addNotificationActions(context, notificationIntent, 844 notification, account, conversation, message, folder, 845 notificationId, when, notificationActions); 846 } else { 847 // For an old-style notification 848 849 // The title of a single conversation notification is built from both the sender 850 // and subject of the new message. 851 notification.setContentTitle(getSingleMessageNotificationTitle(context, 852 from, conversation.subject)); 853 854 // The notification content will be the subject of the conversation for inbox 855 // notifications, or will based on the the label name for user label 856 // notifications. 857 notification.setContentText( 858 isInbox ? notificationAccount : notificationLabelName); 859 860 // For a single new conversation, the ticker is based on the sender's name. 861 notificationTicker = from; 862 } 863 } finally { 864 if (messageCursor != null) { 865 messageCursor.close(); 866 } 867 if (cursor != null) { 868 cursor.close(); 869 } 870 } 871 } 872 873 // Build the notification ticker 874 if (notificationLabelName != null && notificationTicker != null) { 875 // This is a per label notification, format the ticker with that information 876 notificationTicker = res.getString(R.string.label_notification_ticker, 877 notificationLabelName, notificationTicker); 878 } 879 880 if (notificationTicker != null) { 881 // If we didn't generate a notification ticker, it will default to account name 882 notification.setTicker(notificationTicker); 883 } 884 885 // Set the number in the notification 886 if (unreadCount > 1) { 887 notification.setNumber(unreadCount); 888 } 889 890 notification.setContentIntent(clickIntent); 891 } 892 893 private static SpannableStringBuilder getStyledSenders(final Context context, 894 final Cursor conversationCursor, final int maxLength, final String account) { 895 final Conversation conversation = new Conversation(conversationCursor); 896 final com.android.mail.providers.ConversationInfo conversationInfo = 897 conversation.conversationInfo; 898 final ArrayList<SpannableString> senders = new ArrayList<SpannableString>(); 899 if (sNotificationUnreadStyleSpan == null) { 900 sNotificationUnreadStyleSpan = new TextAppearanceSpan( 901 context, R.style.NotificationSendersUnreadTextAppearance); 902 sNotificationReadStyleSpan = 903 new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance); 904 } 905 SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account, 906 sNotificationUnreadStyleSpan, sNotificationReadStyleSpan, false); 907 908 return ellipsizeStyledSenders(context, senders); 909 } 910 911 private static String sSendersSplitToken = null; 912 private static String sElidedPaddingToken = null; 913 914 private static SpannableStringBuilder ellipsizeStyledSenders(final Context context, 915 ArrayList<SpannableString> styledSenders) { 916 if (sSendersSplitToken == null) { 917 sSendersSplitToken = context.getString(R.string.senders_split_token); 918 sElidedPaddingToken = context.getString(R.string.elided_padding_token); 919 } 920 921 SpannableStringBuilder builder = new SpannableStringBuilder(); 922 SpannableString prevSender = null; 923 for (SpannableString sender : styledSenders) { 924 if (sender == null) { 925 LogUtils.e(LOG_TAG, "null sender while iterating over styledSenders"); 926 continue; 927 } 928 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 929 if (SendersView.sElidedString.equals(sender.toString())) { 930 prevSender = sender; 931 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 932 } else if (builder.length() > 0 933 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 934 .toString()))) { 935 prevSender = sender; 936 sender = copyStyles(spans, sSendersSplitToken + sender); 937 } else { 938 prevSender = sender; 939 } 940 builder.append(sender); 941 } 942 return builder; 943 } 944 945 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 946 SpannableString s = new SpannableString(newText); 947 if (spans != null && spans.length > 0) { 948 s.setSpan(spans[0], 0, s.length(), 0); 949 } 950 return s; 951 } 952 953 /** 954 * Seeks the cursor to the position of the most recent unread conversation. If no unread 955 * conversation is found, the position of the cursor will be restored, and false will be 956 * returned. 957 */ 958 private static boolean seekToLatestUnreadConversation(final Cursor cursor) { 959 final int initialPosition = cursor.getPosition(); 960 do { 961 final Conversation conversation = new Conversation(cursor); 962 if (!conversation.read) { 963 return true; 964 } 965 } while (cursor.moveToNext()); 966 967 // Didn't find an unread conversation, reset the position. 968 cursor.moveToPosition(initialPosition); 969 return false; 970 } 971 972 /** 973 * Sets the bigtext for a notification for a single new conversation 974 * 975 * @param context 976 * @param senders Sender of the new message that triggered the notification. 977 * @param subject Subject of the new message that triggered the notification 978 * @param snippet Snippet of the new message that triggered the notification 979 * @return a {@link CharSequence} suitable for use in 980 * {@link android.support.v4.app.NotificationCompat.BigTextStyle} 981 */ 982 private static CharSequence getSingleMessageInboxLine(Context context, 983 String senders, String subject, String snippet) { 984 // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText 985 986 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet; 987 988 final TextAppearanceSpan notificationPrimarySpan = 989 new TextAppearanceSpan(context, R.style.NotificationPrimaryText); 990 991 if (TextUtils.isEmpty(senders)) { 992 // If the senders are empty, just use the subject/snippet. 993 return subjectSnippet; 994 } else if (TextUtils.isEmpty(subjectSnippet)) { 995 // If the subject/snippet is empty, just use the senders. 996 final SpannableString spannableString = new SpannableString(senders); 997 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0); 998 999 return spannableString; 1000 } else { 1001 final String formatString = context.getResources().getString( 1002 R.string.multiple_new_message_notification_item); 1003 final TextAppearanceSpan notificationSecondarySpan = 1004 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 1005 1006 final String instantiatedString = String.format(formatString, senders, subjectSnippet); 1007 1008 final SpannableString spannableString = new SpannableString(instantiatedString); 1009 1010 final boolean isOrderReversed = formatString.indexOf("%2$s") < 1011 formatString.indexOf("%1$s"); 1012 final int primaryOffset = 1013 (isOrderReversed ? instantiatedString.lastIndexOf(senders) : 1014 instantiatedString.indexOf(senders)); 1015 final int secondaryOffset = 1016 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) : 1017 instantiatedString.indexOf(subjectSnippet)); 1018 spannableString.setSpan(notificationPrimarySpan, 1019 primaryOffset, primaryOffset + senders.length(), 0); 1020 spannableString.setSpan(notificationSecondarySpan, 1021 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0); 1022 return spannableString; 1023 } 1024 } 1025 1026 /** 1027 * Sets the bigtext for a notification for a single new conversation 1028 * @param context 1029 * @param subject Subject of the new message that triggered the notification 1030 * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText} 1031 */ 1032 private static CharSequence getSingleMessageLittleText(Context context, String subject) { 1033 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 1034 context, R.style.NotificationPrimaryText); 1035 1036 final SpannableString spannableString = new SpannableString(subject); 1037 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 1038 1039 return spannableString; 1040 } 1041 1042 /** 1043 * Sets the bigtext for a notification for a single new conversation 1044 * 1045 * @param context 1046 * @param subject Subject of the new message that triggered the notification 1047 * @param message the {@link Message} to be displayed. 1048 * @return a {@link CharSequence} suitable for use in 1049 * {@link android.support.v4.app.NotificationCompat.BigTextStyle} 1050 */ 1051 private static CharSequence getSingleMessageBigText(Context context, String subject, 1052 final Message message) { 1053 1054 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 1055 context, R.style.NotificationPrimaryText); 1056 1057 final String snippet = getMessageBodyWithoutElidedText(message); 1058 1059 // Change multiple newlines (with potential white space between), into a single new line 1060 final String collapsedSnippet = 1061 !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : ""; 1062 1063 if (TextUtils.isEmpty(subject)) { 1064 // If the subject is empty, just use the snippet. 1065 return snippet; 1066 } else if (TextUtils.isEmpty(collapsedSnippet)) { 1067 // If the snippet is empty, just use the subject. 1068 final SpannableString spannableString = new SpannableString(subject); 1069 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 1070 1071 return spannableString; 1072 } else { 1073 final String notificationBigTextFormat = context.getResources().getString( 1074 R.string.single_new_message_notification_big_text); 1075 1076 // Localizers may change the order of the parameters, look at how the format 1077 // string is structured. 1078 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") > 1079 notificationBigTextFormat.indexOf("%1$s"); 1080 final String bigText = 1081 String.format(notificationBigTextFormat, subject, collapsedSnippet); 1082 final SpannableString spannableString = new SpannableString(bigText); 1083 1084 final int subjectOffset = 1085 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject)); 1086 spannableString.setSpan(notificationSubjectSpan, 1087 subjectOffset, subjectOffset + subject.length(), 0); 1088 1089 return spannableString; 1090 } 1091 } 1092 1093 /** 1094 * Gets the title for a notification for a single new conversation 1095 * @param context 1096 * @param sender Sender of the new message that triggered the notification. 1097 * @param subject Subject of the new message that triggered the notification 1098 * @return a {@link CharSequence} suitable for use as a {@link Notification} title. 1099 */ 1100 private static CharSequence getSingleMessageNotificationTitle(Context context, 1101 String sender, String subject) { 1102 1103 if (TextUtils.isEmpty(subject)) { 1104 // If the subject is empty, just set the title to the sender's information. 1105 return sender; 1106 } else { 1107 final String notificationTitleFormat = context.getResources().getString( 1108 R.string.single_new_message_notification_title); 1109 1110 // Localizers may change the order of the parameters, look at how the format 1111 // string is structured. 1112 final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") > 1113 notificationTitleFormat.indexOf("%1$s"); 1114 final String titleString = String.format(notificationTitleFormat, sender, subject); 1115 1116 // Format the string so the subject is using the secondaryText style 1117 final SpannableString titleSpannable = new SpannableString(titleString); 1118 1119 // Find the offset of the subject. 1120 final int subjectOffset = 1121 isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject); 1122 final TextAppearanceSpan notificationSubjectSpan = 1123 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 1124 titleSpannable.setSpan(notificationSubjectSpan, 1125 subjectOffset, subjectOffset + subject.length(), 0); 1126 return titleSpannable; 1127 } 1128 } 1129 1130 /** 1131 * Adds a fragment with given style to a string builder. 1132 * 1133 * @param builder the current string builder 1134 * @param fragment the fragment to be added 1135 * @param style the style of the fragment 1136 * @param withSpaces whether to add the whole fragment or to divide it into 1137 * smaller ones 1138 */ 1139 private static void addStyledFragment(SpannableStringBuilder builder, String fragment, 1140 CharacterStyle style, boolean withSpaces) { 1141 if (withSpaces) { 1142 int pos = builder.length(); 1143 builder.append(fragment); 1144 builder.setSpan(CharacterStyle.wrap(style), pos, builder.length(), 1145 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 1146 } else { 1147 int start = 0; 1148 while (true) { 1149 int pos = fragment.substring(start).indexOf(' '); 1150 if (pos == -1) { 1151 addStyledFragment(builder, fragment.substring(start), style, true); 1152 break; 1153 } else { 1154 pos += start; 1155 if (start < pos) { 1156 addStyledFragment(builder, fragment.substring(start, pos), style, true); 1157 builder.append(' '); 1158 } 1159 start = pos + 1; 1160 if (start >= fragment.length()) { 1161 break; 1162 } 1163 } 1164 } 1165 } 1166 } 1167 1168 /** 1169 * Uses sender instructions to build a formatted string. 1170 * 1171 * <p>Sender list instructions contain compact information about the sender list. Most work that 1172 * can be done without knowing how much room will be availble for the sender list is done when 1173 * creating the instructions. 1174 * 1175 * <p>The instructions string consists of tokens separated by SENDER_LIST_SEPARATOR. Here are 1176 * the tokens, one per line:<ul> 1177 * <li><tt>n</tt></li> 1178 * <li><em>int</em>, the number of non-draft messages in the conversation</li> 1179 * <li><tt>d</tt</li> 1180 * <li><em>int</em>, the number of drafts in the conversation</li> 1181 * <li><tt>l</tt></li> 1182 * <li><em>literal html to be included in the output</em></li> 1183 * <li><tt>s</tt> indicates that the message is sending (in the outbox without errors)</li> 1184 * <li><tt>f</tt> indicates that the message failed to send (in the outbox with errors)</li> 1185 * <li><em>for each message</em><ul> 1186 * <li><em>int</em>, 0 for read, 1 for unread</li> 1187 * <li><em>int</em>, the priority of the message. Zero is the most important</li> 1188 * <li><em>text</em>, the sender text or blank for messages from 'me'</li> 1189 * </ul></li> 1190 * <li><tt>e</tt> to indicate that one or more messages have been elided</li> 1191 * 1192 * <p>The instructions indicate how many messages and drafts are in the conversation and then 1193 * describe the most important messages in order, indicating the priority of each message and 1194 * whether the message is unread. 1195 * 1196 * @param instructions instructions as described above 1197 * @param senderBuilder the SpannableStringBuilder to append to for sender information 1198 * @param statusBuilder the SpannableStringBuilder to append to for status 1199 * @param maxChars the number of characters available to display the text 1200 * @param unreadStyle the CharacterStyle for unread messages, or null 1201 * @param draftsStyle the CharacterStyle for draft messages, or null 1202 * @param sendingString the string to use when there are messages scheduled to be sent 1203 * @param sendFailedString the string to use when there are messages that mailed to send 1204 * @param meString the string to use for messages sent by this user 1205 * @param draftString the string to use for "Draft" 1206 * @param draftPluralString the string to use for "Drafts" 1207 * @param showNumMessages false means do not show the message count 1208 * @param onlyShowUnread true means the only information from unread messages should be included 1209 */ 1210 public static synchronized void getSenderSnippet( 1211 String instructions, SpannableStringBuilder senderBuilder, 1212 SpannableStringBuilder statusBuilder, int maxChars, 1213 CharacterStyle unreadStyle, 1214 CharacterStyle readStyle, 1215 CharacterStyle draftsStyle, 1216 CharSequence meString, CharSequence draftString, CharSequence draftPluralString, 1217 CharSequence sendingString, CharSequence sendFailedString, 1218 boolean forceAllUnread, boolean forceAllRead, boolean allowDraft, 1219 boolean showNumMessages, boolean onlyShowUnread) { 1220 assert !(forceAllUnread && forceAllRead); 1221 boolean unreadStatusIsForced = forceAllUnread || forceAllRead; 1222 boolean forcedUnreadStatus = forceAllUnread; 1223 1224 // Measure each fragment. It's ok to iterate over the entire set of fragments because it is 1225 // never a long list, even if there are many senders. 1226 final Map<Integer, Integer> priorityToLength = sPriorityToLength; 1227 priorityToLength.clear(); 1228 1229 int maxFoundPriority = Integer.MIN_VALUE; 1230 int numMessages = 0; 1231 int numDrafts = 0; 1232 CharSequence draftsFragment = ""; 1233 CharSequence sendingFragment = ""; 1234 CharSequence sendFailedFragment = ""; 1235 1236 SENDER_LIST_SPLITTER.setString(instructions); 1237 int numFragments = 0; 1238 String[] fragments = sSenderFragments; 1239 int currentSize = fragments.length; 1240 while (SENDER_LIST_SPLITTER.hasNext()) { 1241 fragments[numFragments++] = SENDER_LIST_SPLITTER.next(); 1242 if (numFragments == currentSize) { 1243 sSenderFragments = new String[2 * currentSize]; 1244 System.arraycopy(fragments, 0, sSenderFragments, 0, currentSize); 1245 currentSize *= 2; 1246 fragments = sSenderFragments; 1247 } 1248 } 1249 1250 for (int i = 0; i < numFragments;) { 1251 String fragment0 = fragments[i++]; 1252 if ("".equals(fragment0)) { 1253 // This should be the final fragment. 1254 } else if (Utils.SENDER_LIST_TOKEN_ELIDED.equals(fragment0)) { 1255 // ignore 1256 } else if (Utils.SENDER_LIST_TOKEN_NUM_MESSAGES.equals(fragment0)) { 1257 numMessages = Integer.valueOf(fragments[i++]); 1258 } else if (Utils.SENDER_LIST_TOKEN_NUM_DRAFTS.equals(fragment0)) { 1259 String numDraftsString = fragments[i++]; 1260 numDrafts = Integer.parseInt(numDraftsString); 1261 draftsFragment = numDrafts == 1 ? draftString : 1262 draftPluralString + " (" + numDraftsString + ")"; 1263 } else if (Utils.SENDER_LIST_TOKEN_LITERAL.equals(fragment0)) { 1264 senderBuilder.append(Html.fromHtml(fragments[i++])); 1265 return; 1266 } else if (Utils.SENDER_LIST_TOKEN_SENDING.equals(fragment0)) { 1267 sendingFragment = sendingString; 1268 } else if (Utils.SENDER_LIST_TOKEN_SEND_FAILED.equals(fragment0)) { 1269 sendFailedFragment = sendFailedString; 1270 } else { 1271 final String unreadString = fragment0; 1272 final boolean unread = unreadStatusIsForced 1273 ? forcedUnreadStatus : Integer.parseInt(unreadString) != 0; 1274 String priorityString = fragments[i++]; 1275 CharSequence nameString = fragments[i++]; 1276 if (nameString.length() == 0) nameString = meString; 1277 int priority = Integer.parseInt(priorityString); 1278 1279 // We want to include this entry if 1280 // 1) The onlyShowUnread flags is not set 1281 // 2) The above flag is set, and the message is unread 1282 if (!onlyShowUnread || unread) { 1283 priorityToLength.put(priority, nameString.length()); 1284 maxFoundPriority = Math.max(maxFoundPriority, priority); 1285 } 1286 } 1287 } 1288 final String numMessagesFragment = 1289 (numMessages != 0 && showNumMessages) ? 1290 " \u00A0" + Integer.toString(numMessages + numDrafts) : ""; 1291 1292 // Don't allocate fixedFragment unless we need it 1293 SpannableStringBuilder fixedFragment = null; 1294 int fixedFragmentLength = 0; 1295 if (draftsFragment.length() != 0 && allowDraft) { 1296 if (fixedFragment == null) { 1297 fixedFragment = new SpannableStringBuilder(); 1298 } 1299 fixedFragment.append(draftsFragment); 1300 if (draftsStyle != null) { 1301 fixedFragment.setSpan( 1302 CharacterStyle.wrap(draftsStyle), 1303 0, fixedFragment.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 1304 } 1305 } 1306 if (sendingFragment.length() != 0) { 1307 if (fixedFragment == null) { 1308 fixedFragment = new SpannableStringBuilder(); 1309 } 1310 if (fixedFragment.length() != 0) fixedFragment.append(", "); 1311 fixedFragment.append(sendingFragment); 1312 } 1313 if (sendFailedFragment.length() != 0) { 1314 if (fixedFragment == null) { 1315 fixedFragment = new SpannableStringBuilder(); 1316 } 1317 if (fixedFragment.length() != 0) fixedFragment.append(", "); 1318 fixedFragment.append(sendFailedFragment); 1319 } 1320 1321 if (fixedFragment != null) { 1322 fixedFragmentLength = fixedFragment.length(); 1323 } 1324 maxChars -= fixedFragmentLength; 1325 1326 int maxPriorityToInclude = -1; // inclusive 1327 int numCharsUsed = numMessagesFragment.length(); 1328 int numSendersUsed = 0; 1329 while (maxPriorityToInclude < maxFoundPriority) { 1330 if (priorityToLength.containsKey(maxPriorityToInclude + 1)) { 1331 int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1); 1332 if (numCharsUsed > 0) length += 2; 1333 // We must show at least two senders if they exist. If we don't have space for both 1334 // then we will truncate names. 1335 if (length > maxChars && numSendersUsed >= 2) { 1336 break; 1337 } 1338 numCharsUsed = length; 1339 numSendersUsed++; 1340 } 1341 maxPriorityToInclude++; 1342 } 1343 1344 int numCharsToRemovePerWord = 0; 1345 if (numCharsUsed > maxChars) { 1346 numCharsToRemovePerWord = (numCharsUsed - maxChars) / numSendersUsed; 1347 } 1348 1349 String lastFragment = null; 1350 CharacterStyle lastStyle = null; 1351 for (int i = 0; i < numFragments;) { 1352 String fragment0 = fragments[i++]; 1353 if ("".equals(fragment0)) { 1354 // This should be the final fragment. 1355 } else if (Utils.SENDER_LIST_TOKEN_ELIDED.equals(fragment0)) { 1356 if (lastFragment != null) { 1357 addStyledFragment(senderBuilder, lastFragment, lastStyle, false); 1358 senderBuilder.append(" "); 1359 addStyledFragment(senderBuilder, "..", lastStyle, true); 1360 senderBuilder.append(" "); 1361 } 1362 lastFragment = null; 1363 } else if (Utils.SENDER_LIST_TOKEN_NUM_MESSAGES.equals(fragment0)) { 1364 i++; 1365 } else if (Utils.SENDER_LIST_TOKEN_NUM_DRAFTS.equals(fragment0)) { 1366 i++; 1367 } else if (Utils.SENDER_LIST_TOKEN_SENDING.equals(fragment0)) { 1368 } else if (Utils.SENDER_LIST_TOKEN_SEND_FAILED.equals(fragment0)) { 1369 } else { 1370 final String unreadString = fragment0; 1371 final String priorityString = fragments[i++]; 1372 String nameString = fragments[i++]; 1373 final boolean unread = unreadStatusIsForced 1374 ? forcedUnreadStatus : Integer.parseInt(unreadString) != 0; 1375 1376 // We want to include this entry if 1377 // 1) The onlyShowUnread flags is not set 1378 // 2) The above flag is set, and the message is unread 1379 if (!onlyShowUnread || unread) { 1380 if (nameString.length() == 0) { 1381 nameString = meString.toString(); 1382 } else { 1383 nameString = Html.fromHtml(nameString).toString(); 1384 } 1385 if (numCharsToRemovePerWord != 0) { 1386 nameString = nameString.substring( 1387 0, Math.max(nameString.length() - numCharsToRemovePerWord, 0)); 1388 } 1389 final int priority = Integer.parseInt(priorityString); 1390 if (priority <= maxPriorityToInclude) { 1391 if (lastFragment != null && !lastFragment.equals(nameString)) { 1392 addStyledFragment( 1393 senderBuilder, lastFragment.concat(","), lastStyle, false); 1394 senderBuilder.append(" "); 1395 } 1396 lastFragment = nameString; 1397 lastStyle = unread ? unreadStyle : readStyle; 1398 } else { 1399 if (lastFragment != null) { 1400 addStyledFragment(senderBuilder, lastFragment, lastStyle, false); 1401 // Adjacent spans can cause the TextView in Gmail widget 1402 // confused and leads to weird behavior on scrolling. 1403 // Our workaround here is to separate the spans by 1404 // spaces. 1405 senderBuilder.append(" "); 1406 addStyledFragment(senderBuilder, "..", lastStyle, true); 1407 senderBuilder.append(" "); 1408 } 1409 lastFragment = null; 1410 } 1411 } 1412 } 1413 } 1414 if (lastFragment != null) { 1415 addStyledFragment(senderBuilder, lastFragment, lastStyle, false); 1416 } 1417 senderBuilder.append(numMessagesFragment); 1418 if (fixedFragmentLength != 0) { 1419 statusBuilder.append(fixedFragment); 1420 } 1421 } 1422 1423 /** 1424 * Clears the notifications for the specified account/folder/conversation. 1425 */ 1426 public static void clearFolderNotification(Context context, Account account, Folder folder) { 1427 LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.name, folder.name); 1428 final NotificationMap notificationMap = getNotificationMap(context); 1429 final NotificationKey key = new NotificationKey(account, folder); 1430 notificationMap.remove(key); 1431 notificationMap.saveNotificationMap(context); 1432 1433 markSeen(context, folder); 1434 } 1435 1436 private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) { 1437 ArrayList<String> whereArgs = new ArrayList<String>(); 1438 StringBuilder whereBuilder = new StringBuilder(); 1439 String[] questionMarks = new String[addresses.size()]; 1440 1441 whereArgs.addAll(addresses); 1442 Arrays.fill(questionMarks, "?"); 1443 whereBuilder.append(Email.DATA1 + " IN ("). 1444 append(TextUtils.join(",", questionMarks)). 1445 append(")"); 1446 1447 ContentResolver resolver = context.getContentResolver(); 1448 Cursor c = resolver.query(Email.CONTENT_URI, 1449 new String[]{Email.CONTACT_ID}, whereBuilder.toString(), 1450 whereArgs.toArray(new String[0]), null); 1451 1452 ArrayList<Long> contactIds = new ArrayList<Long>(); 1453 if (c == null) { 1454 return contactIds; 1455 } 1456 try { 1457 while (c.moveToNext()) { 1458 contactIds.add(c.getLong(0)); 1459 } 1460 } finally { 1461 c.close(); 1462 } 1463 return contactIds; 1464 } 1465 1466 private static Bitmap getContactIcon(Context context, String senderAddress) { 1467 if (senderAddress == null) { 1468 return null; 1469 } 1470 Bitmap icon = null; 1471 ArrayList<Long> contactIds = findContacts( 1472 context, Arrays.asList(new String[] { senderAddress })); 1473 1474 if (contactIds != null) { 1475 // Get the ideal size for this icon. 1476 final Resources res = context.getResources(); 1477 final int idealIconHeight = 1478 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); 1479 final int idealIconWidth = 1480 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); 1481 for (long id : contactIds) { 1482 final Uri contactUri = 1483 ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id); 1484 final Uri photoUri = Uri.withAppendedPath(contactUri, Photo.CONTENT_DIRECTORY); 1485 final Cursor cursor = context.getContentResolver().query( 1486 photoUri, new String[] { Photo.PHOTO }, null, null, null); 1487 1488 if (cursor != null) { 1489 try { 1490 if (cursor.moveToFirst()) { 1491 byte[] data = cursor.getBlob(0); 1492 if (data != null) { 1493 icon = BitmapFactory.decodeStream(new ByteArrayInputStream(data)); 1494 if (icon != null && icon.getHeight() < idealIconHeight) { 1495 // We should scale this image to fit the intended size 1496 icon = Bitmap.createScaledBitmap( 1497 icon, idealIconWidth, idealIconHeight, true); 1498 } 1499 if (icon != null) { 1500 break; 1501 } 1502 } 1503 } 1504 } finally { 1505 cursor.close(); 1506 } 1507 } 1508 } 1509 } 1510 if (icon == null) { 1511 // icon should be the default gmail icon. 1512 icon = getDefaultNotificationIcon(context, false /* single new message */); 1513 } 1514 return icon; 1515 } 1516 1517 private static String getMessageBodyWithoutElidedText(final Message message) { 1518 return getMessageBodyWithoutElidedText(message.bodyText); 1519 } 1520 1521 public static String getMessageBodyWithoutElidedText(String html) { 1522 if (TextUtils.isEmpty(html)) { 1523 return ""; 1524 } 1525 // Get the html "tree" for this message body 1526 final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html); 1527 htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY); 1528 1529 return htmlTree.getPlainText(); 1530 } 1531 1532 public static void markSeen(final Context context, final Folder folder) { 1533 final Uri uri = folder.uri; 1534 1535 final ContentValues values = new ContentValues(1); 1536 values.put(UIProvider.ConversationColumns.SEEN, 1); 1537 1538 context.getContentResolver().update(uri, values, null, null); 1539 } 1540 1541 /** 1542 * Returns a displayable string representing 1543 * the message sender. It has a preference toward showing the name, 1544 * but will fall back to the address if that is all that is available. 1545 */ 1546 private static String getDisplayableSender(String sender) { 1547 final EmailAddress address = EmailAddress.getEmailAddress(sender); 1548 1549 String displayableSender = address.getName(); 1550 // If that fails, default to the sender address. 1551 if (TextUtils.isEmpty(displayableSender)) { 1552 displayableSender = address.getAddress(); 1553 } 1554 // If we were unable to tokenize a name or address, 1555 // just use whatever was in the sender. 1556 if (TextUtils.isEmpty(displayableSender)) { 1557 displayableSender = sender; 1558 } 1559 return displayableSender; 1560 } 1561 1562 /** 1563 * Returns only the address portion of a message sender. 1564 */ 1565 private static String getSenderAddress(String sender) { 1566 final EmailAddress address = EmailAddress.getEmailAddress(sender); 1567 1568 String tokenizedAddress = address.getAddress(); 1569 1570 // If we were unable to tokenize a name or address, 1571 // just use whatever was in the sender. 1572 if (TextUtils.isEmpty(tokenizedAddress)) { 1573 tokenizedAddress = sender; 1574 } 1575 return tokenizedAddress; 1576 } 1577 1578 public static int getNotificationId(final String account, final Folder folder) { 1579 return 1 ^ account.hashCode() ^ folder.hashCode(); 1580 } 1581 1582 private static class NotificationKey { 1583 public final Account account; 1584 public final Folder folder; 1585 1586 public NotificationKey(Account account, Folder folder) { 1587 this.account = account; 1588 this.folder = folder; 1589 } 1590 1591 @Override 1592 public boolean equals(Object other) { 1593 if (!(other instanceof NotificationKey)) { 1594 return false; 1595 } 1596 NotificationKey key = (NotificationKey) other; 1597 return account.equals(key.account) && folder.equals(key.folder); 1598 } 1599 1600 @Override 1601 public String toString() { 1602 return account.toString() + " " + folder.name; 1603 } 1604 1605 @Override 1606 public int hashCode() { 1607 final int accountHashCode = account.hashCode(); 1608 final int folderHashCode = folder.hashCode(); 1609 return accountHashCode ^ folderHashCode; 1610 } 1611 } 1612 1613 /** 1614 * Contains the logic for converting the contents of one HtmlTree into 1615 * plaintext. 1616 */ 1617 public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter { 1618 // Strings for parsing html message bodies 1619 private static final String ELIDED_TEXT_ELEMENT_NAME = "div"; 1620 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class"; 1621 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text"; 1622 1623 private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE = 1624 new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE); 1625 1626 private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE = 1627 HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null); 1628 1629 private int mEndNodeElidedTextBlock = -1; 1630 1631 @Override 1632 public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) { 1633 // If we are in the middle of an elided text block, don't add this node 1634 if (nodeNum < mEndNodeElidedTextBlock) { 1635 return; 1636 } else if (nodeNum == mEndNodeElidedTextBlock) { 1637 super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum); 1638 return; 1639 } 1640 1641 // If this tag starts another elided text block, we want to remember the end 1642 if (n instanceof HtmlDocument.Tag) { 1643 boolean foundElidedTextTag = false; 1644 final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n; 1645 final HTML.Element htmlElement = htmlTag.getElement(); 1646 if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) { 1647 // Make sure that the class is what is expected 1648 final List<HtmlDocument.TagAttribute> attributes = 1649 htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE); 1650 for (HtmlDocument.TagAttribute attribute : attributes) { 1651 if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals( 1652 attribute.getValue())) { 1653 // Found an "elided-text" div. Remember information about this tag 1654 mEndNodeElidedTextBlock = endNum; 1655 foundElidedTextTag = true; 1656 break; 1657 } 1658 } 1659 } 1660 1661 if (foundElidedTextTag) { 1662 return; 1663 } 1664 } 1665 1666 super.addNode(n, nodeNum, endNum); 1667 } 1668 } 1669 1670 /** 1671 * During account setup in Email, we may not have an inbox yet, so the notification setting had 1672 * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the 1673 * {@link FolderPreferences} now. 1674 */ 1675 public static void moveNotificationSetting(final AccountPreferences accountPreferences, 1676 final FolderPreferences folderPreferences) { 1677 if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) { 1678 // If this setting has been changed some other way, don't overwrite it 1679 if (!folderPreferences.isNotificationsEnabledSet()) { 1680 final boolean notificationsEnabled = 1681 accountPreferences.getDefaultInboxNotificationsEnabled(); 1682 1683 folderPreferences.setNotificationsEnabled(notificationsEnabled); 1684 } 1685 1686 accountPreferences.clearDefaultInboxNotificationsEnabled(); 1687 } 1688 } 1689} 1690