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