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