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