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