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