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