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.NotificationManager; 20 import android.app.PendingIntent; 21 import android.content.ContentResolver; 22 import android.content.ContentUris; 23 import android.content.ContentValues; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.res.Resources; 27 import android.database.Cursor; 28 import android.graphics.Bitmap; 29 import android.graphics.BitmapFactory; 30 import android.net.Uri; 31 import android.provider.ContactsContract; 32 import android.provider.ContactsContract.CommonDataKinds.Email; 33 import android.provider.ContactsContract.Contacts.Photo; 34 import android.support.v4.app.NotificationCompat; 35 import android.support.v4.text.BidiFormatter; 36 import android.text.SpannableString; 37 import android.text.SpannableStringBuilder; 38 import android.text.TextUtils; 39 import android.text.style.CharacterStyle; 40 import android.text.style.TextAppearanceSpan; 41 import android.util.Pair; 42 import android.util.SparseArray; 43 44 import com.android.mail.EmailAddress; 45 import com.android.mail.MailIntentService; 46 import com.android.mail.R; 47 import com.android.mail.analytics.Analytics; 48 import com.android.mail.analytics.AnalyticsUtils; 49 import com.android.mail.browse.MessageCursor; 50 import com.android.mail.browse.SendersView; 51 import com.android.mail.photomanager.LetterTileProvider; 52 import com.android.mail.preferences.AccountPreferences; 53 import com.android.mail.preferences.FolderPreferences; 54 import com.android.mail.preferences.MailPrefs; 55 import com.android.mail.providers.Account; 56 import com.android.mail.providers.Address; 57 import com.android.mail.providers.Conversation; 58 import com.android.mail.providers.Folder; 59 import com.android.mail.providers.Message; 60 import com.android.mail.providers.UIProvider; 61 import com.android.mail.ui.ImageCanvas.Dimensions; 62 import com.android.mail.utils.NotificationActionUtils.NotificationAction; 63 import com.google.android.mail.common.html.parser.HTML; 64 import com.google.android.mail.common.html.parser.HTML4; 65 import com.google.android.mail.common.html.parser.HtmlDocument; 66 import com.google.android.mail.common.html.parser.HtmlTree; 67 import com.google.common.base.Objects; 68 import com.google.common.collect.ImmutableList; 69 import com.google.common.collect.Lists; 70 import com.google.common.collect.Sets; 71 72 import java.io.ByteArrayInputStream; 73 import java.util.ArrayList; 74 import java.util.Arrays; 75 import java.util.Collection; 76 import java.util.List; 77 import java.util.Set; 78 import java.util.concurrent.ConcurrentHashMap; 79 80 public class NotificationUtils { 81 public static final String LOG_TAG = "NotifUtils"; 82 83 /** Contains a list of <(account, label), unread conversations> */ 84 private static NotificationMap sActiveNotificationMap = null; 85 86 private static final SparseArray<Bitmap> sNotificationIcons = new SparseArray<Bitmap>(); 87 88 private static TextAppearanceSpan sNotificationUnreadStyleSpan; 89 private static CharacterStyle sNotificationReadStyleSpan; 90 91 /** A factory that produces a plain text converter that removes elided text. */ 92 private static final HtmlTree.PlainTextConverterFactory MESSAGE_CONVERTER_FACTORY = 93 new HtmlTree.PlainTextConverterFactory() { 94 @Override 95 public HtmlTree.PlainTextConverter createInstance() { 96 return new MailMessagePlainTextConverter(); 97 } 98 }; 99 100 private static final BidiFormatter BIDI_FORMATTER = BidiFormatter.getInstance(); 101 102 /** 103 * Clears all notifications in response to the user tapping "Clear" in the status bar. 104 */ 105 public static void clearAllNotfications(Context context) { 106 LogUtils.v(LOG_TAG, "Clearing all notifications."); 107 final NotificationMap notificationMap = getNotificationMap(context); 108 notificationMap.clear(); 109 notificationMap.saveNotificationMap(context); 110 } 111 112 /** 113 * Returns the notification map, creating it if necessary. 114 */ 115 private static synchronized NotificationMap getNotificationMap(Context context) { 116 if (sActiveNotificationMap == null) { 117 sActiveNotificationMap = new NotificationMap(); 118 119 // populate the map from the cached data 120 sActiveNotificationMap.loadNotificationMap(context); 121 } 122 return sActiveNotificationMap; 123 } 124 125 /** 126 * Class representing the existing notifications, and the number of unread and 127 * unseen conversations that triggered each. 128 */ 129 private static class NotificationMap 130 extends ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>> { 131 132 private static final String NOTIFICATION_PART_SEPARATOR = " "; 133 private static final int NUM_NOTIFICATION_PARTS= 4; 134 135 /** 136 * Retuns the unread count for the given NotificationKey. 137 */ 138 public Integer getUnread(NotificationKey key) { 139 final Pair<Integer, Integer> value = get(key); 140 return value != null ? value.first : null; 141 } 142 143 /** 144 * Retuns the unread unseen count for the given NotificationKey. 145 */ 146 public Integer getUnseen(NotificationKey key) { 147 final Pair<Integer, Integer> value = get(key); 148 return value != null ? value.second : null; 149 } 150 151 /** 152 * Store the unread and unseen value for the given NotificationKey 153 */ 154 public void put(NotificationKey key, int unread, int unseen) { 155 final Pair<Integer, Integer> value = 156 new Pair<Integer, Integer>(Integer.valueOf(unread), Integer.valueOf(unseen)); 157 put(key, value); 158 } 159 160 /** 161 * Populates the notification map with previously cached data. 162 */ 163 public synchronized void loadNotificationMap(final Context context) { 164 final MailPrefs mailPrefs = MailPrefs.get(context); 165 final Set<String> notificationSet = mailPrefs.getActiveNotificationSet(); 166 if (notificationSet != null) { 167 for (String notificationEntry : notificationSet) { 168 // Get the parts of the string that make the notification entry 169 final String[] notificationParts = 170 TextUtils.split(notificationEntry, NOTIFICATION_PART_SEPARATOR); 171 if (notificationParts.length == NUM_NOTIFICATION_PARTS) { 172 final Uri accountUri = Uri.parse(notificationParts[0]); 173 final Cursor accountCursor = context.getContentResolver().query( 174 accountUri, UIProvider.ACCOUNTS_PROJECTION, null, null, null); 175 final Account account; 176 try { 177 if (accountCursor.moveToFirst()) { 178 account = new Account(accountCursor); 179 } else { 180 continue; 181 } 182 } finally { 183 accountCursor.close(); 184 } 185 186 final Uri folderUri = Uri.parse(notificationParts[1]); 187 final Cursor folderCursor = context.getContentResolver().query( 188 folderUri, UIProvider.FOLDERS_PROJECTION, null, null, null); 189 final Folder folder; 190 try { 191 if (folderCursor.moveToFirst()) { 192 folder = new Folder(folderCursor); 193 } else { 194 continue; 195 } 196 } finally { 197 folderCursor.close(); 198 } 199 200 final NotificationKey key = new NotificationKey(account, folder); 201 final Integer unreadValue = Integer.valueOf(notificationParts[2]); 202 final Integer unseenValue = Integer.valueOf(notificationParts[3]); 203 final Pair<Integer, Integer> unreadUnseenValue = 204 new Pair<Integer, Integer>(unreadValue, unseenValue); 205 put(key, unreadUnseenValue); 206 } 207 } 208 } 209 } 210 211 /** 212 * Cache the notification map. 213 */ 214 public synchronized void saveNotificationMap(Context context) { 215 final Set<String> notificationSet = Sets.newHashSet(); 216 final Set<NotificationKey> keys = keySet(); 217 for (NotificationKey key : keys) { 218 final Pair<Integer, Integer> value = get(key); 219 final Integer unreadCount = value.first; 220 final Integer unseenCount = value.second; 221 if (unreadCount != null && unseenCount != null) { 222 final String[] partValues = new String[] { 223 key.account.uri.toString(), key.folder.folderUri.fullUri.toString(), 224 unreadCount.toString(), unseenCount.toString()}; 225 notificationSet.add(TextUtils.join(NOTIFICATION_PART_SEPARATOR, partValues)); 226 } 227 } 228 final MailPrefs mailPrefs = MailPrefs.get(context); 229 mailPrefs.cacheActiveNotificationSet(notificationSet); 230 } 231 } 232 233 /** 234 * @return the title of this notification with each account and the number of unread and unseen 235 * conversations for it. Also remove any account in the map that has 0 unread. 236 */ 237 private static String createNotificationString(NotificationMap notifications) { 238 StringBuilder result = new StringBuilder(); 239 int i = 0; 240 Set<NotificationKey> keysToRemove = Sets.newHashSet(); 241 for (NotificationKey key : notifications.keySet()) { 242 Integer unread = notifications.getUnread(key); 243 Integer unseen = notifications.getUnseen(key); 244 if (unread == null || unread.intValue() == 0) { 245 keysToRemove.add(key); 246 } else { 247 if (i > 0) result.append(", "); 248 result.append(key.toString() + " (" + unread + ", " + unseen + ")"); 249 i++; 250 } 251 } 252 253 for (NotificationKey key : keysToRemove) { 254 notifications.remove(key); 255 } 256 257 return result.toString(); 258 } 259 260 /** 261 * Get all notifications for all accounts and cancel them. 262 **/ 263 public static void cancelAllNotifications(Context context) { 264 LogUtils.d(LOG_TAG, "cancelAllNotifications - cancelling all"); 265 NotificationManager nm = (NotificationManager) context.getSystemService( 266 Context.NOTIFICATION_SERVICE); 267 nm.cancelAll(); 268 clearAllNotfications(context); 269 } 270 271 /** 272 * Get all notifications for all accounts, cancel them, and repost. 273 * This happens when locale changes. 274 **/ 275 public static void cancelAndResendNotifications(Context context) { 276 LogUtils.d(LOG_TAG, "cancelAndResendNotifications"); 277 resendNotifications(context, true, null, null); 278 } 279 280 /** 281 * Get all notifications for all accounts, optionally cancel them, and repost. 282 * This happens when locale changes. If you only want to resend messages from one 283 * account-folder pair, pass in the account and folder that should be resent. 284 * All other account-folder pairs will not have their notifications resent. 285 * All notifications will be resent if account or folder is null. 286 * 287 * @param context Current context. 288 * @param cancelExisting True, if all notifications should be canceled before resending. 289 * False, otherwise. 290 * @param accountUri The {@link Uri} of the {@link Account} of the notification 291 * upon which an action occurred. 292 * @param folderUri The {@link Uri} of the {@link Folder} of the notification 293 * upon which an action occurred. 294 */ 295 public static void resendNotifications(Context context, final boolean cancelExisting, 296 final Uri accountUri, final FolderUri folderUri) { 297 LogUtils.d(LOG_TAG, "resendNotifications "); 298 299 if (cancelExisting) { 300 LogUtils.d(LOG_TAG, "resendNotifications - cancelling all"); 301 NotificationManager nm = 302 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 303 nm.cancelAll(); 304 } 305 // Re-validate the notifications. 306 final NotificationMap notificationMap = getNotificationMap(context); 307 final Set<NotificationKey> keys = notificationMap.keySet(); 308 for (NotificationKey notification : keys) { 309 final Folder folder = notification.folder; 310 final int notificationId = 311 getNotificationId(notification.account.getAccountManagerAccount(), folder); 312 313 // Only resend notifications if the notifications are from the same folder 314 // and same account as the undo notification that was previously displayed. 315 if (accountUri != null && !Objects.equal(accountUri, notification.account.uri) && 316 folderUri != null && !Objects.equal(folderUri, folder.folderUri)) { 317 LogUtils.d(LOG_TAG, "resendNotifications - not resending %s / %s" 318 + " because it doesn't match %s / %s", 319 notification.account.uri, folder.folderUri, accountUri, folderUri); 320 continue; 321 } 322 323 LogUtils.d(LOG_TAG, "resendNotifications - resending %s / %s", 324 notification.account.uri, folder.folderUri); 325 326 final NotificationAction undoableAction = 327 NotificationActionUtils.sUndoNotifications.get(notificationId); 328 if (undoableAction == null) { 329 validateNotifications(context, folder, notification.account, true, 330 false, notification); 331 } else { 332 // Create an undo notification 333 NotificationActionUtils.createUndoNotification(context, undoableAction); 334 } 335 } 336 } 337 338 /** 339 * Validate the notifications for the specified account. 340 */ 341 public static void validateAccountNotifications(Context context, String account) { 342 LogUtils.d(LOG_TAG, "validateAccountNotifications - %s", account); 343 344 List<NotificationKey> notificationsToCancel = Lists.newArrayList(); 345 // Iterate through the notification map to see if there are any entries that correspond to 346 // labels that are not in the sync set. 347 final NotificationMap notificationMap = getNotificationMap(context); 348 Set<NotificationKey> keys = notificationMap.keySet(); 349 final AccountPreferences accountPreferences = new AccountPreferences(context, account); 350 final boolean enabled = accountPreferences.areNotificationsEnabled(); 351 if (!enabled) { 352 // Cancel all notifications for this account 353 for (NotificationKey notification : keys) { 354 if (notification.account.getAccountManagerAccount().name.equals(account)) { 355 notificationsToCancel.add(notification); 356 } 357 } 358 } else { 359 // Iterate through the notification map to see if there are any entries that 360 // correspond to labels that are not in the notification set. 361 for (NotificationKey notification : keys) { 362 if (notification.account.getAccountManagerAccount().name.equals(account)) { 363 // If notification is not enabled for this label, remember this NotificationKey 364 // to later cancel the notification, and remove the entry from the map 365 final Folder folder = notification.folder; 366 final boolean isInbox = folder.folderUri.equals( 367 notification.account.settings.defaultInbox); 368 final FolderPreferences folderPreferences = new FolderPreferences( 369 context, notification.account.getEmailAddress(), folder, isInbox); 370 371 if (!folderPreferences.areNotificationsEnabled()) { 372 notificationsToCancel.add(notification); 373 } 374 } 375 } 376 } 377 378 // Cancel & remove the invalid notifications. 379 if (notificationsToCancel.size() > 0) { 380 NotificationManager nm = (NotificationManager) context.getSystemService( 381 Context.NOTIFICATION_SERVICE); 382 for (NotificationKey notification : notificationsToCancel) { 383 final Folder folder = notification.folder; 384 final int notificationId = 385 getNotificationId(notification.account.getAccountManagerAccount(), folder); 386 LogUtils.d(LOG_TAG, "validateAccountNotifications - cancelling %s / %s", 387 notification.account.name, folder.persistentId); 388 nm.cancel(notificationId); 389 notificationMap.remove(notification); 390 NotificationActionUtils.sUndoNotifications.remove(notificationId); 391 NotificationActionUtils.sNotificationTimestamps.delete(notificationId); 392 } 393 notificationMap.saveNotificationMap(context); 394 } 395 } 396 397 /** 398 * Display only one notification. 399 */ 400 public static void setNewEmailIndicator(Context context, final int unreadCount, 401 final int unseenCount, final Account account, final Folder folder, 402 final boolean getAttention) { 403 LogUtils.d(LOG_TAG, "setNewEmailIndicator unreadCount = %d, unseenCount = %d, account = %s," 404 + " folder = %s, getAttention = %b", unreadCount, unseenCount, account.name, 405 folder.folderUri, getAttention); 406 407 boolean ignoreUnobtrusiveSetting = false; 408 409 final int notificationId = getNotificationId(account.getAccountManagerAccount(), folder); 410 411 // Update the notification map 412 final NotificationMap notificationMap = getNotificationMap(context); 413 final NotificationKey key = new NotificationKey(account, folder); 414 if (unreadCount == 0) { 415 LogUtils.d(LOG_TAG, "setNewEmailIndicator - cancelling %s / %s", account.name, 416 folder.persistentId); 417 notificationMap.remove(key); 418 ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)) 419 .cancel(notificationId); 420 } else { 421 if (!notificationMap.containsKey(key)) { 422 // This account previously didn't have any unread mail; ignore the "unobtrusive 423 // notifications" setting and play sound and/or vibrate the device even if a 424 // notification already exists (bug 2412348). 425 ignoreUnobtrusiveSetting = true; 426 } 427 notificationMap.put(key, unreadCount, unseenCount); 428 } 429 notificationMap.saveNotificationMap(context); 430 431 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 432 LogUtils.v(LOG_TAG, "New email: %s mapSize: %d getAttention: %b", 433 createNotificationString(notificationMap), notificationMap.size(), 434 getAttention); 435 } 436 437 if (NotificationActionUtils.sUndoNotifications.get(notificationId) == null) { 438 validateNotifications(context, folder, account, getAttention, ignoreUnobtrusiveSetting, 439 key); 440 } 441 } 442 443 /** 444 * Validate the notifications notification. 445 */ 446 private static void validateNotifications(Context context, final Folder folder, 447 final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting, 448 NotificationKey key) { 449 450 NotificationManager nm = (NotificationManager) 451 context.getSystemService(Context.NOTIFICATION_SERVICE); 452 453 final NotificationMap notificationMap = getNotificationMap(context); 454 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 455 LogUtils.i(LOG_TAG, "Validating Notification: %s mapSize: %d " 456 + "folder: %s getAttention: %b", createNotificationString(notificationMap), 457 notificationMap.size(), folder.name, getAttention); 458 } else { 459 LogUtils.i(LOG_TAG, "Validating Notification, mapSize: %d " 460 + "getAttention: %b", notificationMap.size(), getAttention); 461 } 462 // The number of unread messages for this account and label. 463 final Integer unread = notificationMap.getUnread(key); 464 final int unreadCount = unread != null ? unread.intValue() : 0; 465 final Integer unseen = notificationMap.getUnseen(key); 466 int unseenCount = unseen != null ? unseen.intValue() : 0; 467 468 Cursor cursor = null; 469 470 try { 471 final Uri.Builder uriBuilder = folder.conversationListUri.buildUpon(); 472 uriBuilder.appendQueryParameter( 473 UIProvider.SEEN_QUERY_PARAMETER, Boolean.FALSE.toString()); 474 // Do not allow this quick check to disrupt any active network-enabled conversation 475 // cursor. 476 uriBuilder.appendQueryParameter( 477 UIProvider.ConversationListQueryParameters.USE_NETWORK, 478 Boolean.FALSE.toString()); 479 cursor = context.getContentResolver().query(uriBuilder.build(), 480 UIProvider.CONVERSATION_PROJECTION, null, null, null); 481 if (cursor == null) { 482 // This folder doesn't exist. 483 LogUtils.i(LOG_TAG, 484 "The cursor is null, so the specified folder probably does not exist"); 485 clearFolderNotification(context, account, folder, false); 486 return; 487 } 488 final int cursorUnseenCount = cursor.getCount(); 489 490 // Make sure the unseen count matches the number of items in the cursor. But, we don't 491 // want to overwrite a 0 unseen count that was specified in the intent 492 if (unseenCount != 0 && unseenCount != cursorUnseenCount) { 493 LogUtils.i(LOG_TAG, 494 "Unseen count doesn't match cursor count. unseen: %d cursor count: %d", 495 unseenCount, cursorUnseenCount); 496 unseenCount = cursorUnseenCount; 497 } 498 499 // For the purpose of the notifications, the unseen count should be capped at the num of 500 // unread conversations. 501 if (unseenCount > unreadCount) { 502 unseenCount = unreadCount; 503 } 504 505 final int notificationId = 506 getNotificationId(account.getAccountManagerAccount(), folder); 507 508 if (unseenCount == 0) { 509 LogUtils.i(LOG_TAG, "validateNotifications - cancelling account %s / folder %s", 510 LogUtils.sanitizeName(LOG_TAG, account.name), 511 LogUtils.sanitizeName(LOG_TAG, folder.persistentId)); 512 nm.cancel(notificationId); 513 return; 514 } 515 516 // We now have all we need to create the notification and the pending intent 517 PendingIntent clickIntent; 518 519 NotificationCompat.Builder notification = new NotificationCompat.Builder(context); 520 notification.setSmallIcon(R.drawable.stat_notify_email); 521 notification.setTicker(account.name); 522 523 final long when; 524 525 final long oldWhen = 526 NotificationActionUtils.sNotificationTimestamps.get(notificationId); 527 if (oldWhen != 0) { 528 when = oldWhen; 529 } else { 530 when = System.currentTimeMillis(); 531 } 532 533 notification.setWhen(when); 534 535 // The timestamp is now stored in the notification, so we can remove it from here 536 NotificationActionUtils.sNotificationTimestamps.delete(notificationId); 537 538 // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a 539 // notification. Also this intent gets fired when the user taps on a notification as 540 // the AutoCancel flag has been set 541 final Intent cancelNotificationIntent = 542 new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS); 543 cancelNotificationIntent.setPackage(context.getPackageName()); 544 cancelNotificationIntent.setData(Utils.appendVersionQueryParameter(context, 545 folder.folderUri.fullUri)); 546 cancelNotificationIntent.putExtra(Utils.EXTRA_ACCOUNT, account); 547 cancelNotificationIntent.putExtra(Utils.EXTRA_FOLDER, folder); 548 549 notification.setDeleteIntent(PendingIntent.getService( 550 context, notificationId, cancelNotificationIntent, 0)); 551 552 // Ensure that the notification is cleared when the user selects it 553 notification.setAutoCancel(true); 554 555 boolean eventInfoConfigured = false; 556 557 final boolean isInbox = folder.folderUri.equals(account.settings.defaultInbox); 558 final FolderPreferences folderPreferences = 559 new FolderPreferences(context, account.getEmailAddress(), folder, isInbox); 560 561 if (isInbox) { 562 final AccountPreferences accountPreferences = 563 new AccountPreferences(context, account.getEmailAddress()); 564 moveNotificationSetting(accountPreferences, folderPreferences); 565 } 566 567 if (!folderPreferences.areNotificationsEnabled()) { 568 LogUtils.i(LOG_TAG, "Notifications are disabled for this folder; not notifying"); 569 // Don't notify 570 return; 571 } 572 573 if (unreadCount > 0) { 574 // How can I order this properly? 575 if (cursor.moveToNext()) { 576 final Intent notificationIntent; 577 578 // Launch directly to the conversation, if there is only 1 unseen conversation 579 final boolean launchConversationMode = (unseenCount == 1); 580 if (launchConversationMode) { 581 notificationIntent = createViewConversationIntent(context, account, folder, 582 cursor); 583 } else { 584 notificationIntent = createViewConversationIntent(context, account, folder, 585 null); 586 } 587 588 Analytics.getInstance().sendEvent("notification_create", 589 launchConversationMode ? "conversation" : "conversation_list", 590 folder.getTypeDescription(), unseenCount); 591 592 if (notificationIntent == null) { 593 LogUtils.e(LOG_TAG, "Null intent when building notification"); 594 return; 595 } 596 597 // Amend the click intent with a hint that its source was a notification, 598 // but remove the hint before it's used to generate notification action 599 // intents. This prevents the following sequence: 600 // 1. generate single notification 601 // 2. user clicks reply, then completes Compose activity 602 // 3. main activity launches, gets FROM_NOTIFICATION hint in intent 603 notificationIntent.putExtra(Utils.EXTRA_FROM_NOTIFICATION, true); 604 clickIntent = PendingIntent.getActivity(context, -1, notificationIntent, 605 PendingIntent.FLAG_UPDATE_CURRENT); 606 notificationIntent.removeExtra(Utils.EXTRA_FROM_NOTIFICATION); 607 608 configureLatestEventInfoFromConversation(context, account, folderPreferences, 609 notification, cursor, clickIntent, notificationIntent, 610 unreadCount, unseenCount, folder, when); 611 eventInfoConfigured = true; 612 } 613 } 614 615 final boolean vibrate = folderPreferences.isNotificationVibrateEnabled(); 616 final String ringtoneUri = folderPreferences.getNotificationRingtoneUri(); 617 final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled(); 618 619 if (!ignoreUnobtrusiveSetting && notifyOnce) { 620 // If the user has "unobtrusive notifications" enabled, only alert the first time 621 // new mail is received in this account. This is the default behavior. See 622 // bugs 2412348 and 2413490. 623 notification.setOnlyAlertOnce(true); 624 } 625 626 LogUtils.i(LOG_TAG, "Account: %s vibrate: %s", 627 LogUtils.sanitizeName(LOG_TAG, account.name), 628 Boolean.toString(folderPreferences.isNotificationVibrateEnabled())); 629 630 int defaults = 0; 631 632 /* 633 * We do not want to notify if this is coming back from an Undo notification, hence the 634 * oldWhen check. 635 */ 636 if (getAttention && oldWhen == 0) { 637 final AccountPreferences accountPreferences = 638 new AccountPreferences(context, account.name); 639 if (accountPreferences.areNotificationsEnabled()) { 640 if (vibrate) { 641 defaults |= Notification.DEFAULT_VIBRATE; 642 } 643 644 notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null 645 : Uri.parse(ringtoneUri)); 646 LogUtils.i(LOG_TAG, "New email in %s vibrateWhen: %s, playing notification: %s", 647 LogUtils.sanitizeName(LOG_TAG, account.name), vibrate, ringtoneUri); 648 } 649 } 650 651 // TODO(skennedy) Why do we do any of the above if we're just going to bail here? 652 if (eventInfoConfigured) { 653 defaults |= Notification.DEFAULT_LIGHTS; 654 notification.setDefaults(defaults); 655 656 if (oldWhen != 0) { 657 // We do not want to display the ticker again if we are re-displaying this 658 // notification (like from an Undo notification) 659 notification.setTicker(null); 660 } 661 662 nm.notify(notificationId, notification.build()); 663 } else { 664 LogUtils.i(LOG_TAG, "event info not configured - not notifying"); 665 } 666 } finally { 667 if (cursor != null) { 668 cursor.close(); 669 } 670 } 671 } 672 673 /** 674 * @return an {@link Intent} which, if launched, will display the corresponding conversation 675 */ 676 private static Intent createViewConversationIntent(final Context context, final Account account, 677 final Folder folder, final Cursor cursor) { 678 if (folder == null || account == null) { 679 LogUtils.e(LOG_TAG, "createViewConversationIntent(): " 680 + "Null account or folder. account: %s folder: %s", account, folder); 681 return null; 682 } 683 684 final Intent intent; 685 686 if (cursor == null) { 687 intent = Utils.createViewFolderIntent(context, folder.folderUri.fullUri, account); 688 } else { 689 // A conversation cursor has been specified, so this intent is intended to be go 690 // directly to the one new conversation 691 692 // Get the Conversation object 693 final Conversation conversation = new Conversation(cursor); 694 intent = Utils.createViewConversationIntent(context, conversation, 695 folder.folderUri.fullUri, account); 696 } 697 698 return intent; 699 } 700 701 private static Bitmap getDefaultNotificationIcon( 702 final Context context, final Folder folder, final boolean multipleNew) { 703 final int resId; 704 if (folder.notificationIconResId != 0) { 705 resId = folder.notificationIconResId; 706 } else if (multipleNew) { 707 resId = R.drawable.ic_notification_multiple_mail_holo_dark; 708 } else { 709 resId = R.drawable.ic_contact_picture; 710 } 711 712 final Bitmap icon = getIcon(context, resId); 713 714 if (icon == null) { 715 LogUtils.e(LOG_TAG, "Couldn't decode notif icon res id %d", resId); 716 } 717 718 return icon; 719 } 720 721 private static Bitmap getIcon(final Context context, final int resId) { 722 final Bitmap cachedIcon = sNotificationIcons.get(resId); 723 if (cachedIcon != null) { 724 return cachedIcon; 725 } 726 727 final Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resId); 728 sNotificationIcons.put(resId, icon); 729 730 return icon; 731 } 732 733 private static void configureLatestEventInfoFromConversation(final Context context, 734 final Account account, final FolderPreferences folderPreferences, 735 final NotificationCompat.Builder notification, final Cursor conversationCursor, 736 final PendingIntent clickIntent, final Intent notificationIntent, 737 final int unreadCount, final int unseenCount, 738 final Folder folder, final long when) { 739 final Resources res = context.getResources(); 740 final String notificationAccount = account.name; 741 742 LogUtils.i(LOG_TAG, "Showing notification with unreadCount of %d and unseenCount of %d", 743 unreadCount, unseenCount); 744 745 String notificationTicker = null; 746 747 // Boolean indicating that this notification is for a non-inbox label. 748 final boolean isInbox = folder.folderUri.fullUri.equals(account.settings.defaultInbox); 749 750 // Notification label name for user label notifications. 751 final String notificationLabelName = isInbox ? null : folder.name; 752 753 if (unseenCount > 1) { 754 // Build the string that describes the number of new messages 755 final String newMessagesString = res.getString(R.string.new_messages, unseenCount); 756 757 // Use the default notification icon 758 notification.setLargeIcon( 759 getDefaultNotificationIcon(context, folder, true /* multiple new messages */)); 760 761 // The ticker initially start as the new messages string. 762 notificationTicker = newMessagesString; 763 764 // The title of the notification is the new messages string 765 notification.setContentTitle(newMessagesString); 766 767 // TODO(skennedy) Can we remove this check? 768 if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) { 769 // For a new-style notification 770 final int maxNumDigestItems = context.getResources().getInteger( 771 R.integer.max_num_notification_digest_items); 772 773 // The body of the notification is the account name, or the label name. 774 notification.setSubText(isInbox ? notificationAccount : notificationLabelName); 775 776 final NotificationCompat.InboxStyle digest = 777 new NotificationCompat.InboxStyle(notification); 778 779 int numDigestItems = 0; 780 do { 781 final Conversation conversation = new Conversation(conversationCursor); 782 783 if (!conversation.read) { 784 boolean multipleUnreadThread = false; 785 // TODO(cwren) extract this pattern into a helper 786 787 Cursor cursor = null; 788 MessageCursor messageCursor = null; 789 try { 790 final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon(); 791 uriBuilder.appendQueryParameter( 792 UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName); 793 cursor = context.getContentResolver().query(uriBuilder.build(), 794 UIProvider.MESSAGE_PROJECTION, null, null, null); 795 messageCursor = new MessageCursor(cursor); 796 797 String from = ""; 798 String fromAddress = ""; 799 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) { 800 final Message message = messageCursor.getMessage(); 801 fromAddress = message.getFrom(); 802 if (fromAddress == null) { 803 fromAddress = ""; 804 } 805 from = getDisplayableSender(fromAddress); 806 } 807 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) { 808 final Message message = messageCursor.getMessage(); 809 if (!message.read && 810 !fromAddress.contentEquals(message.getFrom())) { 811 multipleUnreadThread = true; 812 break; 813 } 814 } 815 final SpannableStringBuilder sendersBuilder; 816 if (multipleUnreadThread) { 817 final int sendersLength = 818 res.getInteger(R.integer.swipe_senders_length); 819 820 sendersBuilder = getStyledSenders(context, conversationCursor, 821 sendersLength, notificationAccount); 822 } else { 823 sendersBuilder = 824 new SpannableStringBuilder(getWrappedFromString(from)); 825 } 826 final CharSequence digestLine = getSingleMessageInboxLine(context, 827 sendersBuilder.toString(), 828 conversation.subject, 829 conversation.snippet); 830 digest.addLine(digestLine); 831 numDigestItems++; 832 } finally { 833 if (messageCursor != null) { 834 messageCursor.close(); 835 } 836 if (cursor != null) { 837 cursor.close(); 838 } 839 } 840 } 841 } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext()); 842 } else { 843 // The body of the notification is the account name, or the label name. 844 notification.setContentText( 845 isInbox ? notificationAccount : notificationLabelName); 846 } 847 } else { 848 // For notifications for a single new conversation, we want to get the information from 849 // the conversation 850 851 // Move the cursor to the most recent unread conversation 852 seekToLatestUnreadConversation(conversationCursor); 853 854 final Conversation conversation = new Conversation(conversationCursor); 855 856 Cursor cursor = null; 857 MessageCursor messageCursor = null; 858 boolean multipleUnseenThread = false; 859 String from = null; 860 try { 861 final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter( 862 UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build(); 863 cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION, 864 null, null, null); 865 messageCursor = new MessageCursor(cursor); 866 // Use the information from the last sender in the conversation that triggered 867 // this notification. 868 869 String fromAddress = ""; 870 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) { 871 final Message message = messageCursor.getMessage(); 872 fromAddress = message.getFrom(); 873 from = getDisplayableSender(fromAddress); 874 notification.setLargeIcon( 875 getContactIcon(context, from, getSenderAddress(fromAddress), folder)); 876 } 877 878 // Assume that the last message in this conversation is unread 879 int firstUnseenMessagePos = messageCursor.getPosition(); 880 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) { 881 final Message message = messageCursor.getMessage(); 882 final boolean unseen = !message.seen; 883 if (unseen) { 884 firstUnseenMessagePos = messageCursor.getPosition(); 885 if (!multipleUnseenThread 886 && !fromAddress.contentEquals(message.getFrom())) { 887 multipleUnseenThread = true; 888 } 889 } 890 } 891 892 // TODO(skennedy) Can we remove this check? 893 if (Utils.isRunningJellybeanOrLater()) { 894 // For a new-style notification 895 896 if (multipleUnseenThread) { 897 // The title of a single conversation is the list of senders. 898 int sendersLength = res.getInteger(R.integer.swipe_senders_length); 899 900 final SpannableStringBuilder sendersBuilder = getStyledSenders( 901 context, conversationCursor, sendersLength, notificationAccount); 902 903 notification.setContentTitle(sendersBuilder); 904 // For a single new conversation, the ticker is based on the sender's name. 905 notificationTicker = sendersBuilder.toString(); 906 } else { 907 from = getWrappedFromString(from); 908 // The title of a single message the sender. 909 notification.setContentTitle(from); 910 // For a single new conversation, the ticker is based on the sender's name. 911 notificationTicker = from; 912 } 913 914 // The notification content will be the subject of the conversation. 915 notification.setContentText( 916 getSingleMessageLittleText(context, conversation.subject)); 917 918 // The notification subtext will be the subject of the conversation for inbox 919 // notifications, or will based on the the label name for user label 920 // notifications. 921 notification.setSubText(isInbox ? notificationAccount : notificationLabelName); 922 923 if (multipleUnseenThread) { 924 notification.setLargeIcon( 925 getDefaultNotificationIcon(context, folder, true)); 926 } 927 final NotificationCompat.BigTextStyle bigText = 928 new NotificationCompat.BigTextStyle(notification); 929 930 // Seek the message cursor to the first unread message 931 final Message message; 932 if (messageCursor.moveToPosition(firstUnseenMessagePos)) { 933 message = messageCursor.getMessage(); 934 bigText.bigText(getSingleMessageBigText(context, 935 conversation.subject, message)); 936 } else { 937 LogUtils.e(LOG_TAG, "Failed to load message"); 938 message = null; 939 } 940 941 if (message != null) { 942 final Set<String> notificationActions = 943 folderPreferences.getNotificationActions(account); 944 945 final int notificationId = getNotificationId( 946 account.getAccountManagerAccount(), folder); 947 948 NotificationActionUtils.addNotificationActions(context, notificationIntent, 949 notification, account, conversation, message, folder, 950 notificationId, when, notificationActions); 951 } 952 } else { 953 // For an old-style notification 954 955 // The title of a single conversation notification is built from both the sender 956 // and subject of the new message. 957 notification.setContentTitle(getSingleMessageNotificationTitle(context, 958 from, conversation.subject)); 959 960 // The notification content will be the subject of the conversation for inbox 961 // notifications, or will based on the the label name for user label 962 // notifications. 963 notification.setContentText( 964 isInbox ? notificationAccount : notificationLabelName); 965 966 // For a single new conversation, the ticker is based on the sender's name. 967 notificationTicker = from; 968 } 969 } finally { 970 if (messageCursor != null) { 971 messageCursor.close(); 972 } 973 if (cursor != null) { 974 cursor.close(); 975 } 976 } 977 } 978 979 // Build the notification ticker 980 if (notificationLabelName != null && notificationTicker != null) { 981 // This is a per label notification, format the ticker with that information 982 notificationTicker = res.getString(R.string.label_notification_ticker, 983 notificationLabelName, notificationTicker); 984 } 985 986 if (notificationTicker != null) { 987 // If we didn't generate a notification ticker, it will default to account name 988 notification.setTicker(notificationTicker); 989 } 990 991 // Set the number in the notification 992 if (unreadCount > 1) { 993 notification.setNumber(unreadCount); 994 } 995 996 notification.setContentIntent(clickIntent); 997 } 998 999 private static String getWrappedFromString(String from) { 1000 if (from == null) { 1001 LogUtils.e(LOG_TAG, "null from string in getWrappedFromString"); 1002 from = ""; 1003 } 1004 from = BIDI_FORMATTER.unicodeWrap(from); 1005 return from; 1006 } 1007 1008 private static SpannableStringBuilder getStyledSenders(final Context context, 1009 final Cursor conversationCursor, final int maxLength, final String account) { 1010 final Conversation conversation = new Conversation(conversationCursor); 1011 final com.android.mail.providers.ConversationInfo conversationInfo = 1012 conversation.conversationInfo; 1013 final ArrayList<SpannableString> senders = new ArrayList<SpannableString>(); 1014 if (sNotificationUnreadStyleSpan == null) { 1015 sNotificationUnreadStyleSpan = new TextAppearanceSpan( 1016 context, R.style.NotificationSendersUnreadTextAppearance); 1017 sNotificationReadStyleSpan = 1018 new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance); 1019 } 1020 SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account, 1021 sNotificationUnreadStyleSpan, sNotificationReadStyleSpan, false); 1022 1023 return ellipsizeStyledSenders(context, senders); 1024 } 1025 1026 private static String sSendersSplitToken = null; 1027 private static String sElidedPaddingToken = null; 1028 1029 private static SpannableStringBuilder ellipsizeStyledSenders(final Context context, 1030 ArrayList<SpannableString> styledSenders) { 1031 if (sSendersSplitToken == null) { 1032 sSendersSplitToken = context.getString(R.string.senders_split_token); 1033 sElidedPaddingToken = context.getString(R.string.elided_padding_token); 1034 } 1035 1036 SpannableStringBuilder builder = new SpannableStringBuilder(); 1037 SpannableString prevSender = null; 1038 for (SpannableString sender : styledSenders) { 1039 if (sender == null) { 1040 LogUtils.e(LOG_TAG, "null sender iterating over styledSenders"); 1041 continue; 1042 } 1043 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class); 1044 if (SendersView.sElidedString.equals(sender.toString())) { 1045 prevSender = sender; 1046 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken); 1047 } else if (builder.length() > 0 1048 && (prevSender == null || !SendersView.sElidedString.equals(prevSender 1049 .toString()))) { 1050 prevSender = sender; 1051 sender = copyStyles(spans, sSendersSplitToken + sender); 1052 } else { 1053 prevSender = sender; 1054 } 1055 builder.append(sender); 1056 } 1057 return builder; 1058 } 1059 1060 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) { 1061 SpannableString s = new SpannableString(newText); 1062 if (spans != null && spans.length > 0) { 1063 s.setSpan(spans[0], 0, s.length(), 0); 1064 } 1065 return s; 1066 } 1067 1068 /** 1069 * Seeks the cursor to the position of the most recent unread conversation. If no unread 1070 * conversation is found, the position of the cursor will be restored, and false will be 1071 * returned. 1072 */ 1073 private static boolean seekToLatestUnreadConversation(final Cursor cursor) { 1074 final int initialPosition = cursor.getPosition(); 1075 do { 1076 final Conversation conversation = new Conversation(cursor); 1077 if (!conversation.read) { 1078 return true; 1079 } 1080 } while (cursor.moveToNext()); 1081 1082 // Didn't find an unread conversation, reset the position. 1083 cursor.moveToPosition(initialPosition); 1084 return false; 1085 } 1086 1087 /** 1088 * Sets the bigtext for a notification for a single new conversation 1089 * 1090 * @param context 1091 * @param senders Sender of the new message that triggered the notification. 1092 * @param subject Subject of the new message that triggered the notification 1093 * @param snippet Snippet of the new message that triggered the notification 1094 * @return a {@link CharSequence} suitable for use in 1095 * {@link android.support.v4.app.NotificationCompat.BigTextStyle} 1096 */ 1097 private static CharSequence getSingleMessageInboxLine(Context context, 1098 String senders, String subject, String snippet) { 1099 // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText 1100 1101 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet; 1102 1103 final TextAppearanceSpan notificationPrimarySpan = 1104 new TextAppearanceSpan(context, R.style.NotificationPrimaryText); 1105 1106 if (TextUtils.isEmpty(senders)) { 1107 // If the senders are empty, just use the subject/snippet. 1108 return subjectSnippet; 1109 } else if (TextUtils.isEmpty(subjectSnippet)) { 1110 // If the subject/snippet is empty, just use the senders. 1111 final SpannableString spannableString = new SpannableString(senders); 1112 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0); 1113 1114 return spannableString; 1115 } else { 1116 final String formatString = context.getResources().getString( 1117 R.string.multiple_new_message_notification_item); 1118 final TextAppearanceSpan notificationSecondarySpan = 1119 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 1120 1121 // senders is already individually unicode wrapped so it does not need to be done here 1122 final String instantiatedString = String.format(formatString, 1123 senders, 1124 BIDI_FORMATTER.unicodeWrap(subjectSnippet)); 1125 1126 final SpannableString spannableString = new SpannableString(instantiatedString); 1127 1128 final boolean isOrderReversed = formatString.indexOf("%2$s") < 1129 formatString.indexOf("%1$s"); 1130 final int primaryOffset = 1131 (isOrderReversed ? instantiatedString.lastIndexOf(senders) : 1132 instantiatedString.indexOf(senders)); 1133 final int secondaryOffset = 1134 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) : 1135 instantiatedString.indexOf(subjectSnippet)); 1136 spannableString.setSpan(notificationPrimarySpan, 1137 primaryOffset, primaryOffset + senders.length(), 0); 1138 spannableString.setSpan(notificationSecondarySpan, 1139 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0); 1140 return spannableString; 1141 } 1142 } 1143 1144 /** 1145 * Sets the bigtext for a notification for a single new conversation 1146 * @param context 1147 * @param subject Subject of the new message that triggered the notification 1148 * @return a {@link CharSequence} suitable for use in 1149 * {@link NotificationCompat.Builder#setContentText} 1150 */ 1151 private static CharSequence getSingleMessageLittleText(Context context, String subject) { 1152 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 1153 context, R.style.NotificationPrimaryText); 1154 1155 final SpannableString spannableString = new SpannableString(subject); 1156 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 1157 1158 return spannableString; 1159 } 1160 1161 /** 1162 * Sets the bigtext for a notification for a single new conversation 1163 * 1164 * @param context 1165 * @param subject Subject of the new message that triggered the notification 1166 * @param message the {@link Message} to be displayed. 1167 * @return a {@link CharSequence} suitable for use in 1168 * {@link android.support.v4.app.NotificationCompat.BigTextStyle} 1169 */ 1170 private static CharSequence getSingleMessageBigText(Context context, String subject, 1171 final Message message) { 1172 1173 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 1174 context, R.style.NotificationPrimaryText); 1175 1176 final String snippet = getMessageBodyWithoutElidedText(message); 1177 1178 // Change multiple newlines (with potential white space between), into a single new line 1179 final String collapsedSnippet = 1180 !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : ""; 1181 1182 if (TextUtils.isEmpty(subject)) { 1183 // If the subject is empty, just use the snippet. 1184 return snippet; 1185 } else if (TextUtils.isEmpty(collapsedSnippet)) { 1186 // If the snippet is empty, just use the subject. 1187 final SpannableString spannableString = new SpannableString(subject); 1188 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 1189 1190 return spannableString; 1191 } else { 1192 final String notificationBigTextFormat = context.getResources().getString( 1193 R.string.single_new_message_notification_big_text); 1194 1195 // Localizers may change the order of the parameters, look at how the format 1196 // string is structured. 1197 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") > 1198 notificationBigTextFormat.indexOf("%1$s"); 1199 final String bigText = 1200 String.format(notificationBigTextFormat, subject, collapsedSnippet); 1201 final SpannableString spannableString = new SpannableString(bigText); 1202 1203 final int subjectOffset = 1204 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject)); 1205 spannableString.setSpan(notificationSubjectSpan, 1206 subjectOffset, subjectOffset + subject.length(), 0); 1207 1208 return spannableString; 1209 } 1210 } 1211 1212 /** 1213 * Gets the title for a notification for a single new conversation 1214 * @param context 1215 * @param sender Sender of the new message that triggered the notification. 1216 * @param subject Subject of the new message that triggered the notification 1217 * @return a {@link CharSequence} suitable for use as a {@link Notification} title. 1218 */ 1219 private static CharSequence getSingleMessageNotificationTitle(Context context, 1220 String sender, String subject) { 1221 1222 if (TextUtils.isEmpty(subject)) { 1223 // If the subject is empty, just set the title to the sender's information. 1224 return sender; 1225 } else { 1226 final String notificationTitleFormat = context.getResources().getString( 1227 R.string.single_new_message_notification_title); 1228 1229 // Localizers may change the order of the parameters, look at how the format 1230 // string is structured. 1231 final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") > 1232 notificationTitleFormat.indexOf("%1$s"); 1233 final String titleString = String.format(notificationTitleFormat, sender, subject); 1234 1235 // Format the string so the subject is using the secondaryText style 1236 final SpannableString titleSpannable = new SpannableString(titleString); 1237 1238 // Find the offset of the subject. 1239 final int subjectOffset = 1240 isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject); 1241 final TextAppearanceSpan notificationSubjectSpan = 1242 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 1243 titleSpannable.setSpan(notificationSubjectSpan, 1244 subjectOffset, subjectOffset + subject.length(), 0); 1245 return titleSpannable; 1246 } 1247 } 1248 1249 /** 1250 * Clears the notifications for the specified account/folder. 1251 */ 1252 public static void clearFolderNotification(Context context, Account account, Folder folder, 1253 final boolean markSeen) { 1254 LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.name, folder.name); 1255 final NotificationMap notificationMap = getNotificationMap(context); 1256 final NotificationKey key = new NotificationKey(account, folder); 1257 notificationMap.remove(key); 1258 notificationMap.saveNotificationMap(context); 1259 1260 final NotificationManager notificationManager = 1261 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 1262 notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder)); 1263 1264 if (markSeen) { 1265 markSeen(context, folder); 1266 } 1267 } 1268 1269 /** 1270 * Clears all notifications for the specified account. 1271 */ 1272 public static void clearAccountNotifications(final Context context, 1273 final android.accounts.Account account) { 1274 LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account); 1275 final NotificationMap notificationMap = getNotificationMap(context); 1276 1277 // Find all NotificationKeys for this account 1278 final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder(); 1279 1280 for (final NotificationKey key : notificationMap.keySet()) { 1281 if (account.equals(key.account.getAccountManagerAccount())) { 1282 keyBuilder.add(key); 1283 } 1284 } 1285 1286 final List<NotificationKey> notificationKeys = keyBuilder.build(); 1287 1288 final NotificationManager notificationManager = 1289 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 1290 1291 for (final NotificationKey notificationKey : notificationKeys) { 1292 final Folder folder = notificationKey.folder; 1293 notificationManager.cancel(getNotificationId(account, folder)); 1294 notificationMap.remove(notificationKey); 1295 } 1296 1297 notificationMap.saveNotificationMap(context); 1298 } 1299 1300 private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) { 1301 ArrayList<String> whereArgs = new ArrayList<String>(); 1302 StringBuilder whereBuilder = new StringBuilder(); 1303 String[] questionMarks = new String[addresses.size()]; 1304 1305 whereArgs.addAll(addresses); 1306 Arrays.fill(questionMarks, "?"); 1307 whereBuilder.append(Email.DATA1 + " IN ("). 1308 append(TextUtils.join(",", questionMarks)). 1309 append(")"); 1310 1311 ContentResolver resolver = context.getContentResolver(); 1312 Cursor c = resolver.query(Email.CONTENT_URI, 1313 new String[]{Email.CONTACT_ID}, whereBuilder.toString(), 1314 whereArgs.toArray(new String[0]), null); 1315 1316 ArrayList<Long> contactIds = new ArrayList<Long>(); 1317 if (c == null) { 1318 return contactIds; 1319 } 1320 try { 1321 while (c.moveToNext()) { 1322 contactIds.add(c.getLong(0)); 1323 } 1324 } finally { 1325 c.close(); 1326 } 1327 return contactIds; 1328 } 1329 1330 private static Bitmap getContactIcon(final Context context, final String displayName, 1331 final String senderAddress, final Folder folder) { 1332 if (senderAddress == null) { 1333 return null; 1334 } 1335 1336 Bitmap icon = null; 1337 1338 final List<Long> contactIds = findContacts( context, Arrays.asList( 1339 new String[] { senderAddress })); 1340 1341 // Get the ideal size for this icon. 1342 final Resources res = context.getResources(); 1343 final int idealIconHeight = 1344 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); 1345 final int idealIconWidth = 1346 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); 1347 1348 if (contactIds != null) { 1349 for (final long id : contactIds) { 1350 final Uri contactUri = 1351 ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id); 1352 final Uri photoUri = Uri.withAppendedPath(contactUri, Photo.CONTENT_DIRECTORY); 1353 final Cursor cursor = context.getContentResolver().query( 1354 photoUri, new String[] { Photo.PHOTO }, null, null, null); 1355 1356 if (cursor != null) { 1357 try { 1358 if (cursor.moveToFirst()) { 1359 final byte[] data = cursor.getBlob(0); 1360 if (data != null) { 1361 icon = BitmapFactory.decodeStream(new ByteArrayInputStream(data)); 1362 if (icon != null && icon.getHeight() < idealIconHeight) { 1363 // We should scale this image to fit the intended size 1364 icon = Bitmap.createScaledBitmap( 1365 icon, idealIconWidth, idealIconHeight, true); 1366 } 1367 if (icon != null) { 1368 break; 1369 } 1370 } 1371 } 1372 } finally { 1373 cursor.close(); 1374 } 1375 } 1376 } 1377 } 1378 1379 if (icon == null) { 1380 // Make a colorful tile! 1381 final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight, 1382 Dimensions.SCALE_ONE); 1383 1384 icon = new LetterTileProvider(context).getLetterTile(dimensions, 1385 displayName, senderAddress); 1386 } 1387 1388 if (icon == null) { 1389 // Icon should be the default mail icon. 1390 icon = getDefaultNotificationIcon(context, folder, false /* single new message */); 1391 } 1392 return icon; 1393 } 1394 1395 private static String getMessageBodyWithoutElidedText(final Message message) { 1396 return getMessageBodyWithoutElidedText(message.getBodyAsHtml()); 1397 } 1398 1399 public static String getMessageBodyWithoutElidedText(String html) { 1400 if (TextUtils.isEmpty(html)) { 1401 return ""; 1402 } 1403 // Get the html "tree" for this message body 1404 final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html); 1405 htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY); 1406 1407 return htmlTree.getPlainText(); 1408 } 1409 1410 public static void markSeen(final Context context, final Folder folder) { 1411 final Uri uri = folder.folderUri.fullUri; 1412 1413 final ContentValues values = new ContentValues(1); 1414 values.put(UIProvider.ConversationColumns.SEEN, 1); 1415 1416 context.getContentResolver().update(uri, values, null, null); 1417 } 1418 1419 /** 1420 * Returns a displayable string representing 1421 * the message sender. It has a preference toward showing the name, 1422 * but will fall back to the address if that is all that is available. 1423 */ 1424 private static String getDisplayableSender(String sender) { 1425 final EmailAddress address = EmailAddress.getEmailAddress(sender); 1426 1427 String displayableSender = address.getName(); 1428 1429 if (!TextUtils.isEmpty(displayableSender)) { 1430 return Address.decodeAddressName(displayableSender); 1431 } 1432 1433 // If that fails, default to the sender address. 1434 displayableSender = address.getAddress(); 1435 1436 // If we were unable to tokenize a name or address, 1437 // just use whatever was in the sender. 1438 if (TextUtils.isEmpty(displayableSender)) { 1439 displayableSender = sender; 1440 } 1441 return displayableSender; 1442 } 1443 1444 /** 1445 * Returns only the address portion of a message sender. 1446 */ 1447 private static String getSenderAddress(String sender) { 1448 final EmailAddress address = EmailAddress.getEmailAddress(sender); 1449 1450 String tokenizedAddress = address.getAddress(); 1451 1452 // If we were unable to tokenize a name or address, 1453 // just use whatever was in the sender. 1454 if (TextUtils.isEmpty(tokenizedAddress)) { 1455 tokenizedAddress = sender; 1456 } 1457 return tokenizedAddress; 1458 } 1459 1460 public static int getNotificationId(final android.accounts.Account account, 1461 final Folder folder) { 1462 return 1 ^ account.hashCode() ^ folder.hashCode(); 1463 } 1464 1465 private static class NotificationKey { 1466 public final Account account; 1467 public final Folder folder; 1468 1469 public NotificationKey(Account account, Folder folder) { 1470 this.account = account; 1471 this.folder = folder; 1472 } 1473 1474 @Override 1475 public boolean equals(Object other) { 1476 if (!(other instanceof NotificationKey)) { 1477 return false; 1478 } 1479 NotificationKey key = (NotificationKey) other; 1480 return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount()) 1481 && folder.equals(key.folder); 1482 } 1483 1484 @Override 1485 public String toString() { 1486 return account.name + " " + folder.name; 1487 } 1488 1489 @Override 1490 public int hashCode() { 1491 final int accountHashCode = account.getAccountManagerAccount().hashCode(); 1492 final int folderHashCode = folder.hashCode(); 1493 return accountHashCode ^ folderHashCode; 1494 } 1495 } 1496 1497 /** 1498 * Contains the logic for converting the contents of one HtmlTree into 1499 * plaintext. 1500 */ 1501 public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter { 1502 // Strings for parsing html message bodies 1503 private static final String ELIDED_TEXT_ELEMENT_NAME = "div"; 1504 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class"; 1505 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text"; 1506 1507 private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE = 1508 new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE); 1509 1510 private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE = 1511 HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null); 1512 1513 private int mEndNodeElidedTextBlock = -1; 1514 1515 @Override 1516 public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) { 1517 // If we are in the middle of an elided text block, don't add this node 1518 if (nodeNum < mEndNodeElidedTextBlock) { 1519 return; 1520 } else if (nodeNum == mEndNodeElidedTextBlock) { 1521 super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum); 1522 return; 1523 } 1524 1525 // If this tag starts another elided text block, we want to remember the end 1526 if (n instanceof HtmlDocument.Tag) { 1527 boolean foundElidedTextTag = false; 1528 final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n; 1529 final HTML.Element htmlElement = htmlTag.getElement(); 1530 if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) { 1531 // Make sure that the class is what is expected 1532 final List<HtmlDocument.TagAttribute> attributes = 1533 htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE); 1534 for (HtmlDocument.TagAttribute attribute : attributes) { 1535 if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals( 1536 attribute.getValue())) { 1537 // Found an "elided-text" div. Remember information about this tag 1538 mEndNodeElidedTextBlock = endNum; 1539 foundElidedTextTag = true; 1540 break; 1541 } 1542 } 1543 } 1544 1545 if (foundElidedTextTag) { 1546 return; 1547 } 1548 } 1549 1550 super.addNode(n, nodeNum, endNum); 1551 } 1552 } 1553 1554 /** 1555 * During account setup in Email, we may not have an inbox yet, so the notification setting had 1556 * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the 1557 * {@link FolderPreferences} now. 1558 */ 1559 public static void moveNotificationSetting(final AccountPreferences accountPreferences, 1560 final FolderPreferences folderPreferences) { 1561 if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) { 1562 // If this setting has been changed some other way, don't overwrite it 1563 if (!folderPreferences.isNotificationsEnabledSet()) { 1564 final boolean notificationsEnabled = 1565 accountPreferences.getDefaultInboxNotificationsEnabled(); 1566 1567 folderPreferences.setNotificationsEnabled(notificationsEnabled); 1568 } 1569 1570 accountPreferences.clearDefaultInboxNotificationsEnabled(); 1571 } 1572 } 1573 } 1574