NotificationUtils.java revision de2f3f40ab2403b89bebd219dcab4d561fd07387
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.Uri; 30import android.os.Looper; 31import android.provider.ContactsContract; 32import android.provider.ContactsContract.CommonDataKinds.Email; 33import android.support.v4.app.NotificationCompat; 34import android.support.v4.app.NotificationManagerCompat; 35import android.support.v4.text.BidiFormatter; 36import android.support.v4.util.ArrayMap; 37import android.text.SpannableString; 38import android.text.SpannableStringBuilder; 39import android.text.TextUtils; 40import android.text.style.CharacterStyle; 41import android.text.style.TextAppearanceSpan; 42import android.util.Pair; 43import android.util.SparseArray; 44 45import com.android.emailcommon.mail.Address; 46import com.android.mail.EmailAddress; 47import com.android.mail.MailIntentService; 48import com.android.mail.R; 49import com.android.mail.analytics.Analytics; 50import com.android.mail.browse.ConversationItemView; 51import com.android.mail.browse.MessageCursor; 52import com.android.mail.browse.SendersView; 53import com.android.mail.photo.ContactFetcher; 54import com.android.mail.photomanager.LetterTileProvider; 55import com.android.mail.preferences.AccountPreferences; 56import com.android.mail.preferences.FolderPreferences; 57import com.android.mail.preferences.MailPrefs; 58import com.android.mail.providers.Account; 59import com.android.mail.providers.Conversation; 60import com.android.mail.providers.Folder; 61import com.android.mail.providers.Message; 62import com.android.mail.providers.UIProvider; 63import com.android.mail.ui.ImageCanvas.Dimensions; 64import com.android.mail.utils.NotificationActionUtils.NotificationAction; 65import com.google.android.mail.common.html.parser.HTML; 66import com.google.android.mail.common.html.parser.HTML4; 67import com.google.android.mail.common.html.parser.HtmlDocument; 68import com.google.android.mail.common.html.parser.HtmlTree; 69import com.google.common.base.Objects; 70import com.google.common.collect.ImmutableList; 71import com.google.common.collect.Lists; 72import com.google.common.collect.Sets; 73import com.google.common.io.Closeables; 74 75import java.io.InputStream; 76import java.lang.ref.WeakReference; 77import java.util.ArrayList; 78import java.util.Arrays; 79import java.util.Collection; 80import java.util.HashMap; 81import java.util.HashSet; 82import java.util.List; 83import java.util.Map; 84import java.util.Set; 85import java.util.concurrent.ConcurrentHashMap; 86 87public class NotificationUtils { 88 public static final String LOG_TAG = "NotifUtils"; 89 90 public static final String EXTRA_UNREAD_COUNT = "unread-count"; 91 public static final String EXTRA_UNSEEN_COUNT = "unseen-count"; 92 public static final String EXTRA_GET_ATTENTION = "get-attention"; 93 private static final int PUBLIC_NOTIFICATIONS_VISIBLE_CHARS = 4; 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<Bitmap>(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 String notificationAccountDisplayName = account.getDisplayName(); 973 final String notificationAccountEmail = account.getEmailAddress(); 974 final boolean multipleUnseen = unseenCount > 1; 975 976 LogUtils.i(LOG_TAG, "Showing notification with unreadCount of %d and unseenCount of %d", 977 unreadCount, unseenCount); 978 979 String notificationTicker = null; 980 981 // Boolean indicating that this notification is for a non-inbox label. 982 final boolean isInbox = folder.folderUri.fullUri.equals(account.settings.defaultInbox); 983 984 // Notification label name for user label notifications. 985 final String notificationLabelName = isInbox ? null : folder.name; 986 987 if (multipleUnseen) { 988 // Build the string that describes the number of new messages 989 final String newMessagesString = createTitle(context, unseenCount); 990 991 // The ticker initially start as the new messages string. 992 notificationTicker = newMessagesString; 993 994 // The title of the notification is the new messages string 995 notificationBuilder.setContentTitle(newMessagesString); 996 997 // TODO(skennedy) Can we remove this check? 998 if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) { 999 // For a new-style notification 1000 final int maxNumDigestItems = context.getResources().getInteger( 1001 R.integer.max_num_notification_digest_items); 1002 1003 // The body of the notification is the account name, or the label name. 1004 notificationBuilder.setSubText( 1005 isInbox ? notificationAccountDisplayName : notificationLabelName); 1006 1007 final NotificationCompat.InboxStyle digest = 1008 new NotificationCompat.InboxStyle(notificationBuilder); 1009 1010 // Group by account and folder 1011 final String notificationGroupKey = createGroupKey(account, folder); 1012 // Track all senders to later tag them along with the digest notification 1013 final HashSet<String> sendersList = new HashSet<String>(); 1014 notificationBuilder.setGroup(notificationGroupKey).setGroupSummary(true); 1015 1016 ConfigResult firstResult = null; 1017 int numDigestItems = 0; 1018 do { 1019 final Conversation conversation = new Conversation(conversationCursor); 1020 1021 if (!conversation.read) { 1022 boolean multipleUnreadThread = false; 1023 // TODO(cwren) extract this pattern into a helper 1024 1025 Cursor cursor = null; 1026 MessageCursor messageCursor = null; 1027 try { 1028 final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon(); 1029 uriBuilder.appendQueryParameter( 1030 UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName); 1031 cursor = context.getContentResolver().query(uriBuilder.build(), 1032 UIProvider.MESSAGE_PROJECTION, null, null, null); 1033 messageCursor = new MessageCursor(cursor); 1034 1035 String from = ""; 1036 String fromAddress = ""; 1037 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) { 1038 final Message message = messageCursor.getMessage(); 1039 fromAddress = message.getFrom(); 1040 if (fromAddress == null) { 1041 fromAddress = ""; 1042 } 1043 from = getDisplayableSender(fromAddress); 1044 sendersList.add(getSenderAddress(fromAddress)); 1045 } 1046 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) { 1047 final Message message = messageCursor.getMessage(); 1048 if (!message.read && 1049 !fromAddress.contentEquals(message.getFrom())) { 1050 multipleUnreadThread = true; 1051 sendersList.add(getSenderAddress(message.getFrom())); 1052 } 1053 } 1054 final SpannableStringBuilder sendersBuilder; 1055 if (multipleUnreadThread) { 1056 final int sendersLength = 1057 res.getInteger(R.integer.swipe_senders_length); 1058 1059 sendersBuilder = getStyledSenders(context, conversationCursor, 1060 sendersLength, notificationAccountEmail); 1061 } else { 1062 sendersBuilder = 1063 new SpannableStringBuilder(getWrappedFromString(from)); 1064 } 1065 final CharSequence digestLine = getSingleMessageInboxLine(context, 1066 sendersBuilder.toString(), 1067 ConversationItemView.filterTag(context, conversation.subject), 1068 conversation.getSnippet()); 1069 digest.addLine(digestLine); 1070 numDigestItems++; 1071 1072 // Adding conversation notification for Wear. 1073 NotificationCompat.Builder conversationNotif = 1074 new NotificationCompat.Builder(context); 1075 conversationNotif.setCategory(NotificationCompat.CATEGORY_EMAIL); 1076 1077 conversationNotif.setSmallIcon( 1078 R.drawable.ic_notification_multiple_mail_24dp); 1079 1080 if (com.android.mail.utils.Utils.isRunningLOrLater()) { 1081 conversationNotif.setColor( 1082 context.getResources() 1083 .getColor(R.color.notification_icon_color)); 1084 } 1085 conversationNotif.setContentText(digestLine); 1086 Intent conversationNotificationIntent = createViewConversationIntent( 1087 context, account, folder, conversationCursor); 1088 PendingIntent conversationClickIntent = createClickPendingIntent( 1089 context, conversationNotificationIntent); 1090 conversationNotif.setContentIntent(conversationClickIntent); 1091 conversationNotif.setAutoCancel(true); 1092 1093 // Conversations are sorted in descending order, but notification sort 1094 // key is in ascending order. Invert the order key to get the right 1095 // order. Left pad 19 zeros because it's a long. 1096 String groupSortKey = String.format("%019d", 1097 (Long.MAX_VALUE - conversation.orderKey)); 1098 conversationNotif.setGroup(notificationGroupKey); 1099 conversationNotif.setSortKey(groupSortKey); 1100 conversationNotif.setWhen(conversation.dateMs); 1101 1102 int conversationNotificationId = getNotificationId( 1103 summaryNotificationId, conversation.hashCode()); 1104 1105 final NotificationCompat.WearableExtender conversationWearExtender = 1106 new NotificationCompat.WearableExtender(); 1107 final ConfigResult result = 1108 configureNotifForOneConversation(context, account, 1109 folderPreferences, conversationNotif, conversationWearExtender, 1110 conversationCursor, notificationIntent, folder, when, res, 1111 notificationAccountDisplayName, notificationAccountEmail, 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(context, notificationBuilder, sendersList, 1134 account.getAccountManagerAccount().name, contactFetcher); 1135 1136 if (firstResult != null && firstResult.contactIconInfo != null) { 1137 wearableExtender.setBackground(firstResult.contactIconInfo.wearableBg); 1138 } else { 1139 LogUtils.w(LOG_TAG, "First contact icon is null!"); 1140 wearableExtender.setBackground(getDefaultWearableBg(context)); 1141 } 1142 } else { 1143 // The body of the notification is the account name, or the label name. 1144 notificationBuilder.setContentText( 1145 isInbox ? notificationAccountDisplayName : notificationLabelName); 1146 } 1147 } else { 1148 // For notifications for a single new conversation, we want to get the information 1149 // from the conversation 1150 1151 // Move the cursor to the most recent unread conversation 1152 seekToLatestUnreadConversation(conversationCursor); 1153 1154 final ConfigResult result = configureNotifForOneConversation(context, account, 1155 folderPreferences, notificationBuilder, wearableExtender, conversationCursor, 1156 notificationIntent, folder, when, res, notificationAccountDisplayName, 1157 notificationAccountEmail, isInbox, notificationLabelName, 1158 summaryNotificationId, contactFetcher); 1159 notificationTicker = result.notificationTicker; 1160 1161 if (result.contactIconInfo != null) { 1162 wearableExtender.setBackground(result.contactIconInfo.wearableBg); 1163 } else { 1164 wearableExtender.setBackground(getDefaultWearableBg(context)); 1165 } 1166 } 1167 1168 // Build the notification ticker 1169 if (notificationLabelName != null && notificationTicker != null) { 1170 // This is a per label notification, format the ticker with that information 1171 notificationTicker = res.getString(R.string.label_notification_ticker, 1172 notificationLabelName, notificationTicker); 1173 } 1174 1175 if (notificationTicker != null) { 1176 // If we didn't generate a notification ticker, it will default to account name 1177 notificationBuilder.setTicker(notificationTicker); 1178 } 1179 1180 // Set the number in the notification 1181 if (unreadCount > 1) { 1182 notificationBuilder.setNumber(unreadCount); 1183 } 1184 1185 notificationBuilder.setContentIntent(clickIntent); 1186 } 1187 1188 /** 1189 * Configure the notification for one conversation. When there are multiple conversations, 1190 * this method is used to configure bundled notification for Android Wear. 1191 */ 1192 private static ConfigResult configureNotifForOneConversation(Context context, 1193 Account account, FolderPreferences folderPreferences, 1194 NotificationCompat.Builder notificationBuilder, 1195 NotificationCompat.WearableExtender wearExtender, Cursor conversationCursor, 1196 Intent notificationIntent, Folder folder, long when, Resources res, 1197 String notificationAccountDisplayName, String notificationAccountEmail, 1198 boolean isInbox, String notificationLabelName, int notificationId, 1199 final ContactFetcher contactFetcher) { 1200 1201 final ConfigResult result = new ConfigResult(); 1202 1203 final Conversation conversation = new Conversation(conversationCursor); 1204 1205 // Set of all unique senders for unseen messages 1206 final HashSet<String> sendersList = new HashSet<String>(); 1207 Cursor cursor = null; 1208 MessageCursor messageCursor = null; 1209 boolean multipleUnseenThread = false; 1210 String from = null; 1211 try { 1212 final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter( 1213 UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build(); 1214 cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION, 1215 null, null, null); 1216 messageCursor = new MessageCursor(cursor); 1217 // Use the information from the last sender in the conversation that triggered 1218 // this notification. 1219 1220 String fromAddress = ""; 1221 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) { 1222 final Message message = messageCursor.getMessage(); 1223 fromAddress = message.getFrom(); 1224 if (fromAddress == null) { 1225 // No sender. Go back to default value. 1226 LogUtils.e(LOG_TAG, "No sender found for message: %d", message.getId()); 1227 fromAddress = ""; 1228 } 1229 from = getDisplayableSender(fromAddress); 1230 result.contactIconInfo = getContactIcon( 1231 context, account.getAccountManagerAccount().name, from, 1232 getSenderAddress(fromAddress), folder, contactFetcher); 1233 sendersList.add(getSenderAddress(fromAddress)); 1234 notificationBuilder.setLargeIcon(result.contactIconInfo.icon); 1235 } 1236 1237 // Assume that the last message in this conversation is unread 1238 int firstUnseenMessagePos = messageCursor.getPosition(); 1239 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) { 1240 final Message message = messageCursor.getMessage(); 1241 final boolean unseen = !message.seen; 1242 if (unseen) { 1243 firstUnseenMessagePos = messageCursor.getPosition(); 1244 sendersList.add(getSenderAddress(message.getFrom())); 1245 if (!multipleUnseenThread 1246 && !fromAddress.contentEquals(message.getFrom())) { 1247 multipleUnseenThread = true; 1248 } 1249 } 1250 } 1251 1252 final String subject = ConversationItemView.filterTag(context, conversation.subject); 1253 1254 // TODO(skennedy) Can we remove this check? 1255 if (Utils.isRunningJellybeanOrLater()) { 1256 // For a new-style notification 1257 1258 if (multipleUnseenThread) { 1259 // The title of a single conversation is the list of senders. 1260 int sendersLength = res.getInteger(R.integer.swipe_senders_length); 1261 1262 final SpannableStringBuilder sendersBuilder = getStyledSenders( 1263 context, conversationCursor, sendersLength, 1264 notificationAccountEmail); 1265 1266 notificationBuilder.setContentTitle(sendersBuilder); 1267 // For a single new conversation, the ticker is based on the sender's name. 1268 result.notificationTicker = sendersBuilder.toString(); 1269 } else { 1270 from = getWrappedFromString(from); 1271 // The title of a single message the sender. 1272 notificationBuilder.setContentTitle(from); 1273 // For a single new conversation, the ticker is based on the sender's name. 1274 result.notificationTicker = from; 1275 } 1276 1277 // The notification content will be the subject of the conversation. 1278 notificationBuilder.setContentText(getSingleMessageLittleText(context, subject)); 1279 1280 // The notification subtext will be the subject of the conversation for inbox 1281 // notifications, or will based on the the label name for user label 1282 // notifications. 1283 notificationBuilder.setSubText(isInbox ? 1284 notificationAccountDisplayName : notificationLabelName); 1285 1286 final NotificationCompat.BigTextStyle bigText = 1287 new NotificationCompat.BigTextStyle(notificationBuilder); 1288 1289 // Seek the message cursor to the first unread message 1290 final Message message; 1291 if (messageCursor.moveToPosition(firstUnseenMessagePos)) { 1292 message = messageCursor.getMessage(); 1293 bigText.bigText(getSingleMessageBigText(context, subject, message)); 1294 } else { 1295 LogUtils.e(LOG_TAG, "Failed to load message"); 1296 message = null; 1297 } 1298 1299 if (message != null) { 1300 final Set<String> notificationActions = 1301 folderPreferences.getNotificationActions(account); 1302 1303 NotificationActionUtils.addNotificationActions(context, notificationIntent, 1304 notificationBuilder, wearExtender, account, conversation, message, 1305 folder, notificationId, when, notificationActions); 1306 } 1307 } else { 1308 // For an old-style notification 1309 1310 // The title of a single conversation notification is built from both the sender 1311 // and subject of the new message. 1312 notificationBuilder.setContentTitle( 1313 getSingleMessageNotificationTitle(context, from, subject)); 1314 1315 // The notification content will be the subject of the conversation for inbox 1316 // notifications, or will based on the the label name for user label 1317 // notifications. 1318 notificationBuilder.setContentText( 1319 isInbox ? notificationAccountDisplayName : notificationLabelName); 1320 1321 // For a single new conversation, the ticker is based on the sender's name. 1322 result.notificationTicker = from; 1323 } 1324 1325 tagNotificationsWithPeople(context, notificationBuilder, sendersList, 1326 account.getAccountManagerAccount().name, contactFetcher); 1327 } finally { 1328 if (messageCursor != null) { 1329 messageCursor.close(); 1330 } 1331 if (cursor != null) { 1332 cursor.close(); 1333 } 1334 } 1335 return result; 1336 } 1337 1338 /** 1339 * Iterates through all senders, retrieves contact lookup Uris for each sender, and tags the 1340 * given notification with these Uris 1341 * @param context 1342 * @param notificationBuilder 1343 * @param sendersList List of unique senders to be tagged with the conversation 1344 * @param accountName 1345 * @param contactFetcher Implementation of ContactLookupUriFetcher (null by default) 1346 */ 1347 private static void tagNotificationsWithPeople(Context context, 1348 NotificationCompat.Builder notificationBuilder, HashSet<String> sendersList, 1349 String accountName, ContactFetcher contactFetcher) { 1350 // If there is a ContactLookupUriFetcher, go through all unique senders 1351 // in the combined notification and add each one as a person. 1352 if (contactFetcher != null) { 1353 for (final String sender : sendersList) { 1354 final Uri contactLookupUri = 1355 contactFetcher.getContactLookupUri(context, 1356 accountName, sender); 1357 1358 if (contactLookupUri != null) { 1359 notificationBuilder.addPerson(contactLookupUri.toString()); 1360 } 1361 } 1362 1363 // If implementation for the fetcher is not provided, rely on the ContentResolver 1364 // to query for contacts and tag the notification 1365 } else { 1366 findAndTagContacts(context, sendersList, notificationBuilder); 1367 } 1368 } 1369 1370 /** 1371 * Queries for contact id and lookup key to tag the notification with Person objects 1372 * based on the addresses given. 1373 * @param context 1374 * @param addresses set of addresses to tag the single notification with 1375 * @param notificationBuilder 1376 */ 1377 private static void findAndTagContacts(Context context, 1378 HashSet<String> addresses, NotificationCompat.Builder notificationBuilder) { 1379 final ArrayList<String> whereArgs = new ArrayList<String>(addresses); 1380 final StringBuilder whereBuilder = new StringBuilder(); 1381 final String[] questionMarks = new String[addresses.size()]; 1382 1383 Arrays.fill(questionMarks, "?"); 1384 whereBuilder.append(Email.DATA1 + " IN ("). 1385 append(TextUtils.join(",", questionMarks)). 1386 append(")"); 1387 1388 final ContentResolver resolver = context.getContentResolver(); 1389 final Cursor c = resolver.query(Email.CONTENT_URI, 1390 new String[] {Email.CONTACT_ID, ContactsContract.Contacts.LOOKUP_KEY}, 1391 whereBuilder.toString(), 1392 whereArgs.toArray(new String[0]), null); 1393 1394 if (c == null) { 1395 // No query results - no contacts to tag 1396 return; 1397 } 1398 1399 final int contactIdCol = c.getColumnIndex(Email.CONTACT_ID); 1400 final int lookupKeyCol = c.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY); 1401 1402 try { 1403 while (c.moveToNext()) { 1404 c.getString(lookupKeyCol); 1405 1406 // Get lookup uri based on id and lookup key 1407 final Uri contactLookupUri = ContactsContract.Contacts.getLookupUri( 1408 c.getLong(contactIdCol), c.getString(lookupKeyCol)); 1409 1410 // Add Person if uri is found 1411 if (contactLookupUri != null) { 1412 notificationBuilder.addPerson(contactLookupUri.toString()); 1413 } 1414 } 1415 } finally { 1416 c.close(); 1417 } 1418 } 1419 1420 private static String getWrappedFromString(String from) { 1421 if (from == null) { 1422 LogUtils.e(LOG_TAG, "null from string in getWrappedFromString"); 1423 from = ""; 1424 } 1425 from = sBidiFormatter.unicodeWrap(from); 1426 return from; 1427 } 1428 1429 private static SpannableStringBuilder getStyledSenders(final Context context, 1430 final Cursor conversationCursor, final int maxLength, final String account) { 1431 final Conversation conversation = new Conversation(conversationCursor); 1432 final com.android.mail.providers.ConversationInfo conversationInfo = 1433 conversation.conversationInfo; 1434 final ArrayList<SpannableString> senders = new ArrayList<SpannableString>(); 1435 if (sNotificationUnreadStyleSpan == null) { 1436 sNotificationUnreadStyleSpan = new TextAppearanceSpan( 1437 context, R.style.NotificationSendersUnreadTextAppearance); 1438 sNotificationReadStyleSpan = 1439 new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance); 1440 } 1441 SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account, 1442 sNotificationUnreadStyleSpan, sNotificationReadStyleSpan, 1443 false /* showToHeader */, false /* resourceCachingRequired */); 1444 1445 return ellipsizeStyledSenders(context, senders); 1446 } 1447 1448 private static String sSendersSplitToken = null; 1449 private static String sElidedPaddingToken = null; 1450 1451 private static SpannableStringBuilder ellipsizeStyledSenders(final Context context, 1452 ArrayList<SpannableString> styledSenders) { 1453 if (sSendersSplitToken == null) { 1454 sSendersSplitToken = context.getString(R.string.senders_split_token); 1455 sElidedPaddingToken = context.getString(R.string.elided_padding_token); 1456 } 1457 1458 SpannableStringBuilder builder = new SpannableStringBuilder(); 1459 SpannableString prevSender = null; 1460 for (SpannableString sender : styledSenders) { 1461 if (sender == null) { 1462 LogUtils.e(LOG_TAG, "null sender iterating over styledSenders"); 1463 continue; 1464 } 1465 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 1466 if (SendersView.sElidedString.equals(sender.toString())) { 1467 prevSender = sender; 1468 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 1469 } else if (builder.length() > 0 1470 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 1471 .toString()))) { 1472 prevSender = sender; 1473 sender = copyStyles(spans, sSendersSplitToken + sender); 1474 } else { 1475 prevSender = sender; 1476 } 1477 builder.append(sender); 1478 } 1479 return builder; 1480 } 1481 1482 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 1483 SpannableString s = new SpannableString(newText); 1484 if (spans != null && spans.length > 0) { 1485 s.setSpan(spans[0], 0, s.length(), 0); 1486 } 1487 return s; 1488 } 1489 1490 /** 1491 * Seeks the cursor to the position of the most recent unread conversation. If no unread 1492 * conversation is found, the position of the cursor will be restored, and false will be 1493 * returned. 1494 */ 1495 private static boolean seekToLatestUnreadConversation(final Cursor cursor) { 1496 final int initialPosition = cursor.getPosition(); 1497 do { 1498 final Conversation conversation = new Conversation(cursor); 1499 if (!conversation.read) { 1500 return true; 1501 } 1502 } while (cursor.moveToNext()); 1503 1504 // Didn't find an unread conversation, reset the position. 1505 cursor.moveToPosition(initialPosition); 1506 return false; 1507 } 1508 1509 /** 1510 * Sets the bigtext for a notification for a single new conversation 1511 * 1512 * @param context 1513 * @param senders Sender of the new message that triggered the notification. 1514 * @param subject Subject of the new message that triggered the notification 1515 * @param snippet Snippet of the new message that triggered the notification 1516 * @return a {@link CharSequence} suitable for use in 1517 * {@link android.support.v4.app.NotificationCompat.BigTextStyle} 1518 */ 1519 private static CharSequence getSingleMessageInboxLine(Context context, 1520 String senders, String subject, String snippet) { 1521 // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText 1522 1523 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet; 1524 1525 final TextAppearanceSpan notificationPrimarySpan = 1526 new TextAppearanceSpan(context, R.style.NotificationPrimaryText); 1527 1528 if (TextUtils.isEmpty(senders)) { 1529 // If the senders are empty, just use the subject/snippet. 1530 return subjectSnippet; 1531 } else if (TextUtils.isEmpty(subjectSnippet)) { 1532 // If the subject/snippet is empty, just use the senders. 1533 final SpannableString spannableString = new SpannableString(senders); 1534 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0); 1535 1536 return spannableString; 1537 } else { 1538 final String formatString = context.getResources().getString( 1539 R.string.multiple_new_message_notification_item); 1540 final TextAppearanceSpan notificationSecondarySpan = 1541 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 1542 1543 // senders is already individually unicode wrapped so it does not need to be done here 1544 final String instantiatedString = String.format(formatString, 1545 senders, 1546 sBidiFormatter.unicodeWrap(subjectSnippet)); 1547 1548 final SpannableString spannableString = new SpannableString(instantiatedString); 1549 1550 final boolean isOrderReversed = formatString.indexOf("%2$s") < 1551 formatString.indexOf("%1$s"); 1552 final int primaryOffset = 1553 (isOrderReversed ? instantiatedString.lastIndexOf(senders) : 1554 instantiatedString.indexOf(senders)); 1555 final int secondaryOffset = 1556 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) : 1557 instantiatedString.indexOf(subjectSnippet)); 1558 spannableString.setSpan(notificationPrimarySpan, 1559 primaryOffset, primaryOffset + senders.length(), 0); 1560 spannableString.setSpan(notificationSecondarySpan, 1561 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0); 1562 return spannableString; 1563 } 1564 } 1565 1566 /** 1567 * Sets the bigtext for a notification for a single new conversation 1568 * @param context 1569 * @param subject Subject of the new message that triggered the notification 1570 * @return a {@link CharSequence} suitable for use in 1571 * {@link NotificationCompat.Builder#setContentText} 1572 */ 1573 private static CharSequence getSingleMessageLittleText(Context context, String subject) { 1574 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 1575 context, R.style.NotificationPrimaryText); 1576 1577 final SpannableString spannableString = new SpannableString(subject); 1578 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 1579 1580 return spannableString; 1581 } 1582 1583 /** 1584 * Sets the bigtext for a notification for a single new conversation 1585 * 1586 * @param context 1587 * @param subject Subject of the new message that triggered the notification 1588 * @param message the {@link Message} to be displayed. 1589 * @return a {@link CharSequence} suitable for use in 1590 * {@link android.support.v4.app.NotificationCompat.BigTextStyle} 1591 */ 1592 private static CharSequence getSingleMessageBigText(Context context, String subject, 1593 final Message message) { 1594 1595 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 1596 context, R.style.NotificationPrimaryText); 1597 1598 final String snippet = getMessageBodyWithoutElidedText(message); 1599 1600 // Change multiple newlines (with potential white space between), into a single new line 1601 final String collapsedSnippet = 1602 !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : ""; 1603 1604 if (TextUtils.isEmpty(subject)) { 1605 // If the subject is empty, just use the snippet. 1606 return snippet; 1607 } else if (TextUtils.isEmpty(collapsedSnippet)) { 1608 // If the snippet is empty, just use the subject. 1609 final SpannableString spannableString = new SpannableString(subject); 1610 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 1611 1612 return spannableString; 1613 } else { 1614 final String notificationBigTextFormat = context.getResources().getString( 1615 R.string.single_new_message_notification_big_text); 1616 1617 // Localizers may change the order of the parameters, look at how the format 1618 // string is structured. 1619 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") > 1620 notificationBigTextFormat.indexOf("%1$s"); 1621 final String bigText = 1622 String.format(notificationBigTextFormat, subject, collapsedSnippet); 1623 final SpannableString spannableString = new SpannableString(bigText); 1624 1625 final int subjectOffset = 1626 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject)); 1627 spannableString.setSpan(notificationSubjectSpan, 1628 subjectOffset, subjectOffset + subject.length(), 0); 1629 1630 return spannableString; 1631 } 1632 } 1633 1634 /** 1635 * Gets the title for a notification for a single new conversation 1636 * @param context 1637 * @param sender Sender of the new message that triggered the notification. 1638 * @param subject Subject of the new message that triggered the notification 1639 * @return a {@link CharSequence} suitable for use as a {@link Notification} title. 1640 */ 1641 private static CharSequence getSingleMessageNotificationTitle(Context context, 1642 String sender, String subject) { 1643 1644 if (TextUtils.isEmpty(subject)) { 1645 // If the subject is empty, just set the title to the sender's information. 1646 return sender; 1647 } else { 1648 final String notificationTitleFormat = context.getResources().getString( 1649 R.string.single_new_message_notification_title); 1650 1651 // Localizers may change the order of the parameters, look at how the format 1652 // string is structured. 1653 final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") > 1654 notificationTitleFormat.indexOf("%1$s"); 1655 final String titleString = String.format(notificationTitleFormat, sender, subject); 1656 1657 // Format the string so the subject is using the secondaryText style 1658 final SpannableString titleSpannable = new SpannableString(titleString); 1659 1660 // Find the offset of the subject. 1661 final int subjectOffset = 1662 isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject); 1663 final TextAppearanceSpan notificationSubjectSpan = 1664 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 1665 titleSpannable.setSpan(notificationSubjectSpan, 1666 subjectOffset, subjectOffset + subject.length(), 0); 1667 return titleSpannable; 1668 } 1669 } 1670 1671 /** 1672 * Clears the notifications for the specified account/folder. 1673 */ 1674 public static void clearFolderNotification(Context context, Account account, Folder folder, 1675 final boolean markSeen) { 1676 LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.getEmailAddress(), 1677 folder.name); 1678 final NotificationMap notificationMap = getNotificationMap(context); 1679 final NotificationKey key = new NotificationKey(account, folder); 1680 notificationMap.remove(key); 1681 notificationMap.saveNotificationMap(context); 1682 1683 final NotificationManagerCompat notificationManager = 1684 NotificationManagerCompat.from(context); 1685 notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder)); 1686 1687 cancelConversationNotifications(key, notificationManager); 1688 1689 if (markSeen) { 1690 markSeen(context, folder); 1691 } 1692 } 1693 1694 /** 1695 * Use content resolver to update a conversation. Should not be called from a main thread. 1696 */ 1697 public static void markConversationAsReadAndSeen(Context context, Uri conversationUri) { 1698 LogUtils.v(LOG_TAG, "markConversationAsReadAndSeen=%s", conversationUri); 1699 1700 final ContentValues values = new ContentValues(2); 1701 values.put(UIProvider.ConversationColumns.SEEN, Boolean.TRUE); 1702 values.put(UIProvider.ConversationColumns.READ, Boolean.TRUE); 1703 context.getContentResolver().update(conversationUri, values, null, null); 1704 } 1705 1706 /** 1707 * Clears all notifications for the specified account. 1708 */ 1709 public static void clearAccountNotifications(final Context context, 1710 final android.accounts.Account account) { 1711 LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account); 1712 final NotificationMap notificationMap = getNotificationMap(context); 1713 1714 // Find all NotificationKeys for this account 1715 final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder(); 1716 1717 for (final NotificationKey key : notificationMap.keySet()) { 1718 if (account.equals(key.account.getAccountManagerAccount())) { 1719 keyBuilder.add(key); 1720 } 1721 } 1722 1723 final List<NotificationKey> notificationKeys = keyBuilder.build(); 1724 1725 final NotificationManagerCompat notificationManager = 1726 NotificationManagerCompat.from(context); 1727 1728 for (final NotificationKey notificationKey : notificationKeys) { 1729 final Folder folder = notificationKey.folder; 1730 notificationManager.cancel(getNotificationId(account, folder)); 1731 notificationMap.remove(notificationKey); 1732 1733 cancelConversationNotifications(notificationKey, notificationManager); 1734 } 1735 1736 notificationMap.saveNotificationMap(context); 1737 } 1738 1739 private static void cancelConversationNotifications(NotificationKey key, 1740 NotificationManagerCompat nm) { 1741 final Set<Integer> conversationNotifications = sConversationNotificationMap.get(key); 1742 if (conversationNotifications != null) { 1743 for (Integer conversationNotification : conversationNotifications) { 1744 nm.cancel(conversationNotification); 1745 } 1746 sConversationNotificationMap.remove(key); 1747 } 1748 } 1749 1750 private static ContactIconInfo getContactIcon(final Context context, String accountName, 1751 final String displayName, final String senderAddress, final Folder folder, 1752 final ContactFetcher contactFetcher) { 1753 if (Looper.myLooper() == Looper.getMainLooper()) { 1754 throw new IllegalStateException( 1755 "getContactIcon should not be called on the main thread."); 1756 } 1757 1758 final ContactIconInfo contactIconInfo; 1759 if (TextUtils.isEmpty(senderAddress)) { 1760 contactIconInfo = new ContactIconInfo(); 1761 } else { 1762 // Get the ideal size for this icon. 1763 final Resources res = context.getResources(); 1764 final int idealIconHeight = 1765 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); 1766 final int idealIconWidth = 1767 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); 1768 final int idealWearableBgWidth = 1769 res.getDimensionPixelSize(R.dimen.wearable_background_width); 1770 final int idealWearableBgHeight = 1771 res.getDimensionPixelSize(R.dimen.wearable_background_height); 1772 1773 if (contactFetcher != null) { 1774 contactIconInfo = contactFetcher.getContactPhoto(context, accountName, 1775 senderAddress, idealIconWidth, idealIconHeight, idealWearableBgWidth, 1776 idealWearableBgHeight); 1777 } else { 1778 contactIconInfo = getContactInfo(context, senderAddress, idealIconWidth, 1779 idealIconHeight, idealWearableBgWidth, idealWearableBgHeight); 1780 } 1781 1782 if (contactIconInfo.icon == null) { 1783 // Make a colorful tile! 1784 final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight, 1785 Dimensions.SCALE_ONE); 1786 1787 contactIconInfo.icon = new LetterTileProvider(context.getResources()) 1788 .getLetterTile(dimensions, displayName, senderAddress); 1789 } 1790 1791 // Only turn the square photo/letter tile into a circle for L and later 1792 if (Utils.isRunningLOrLater()) { 1793 contactIconInfo.icon = BitmapUtil.frameBitmapInCircle(contactIconInfo.icon); 1794 } 1795 } 1796 1797 if (contactIconInfo.icon == null) { 1798 // Use anonymous icon due to lack of sender 1799 contactIconInfo.icon = getIcon(context, 1800 R.drawable.ic_notification_anonymous_avatar_32dp); 1801 } 1802 1803 if (contactIconInfo.wearableBg == null) { 1804 contactIconInfo.wearableBg = getDefaultWearableBg(context); 1805 } 1806 1807 return contactIconInfo; 1808 } 1809 1810 private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) { 1811 ArrayList<String> whereArgs = new ArrayList<String>(); 1812 StringBuilder whereBuilder = new StringBuilder(); 1813 String[] questionMarks = new String[addresses.size()]; 1814 1815 whereArgs.addAll(addresses); 1816 Arrays.fill(questionMarks, "?"); 1817 whereBuilder.append(Email.DATA1 + " IN ("). 1818 append(TextUtils.join(",", questionMarks)). 1819 append(")"); 1820 1821 ContentResolver resolver = context.getContentResolver(); 1822 Cursor c = resolver.query(Email.CONTENT_URI, 1823 new String[] {Email.CONTACT_ID}, whereBuilder.toString(), 1824 whereArgs.toArray(new String[0]), null); 1825 1826 ArrayList<Long> contactIds = new ArrayList<Long>(); 1827 if (c == null) { 1828 return contactIds; 1829 } 1830 try { 1831 while (c.moveToNext()) { 1832 contactIds.add(c.getLong(0)); 1833 } 1834 } finally { 1835 c.close(); 1836 } 1837 return contactIds; 1838 } 1839 1840 public static ContactIconInfo getContactInfo( 1841 final Context context, final String senderAddress, 1842 final int idealIconWidth, final int idealIconHeight, 1843 final int idealWearableBgWidth, final int idealWearableBgHeight) { 1844 final ContactIconInfo contactIconInfo = new ContactIconInfo(); 1845 final List<Long> contactIds = findContacts(context, Arrays.asList( 1846 new String[]{senderAddress})); 1847 1848 if (contactIds != null) { 1849 for (final long id : contactIds) { 1850 final Uri contactUri = ContentUris.withAppendedId( 1851 ContactsContract.Contacts.CONTENT_URI, id); 1852 final InputStream inputStream = 1853 ContactsContract.Contacts.openContactPhotoInputStream( 1854 context.getContentResolver(), contactUri, true /*preferHighres*/); 1855 1856 if (inputStream != null) { 1857 try { 1858 final Bitmap source = BitmapFactory.decodeStream(inputStream); 1859 if (source != null) { 1860 // We should scale this image to fit the intended size 1861 contactIconInfo.icon = Bitmap.createScaledBitmap(source, idealIconWidth, 1862 idealIconHeight, true); 1863 1864 contactIconInfo.wearableBg = Bitmap.createScaledBitmap(source, 1865 idealWearableBgWidth, idealWearableBgHeight, true); 1866 } 1867 1868 if (contactIconInfo.icon != null) { 1869 break; 1870 } 1871 } finally { 1872 Closeables.closeQuietly(inputStream); 1873 } 1874 } 1875 } 1876 } 1877 1878 return contactIconInfo; 1879 } 1880 1881 private static String getMessageBodyWithoutElidedText(final Message message) { 1882 return getMessageBodyWithoutElidedText(message.getBodyAsHtml()); 1883 } 1884 1885 public static String getMessageBodyWithoutElidedText(String html) { 1886 if (TextUtils.isEmpty(html)) { 1887 return ""; 1888 } 1889 // Get the html "tree" for this message body 1890 final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html); 1891 htmlTree.setConverterFactory(MESSAGE_CONVERTER_FACTORY); 1892 1893 return htmlTree.getPlainText(); 1894 } 1895 1896 public static void markSeen(final Context context, final Folder folder) { 1897 final Uri uri = folder.folderUri.fullUri; 1898 1899 final ContentValues values = new ContentValues(1); 1900 values.put(UIProvider.ConversationColumns.SEEN, 1); 1901 1902 context.getContentResolver().update(uri, values, null, null); 1903 } 1904 1905 /** 1906 * Returns a displayable string representing 1907 * the message sender. It has a preference toward showing the name, 1908 * but will fall back to the address if that is all that is available. 1909 */ 1910 private static String getDisplayableSender(String sender) { 1911 final EmailAddress address = EmailAddress.getEmailAddress(sender); 1912 1913 String displayableSender = address.getName(); 1914 1915 if (!TextUtils.isEmpty(displayableSender)) { 1916 return Address.decodeAddressPersonal(displayableSender); 1917 } 1918 1919 // If that fails, default to the sender address. 1920 displayableSender = address.getAddress(); 1921 1922 // If we were unable to tokenize a name or address, 1923 // just use whatever was in the sender. 1924 if (TextUtils.isEmpty(displayableSender)) { 1925 displayableSender = sender; 1926 } 1927 return displayableSender; 1928 } 1929 1930 /** 1931 * Returns only the address portion of a message sender. 1932 */ 1933 private static String getSenderAddress(String sender) { 1934 final EmailAddress address = EmailAddress.getEmailAddress(sender); 1935 1936 String tokenizedAddress = address.getAddress(); 1937 1938 // If we were unable to tokenize a name or address, 1939 // just use whatever was in the sender. 1940 if (TextUtils.isEmpty(tokenizedAddress)) { 1941 tokenizedAddress = sender; 1942 } 1943 return tokenizedAddress; 1944 } 1945 1946 public static int getNotificationId(final android.accounts.Account account, 1947 final Folder folder) { 1948 return 1 ^ account.hashCode() ^ folder.hashCode(); 1949 } 1950 1951 private static int getNotificationId(int summaryNotificationId, int conversationHashCode) { 1952 return summaryNotificationId ^ conversationHashCode; 1953 } 1954 1955 private static class NotificationKey { 1956 public final Account account; 1957 public final Folder folder; 1958 1959 public NotificationKey(Account account, Folder folder) { 1960 this.account = account; 1961 this.folder = folder; 1962 } 1963 1964 @Override 1965 public boolean equals(Object other) { 1966 if (!(other instanceof NotificationKey)) { 1967 return false; 1968 } 1969 NotificationKey key = (NotificationKey) other; 1970 return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount()) 1971 && folder.equals(key.folder); 1972 } 1973 1974 @Override 1975 public String toString() { 1976 return account.getDisplayName() + " " + folder.name; 1977 } 1978 1979 @Override 1980 public int hashCode() { 1981 final int accountHashCode = account.getAccountManagerAccount().hashCode(); 1982 final int folderHashCode = folder.hashCode(); 1983 return accountHashCode ^ folderHashCode; 1984 } 1985 } 1986 1987 /** 1988 * Contains the logic for converting the contents of one HtmlTree into 1989 * plaintext. 1990 */ 1991 public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter { 1992 // Strings for parsing html message bodies 1993 private static final String ELIDED_TEXT_ELEMENT_NAME = "div"; 1994 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class"; 1995 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text"; 1996 1997 private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE = 1998 new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE); 1999 2000 private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE = 2001 HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null); 2002 2003 private int mEndNodeElidedTextBlock = -1; 2004 2005 @Override 2006 public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) { 2007 // If we are in the middle of an elided text block, don't add this node 2008 if (nodeNum < mEndNodeElidedTextBlock) { 2009 return; 2010 } else if (nodeNum == mEndNodeElidedTextBlock) { 2011 super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum); 2012 return; 2013 } 2014 2015 // If this tag starts another elided text block, we want to remember the end 2016 if (n instanceof HtmlDocument.Tag) { 2017 boolean foundElidedTextTag = false; 2018 final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n; 2019 final HTML.Element htmlElement = htmlTag.getElement(); 2020 if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) { 2021 // Make sure that the class is what is expected 2022 final List<HtmlDocument.TagAttribute> attributes = 2023 htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE); 2024 for (HtmlDocument.TagAttribute attribute : attributes) { 2025 if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals( 2026 attribute.getValue())) { 2027 // Found an "elided-text" div. Remember information about this tag 2028 mEndNodeElidedTextBlock = endNum; 2029 foundElidedTextTag = true; 2030 break; 2031 } 2032 } 2033 } 2034 2035 if (foundElidedTextTag) { 2036 return; 2037 } 2038 } 2039 2040 super.addNode(n, nodeNum, endNum); 2041 } 2042 } 2043 2044 /** 2045 * During account setup in Email, we may not have an inbox yet, so the notification setting had 2046 * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the 2047 * {@link FolderPreferences} now. 2048 */ 2049 public static void moveNotificationSetting(final AccountPreferences accountPreferences, 2050 final FolderPreferences folderPreferences) { 2051 if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) { 2052 // If this setting has been changed some other way, don't overwrite it 2053 if (!folderPreferences.isNotificationsEnabledSet()) { 2054 final boolean notificationsEnabled = 2055 accountPreferences.getDefaultInboxNotificationsEnabled(); 2056 2057 folderPreferences.setNotificationsEnabled(notificationsEnabled); 2058 } 2059 2060 accountPreferences.clearDefaultInboxNotificationsEnabled(); 2061 } 2062 } 2063 2064 private static class NotificationBuilders { 2065 public final NotificationCompat.Builder notifBuilder; 2066 public final NotificationCompat.WearableExtender wearableNotifBuilder; 2067 2068 private NotificationBuilders(NotificationCompat.Builder notifBuilder, 2069 NotificationCompat.WearableExtender wearableNotifBuilder) { 2070 this.notifBuilder = notifBuilder; 2071 this.wearableNotifBuilder = wearableNotifBuilder; 2072 } 2073 2074 public static NotificationBuilders of(NotificationCompat.Builder notifBuilder, 2075 NotificationCompat.WearableExtender wearableNotifBuilder) { 2076 return new NotificationBuilders(notifBuilder, wearableNotifBuilder); 2077 } 2078 } 2079 2080 private static class ConfigResult { 2081 public String notificationTicker; 2082 public ContactIconInfo contactIconInfo; 2083 } 2084 2085 public static class ContactIconInfo { 2086 public Bitmap icon; 2087 public Bitmap wearableBg; 2088 } 2089} 2090