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