1 /* 2 * Copyright (C) 2010 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 17 package com.android.email; 18 19 import android.app.Notification; 20 import android.app.Notification.Builder; 21 import android.app.NotificationManager; 22 import android.app.PendingIntent; 23 import android.content.ContentResolver; 24 import android.content.ContentUris; 25 import android.content.ContentValues; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.res.Resources; 29 import android.database.ContentObserver; 30 import android.database.Cursor; 31 import android.graphics.Bitmap; 32 import android.graphics.BitmapFactory; 33 import android.media.AudioManager; 34 import android.net.Uri; 35 import android.os.Build; 36 import android.os.Handler; 37 import android.os.Looper; 38 import android.os.Process; 39 import android.text.SpannableString; 40 import android.text.TextUtils; 41 import android.text.style.TextAppearanceSpan; 42 import android.util.Log; 43 44 import com.android.email.activity.ContactStatusLoader; 45 import com.android.email.activity.Welcome; 46 import com.android.email.activity.setup.AccountSecurity; 47 import com.android.email.activity.setup.AccountSettings; 48 import com.android.emailcommon.Logging; 49 import com.android.emailcommon.mail.Address; 50 import com.android.emailcommon.provider.Account; 51 import com.android.emailcommon.provider.EmailContent; 52 import com.android.emailcommon.provider.EmailContent.AccountColumns; 53 import com.android.emailcommon.provider.EmailContent.Attachment; 54 import com.android.emailcommon.provider.EmailContent.MailboxColumns; 55 import com.android.emailcommon.provider.EmailContent.Message; 56 import com.android.emailcommon.provider.EmailContent.MessageColumns; 57 import com.android.emailcommon.provider.Mailbox; 58 import com.android.emailcommon.utility.Utility; 59 import com.google.common.annotations.VisibleForTesting; 60 61 import java.util.HashMap; 62 import java.util.HashSet; 63 64 /** 65 * Class that manages notifications. 66 */ 67 public class NotificationController { 68 private static final int NOTIFICATION_ID_SECURITY_NEEDED = 1; 69 /** Reserved for {@link com.android.exchange.CalendarSyncEnabler} */ 70 @SuppressWarnings("unused") 71 private static final int NOTIFICATION_ID_EXCHANGE_CALENDAR_ADDED = 2; 72 private static final int NOTIFICATION_ID_ATTACHMENT_WARNING = 3; 73 private static final int NOTIFICATION_ID_PASSWORD_EXPIRING = 4; 74 private static final int NOTIFICATION_ID_PASSWORD_EXPIRED = 5; 75 76 private static final int NOTIFICATION_ID_BASE_NEW_MESSAGES = 0x10000000; 77 private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000; 78 79 /** Selection to retrieve accounts that should we notify user for changes */ 80 private final static String NOTIFIED_ACCOUNT_SELECTION = 81 Account.FLAGS + "&" + Account.FLAGS_NOTIFY_NEW_MAIL + " != 0"; 82 83 private static NotificationThread sNotificationThread; 84 private static Handler sNotificationHandler; 85 private static NotificationController sInstance; 86 private final Context mContext; 87 private final NotificationManager mNotificationManager; 88 private final AudioManager mAudioManager; 89 private final Bitmap mGenericSenderIcon; 90 private final Bitmap mGenericMultipleSenderIcon; 91 private final Clock mClock; 92 // TODO We're maintaining all of our structures based upon the account ID. This is fine 93 // for now since the assumption is that we only ever look for changes in an account's 94 // INBOX. We should adjust our logic to use the mailbox ID instead. 95 /** Maps account id to the message data */ 96 private final HashMap<Long, ContentObserver> mNotificationMap; 97 private ContentObserver mAccountObserver; 98 /** 99 * Suspend notifications for this account. If {@link Account#NO_ACCOUNT}, no 100 * account notifications are suspended. If {@link Account#ACCOUNT_ID_COMBINED_VIEW}, 101 * notifications for all accounts are suspended. 102 */ 103 private long mSuspendAccountId = Account.NO_ACCOUNT; 104 105 /** 106 * Timestamp indicating when the last message notification sound was played. 107 * Used for throttling. 108 */ 109 private long mLastMessageNotifyTime; 110 111 /** 112 * Minimum interval between notification sounds. 113 * Since a long sync (either on account setup or after a long period of being offline) can cause 114 * several notifications consecutively, it can be pretty overwhelming to get a barrage of 115 * notification sounds. Throttle them using this value. 116 */ 117 private static final long MIN_SOUND_INTERVAL_MS = 15 * 1000; // 15 seconds 118 isRunningJellybeanOrLater()119 private static boolean isRunningJellybeanOrLater() { 120 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; 121 } 122 123 /** Constructor */ 124 @VisibleForTesting NotificationController(Context context, Clock clock)125 NotificationController(Context context, Clock clock) { 126 mContext = context.getApplicationContext(); 127 mNotificationManager = (NotificationManager) context.getSystemService( 128 Context.NOTIFICATION_SERVICE); 129 mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); 130 mGenericSenderIcon = BitmapFactory.decodeResource(mContext.getResources(), 131 R.drawable.ic_contact_picture); 132 mGenericMultipleSenderIcon = BitmapFactory.decodeResource(mContext.getResources(), 133 R.drawable.ic_notification_multiple_mail_holo_dark); 134 mClock = clock; 135 mNotificationMap = new HashMap<Long, ContentObserver>(); 136 } 137 138 /** Singleton access */ getInstance(Context context)139 public static synchronized NotificationController getInstance(Context context) { 140 if (sInstance == null) { 141 sInstance = new NotificationController(context, Clock.INSTANCE); 142 } 143 return sInstance; 144 } 145 146 /** 147 * Return whether or not a notification, based on the passed-in id, needs to be "ongoing" 148 * @param notificationId the notification id to check 149 * @return whether or not the notification must be "ongoing" 150 */ needsOngoingNotification(int notificationId)151 private boolean needsOngoingNotification(int notificationId) { 152 // "Security needed" must be ongoing so that the user doesn't close it; otherwise, sync will 153 // be prevented until a reboot. Consider also doing this for password expired. 154 return notificationId == NOTIFICATION_ID_SECURITY_NEEDED; 155 } 156 157 /** 158 * Returns a {@link Notification.Builder} for an event with the given account. The account 159 * contains specific rules on ring tone usage and these will be used to modify the notification 160 * behaviour. 161 * 162 * @param account The account this notification is being built for. 163 * @param ticker Text displayed when the notification is first shown. May be {@code null}. 164 * @param title The first line of text. May NOT be {@code null}. 165 * @param contentText The second line of text. May NOT be {@code null}. 166 * @param intent The intent to start if the user clicks on the notification. 167 * @param largeIcon A large icon. May be {@code null} 168 * @param number A number to display using {@link Builder#setNumber(int)}. May 169 * be {@code null}. 170 * @param enableAudio If {@code false}, do not play any sound. Otherwise, play sound according 171 * to the settings for the given account. 172 * @return A {@link Notification} that can be sent to the notification service. 173 */ createBaseAccountNotificationBuilder(Account account, String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon, Integer number, boolean enableAudio, boolean ongoing)174 private Notification.Builder createBaseAccountNotificationBuilder(Account account, 175 String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon, 176 Integer number, boolean enableAudio, boolean ongoing) { 177 // Pending Intent 178 PendingIntent pending = null; 179 if (intent != null) { 180 pending = PendingIntent.getActivity( 181 mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 182 } 183 184 // NOTE: the ticker is not shown for notifications in the Holo UX 185 final Notification.Builder builder = new Notification.Builder(mContext) 186 .setContentTitle(title) 187 .setContentText(contentText) 188 .setContentIntent(pending) 189 .setLargeIcon(largeIcon) 190 .setNumber(number == null ? 0 : number) 191 .setSmallIcon(R.drawable.stat_notify_email_generic) 192 .setWhen(mClock.getTime()) 193 .setTicker(ticker) 194 .setOngoing(ongoing); 195 196 if (enableAudio) { 197 setupSoundAndVibration(builder, account); 198 } 199 200 return builder; 201 } 202 203 /** 204 * Generic notifier for any account. Uses notification rules from account. 205 * 206 * @param account The account this notification is being built for. 207 * @param ticker Text displayed when the notification is first shown. May be {@code null}. 208 * @param title The first line of text. May NOT be {@code null}. 209 * @param contentText The second line of text. May NOT be {@code null}. 210 * @param intent The intent to start if the user clicks on the notification. 211 * @param notificationId The ID of the notification to register with the service. 212 */ showAccountNotification(Account account, String ticker, String title, String contentText, Intent intent, int notificationId)213 private void showAccountNotification(Account account, String ticker, String title, 214 String contentText, Intent intent, int notificationId) { 215 Notification.Builder builder = createBaseAccountNotificationBuilder(account, ticker, title, 216 contentText, intent, null, null, true, needsOngoingNotification(notificationId)); 217 mNotificationManager.notify(notificationId, builder.getNotification()); 218 } 219 220 /** 221 * Returns a notification ID for new message notifications for the given account. 222 */ getNewMessageNotificationId(long accountId)223 private int getNewMessageNotificationId(long accountId) { 224 // We assume accountId will always be less than 0x0FFFFFFF; is there a better way? 225 return (int) (NOTIFICATION_ID_BASE_NEW_MESSAGES + accountId); 226 } 227 228 /** 229 * Tells the notification controller if it should be watching for changes to the message table. 230 * This is the main life cycle method for message notifications. When we stop observing 231 * database changes, we save the state [e.g. message ID and count] of the most recent 232 * notification shown to the user. And, when we start observing database changes, we restore 233 * the saved state. 234 * @param watch If {@code true}, we register observers for all accounts whose settings have 235 * notifications enabled. Otherwise, all observers are unregistered. 236 */ watchForMessages(final boolean watch)237 public void watchForMessages(final boolean watch) { 238 if (Email.DEBUG) { 239 Log.i(Logging.LOG_TAG, "Notifications being toggled: " + watch); 240 } 241 // Don't create the thread if we're only going to stop watching 242 if (!watch && sNotificationThread == null) return; 243 244 ensureHandlerExists(); 245 // Run this on the message notification handler 246 sNotificationHandler.post(new Runnable() { 247 @Override 248 public void run() { 249 ContentResolver resolver = mContext.getContentResolver(); 250 if (!watch) { 251 unregisterMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW); 252 if (mAccountObserver != null) { 253 resolver.unregisterContentObserver(mAccountObserver); 254 mAccountObserver = null; 255 } 256 257 // tear down the event loop 258 sNotificationThread.quit(); 259 sNotificationThread = null; 260 return; 261 } 262 263 // otherwise, start new observers for all notified accounts 264 registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW); 265 // If we're already observing account changes, don't do anything else 266 if (mAccountObserver == null) { 267 if (Email.DEBUG) { 268 Log.i(Logging.LOG_TAG, "Observing account changes for notifications"); 269 } 270 mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext); 271 resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver); 272 } 273 } 274 }); 275 } 276 277 /** 278 * Temporarily suspend a single account from receiving notifications. NOTE: only a single 279 * account may ever be suspended at a time. So, if this method is invoked a second time, 280 * notifications for the previously suspended account will automatically be re-activated. 281 * @param suspend If {@code true}, suspend notifications for the given account. Otherwise, 282 * re-activate notifications for the previously suspended account. 283 * @param accountId The ID of the account. If this is the special account ID 284 * {@link Account#ACCOUNT_ID_COMBINED_VIEW}, notifications for all accounts are 285 * suspended. If {@code suspend} is {@code false}, the account ID is ignored. 286 */ suspendMessageNotification(boolean suspend, long accountId)287 public void suspendMessageNotification(boolean suspend, long accountId) { 288 if (mSuspendAccountId != Account.NO_ACCOUNT) { 289 // we're already suspending an account; un-suspend it 290 mSuspendAccountId = Account.NO_ACCOUNT; 291 } 292 if (suspend && accountId != Account.NO_ACCOUNT && accountId > 0L) { 293 mSuspendAccountId = accountId; 294 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 295 // Only go onto the notification handler if we really, absolutely need to 296 ensureHandlerExists(); 297 sNotificationHandler.post(new Runnable() { 298 @Override 299 public void run() { 300 for (long accountId : mNotificationMap.keySet()) { 301 mNotificationManager.cancel(getNewMessageNotificationId(accountId)); 302 } 303 } 304 }); 305 } else { 306 mNotificationManager.cancel(getNewMessageNotificationId(accountId)); 307 } 308 } 309 } 310 311 /** 312 * Ensures the notification handler exists and is ready to handle requests. 313 */ ensureHandlerExists()314 private static synchronized void ensureHandlerExists() { 315 if (sNotificationThread == null) { 316 sNotificationThread = new NotificationThread(); 317 sNotificationHandler = new Handler(sNotificationThread.getLooper()); 318 } 319 } 320 321 /** 322 * Registers an observer for changes to the INBOX for the given account. Since accounts 323 * may only have a single INBOX, we will never have more than one observer for an account. 324 * NOTE: This must be called on the notification handler thread. 325 * @param accountId The ID of the account to register the observer for. May be 326 * {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all 327 * accounts that allow for user notification. 328 */ registerMessageNotification(long accountId)329 private void registerMessageNotification(long accountId) { 330 ContentResolver resolver = mContext.getContentResolver(); 331 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 332 Cursor c = resolver.query( 333 Account.CONTENT_URI, EmailContent.ID_PROJECTION, 334 NOTIFIED_ACCOUNT_SELECTION, null, null); 335 try { 336 while (c.moveToNext()) { 337 long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 338 registerMessageNotification(id); 339 } 340 } finally { 341 c.close(); 342 } 343 } else { 344 ContentObserver obs = mNotificationMap.get(accountId); 345 if (obs != null) return; // we're already observing; nothing to do 346 347 Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, accountId, Mailbox.TYPE_INBOX); 348 if (mailbox == null) { 349 Log.w(Logging.LOG_TAG, "Could not load INBOX for account id: " + accountId); 350 return; 351 } 352 if (Email.DEBUG) { 353 Log.i(Logging.LOG_TAG, "Registering for notifications for account " + accountId); 354 } 355 ContentObserver observer = new MessageContentObserver( 356 sNotificationHandler, mContext, mailbox.mId, accountId); 357 resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer); 358 mNotificationMap.put(accountId, observer); 359 // Now, ping the observer for any initial notifications 360 observer.onChange(true); 361 } 362 } 363 364 /** 365 * Unregisters the observer for the given account. If the specified account does not have 366 * a registered observer, no action is performed. This will not clear any existing notification 367 * for the specified account. Use {@link NotificationManager#cancel(int)}. 368 * NOTE: This must be called on the notification handler thread. 369 * @param accountId The ID of the account to unregister from. To unregister all accounts that 370 * have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}. 371 */ unregisterMessageNotification(long accountId)372 private void unregisterMessageNotification(long accountId) { 373 ContentResolver resolver = mContext.getContentResolver(); 374 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 375 if (Email.DEBUG) { 376 Log.i(Logging.LOG_TAG, "Unregistering notifications for all accounts"); 377 } 378 // cancel all existing message observers 379 for (ContentObserver observer : mNotificationMap.values()) { 380 resolver.unregisterContentObserver(observer); 381 } 382 mNotificationMap.clear(); 383 } else { 384 if (Email.DEBUG) { 385 Log.i(Logging.LOG_TAG, "Unregistering notifications for account " + accountId); 386 } 387 ContentObserver observer = mNotificationMap.remove(accountId); 388 if (observer != null) { 389 resolver.unregisterContentObserver(observer); 390 } 391 } 392 } 393 394 /** 395 * Returns a picture of the sender of the given message. If no picture is available, returns 396 * {@code null}. 397 * 398 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 399 */ getSenderPhoto(Message message)400 private Bitmap getSenderPhoto(Message message) { 401 Address sender = Address.unpackFirst(message.mFrom); 402 if (sender == null) { 403 return null; 404 } 405 String email = sender.getAddress(); 406 if (TextUtils.isEmpty(email)) { 407 return null; 408 } 409 Bitmap photo = ContactStatusLoader.getContactInfo(mContext, email).mPhoto; 410 411 if (photo != null) { 412 final Resources res = mContext.getResources(); 413 final int idealIconHeight = 414 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); 415 final int idealIconWidth = 416 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); 417 418 if (photo.getHeight() < idealIconHeight) { 419 // We should scale this image to fit the intended size 420 photo = Bitmap.createScaledBitmap( 421 photo, idealIconWidth, idealIconHeight, true); 422 } 423 } 424 return photo; 425 } 426 427 /** 428 * Returns a "new message" notification for the given account. 429 * 430 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 431 */ 432 @VisibleForTesting createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor, long newestMessageId, int unseenMessageCount, int unreadCount)433 Notification createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor, 434 long newestMessageId, int unseenMessageCount, int unreadCount) { 435 final Account account = Account.restoreAccountWithId(mContext, accountId); 436 if (account == null) { 437 return null; 438 } 439 // Get the latest message 440 final Message message = Message.restoreMessageWithId(mContext, newestMessageId); 441 if (message == null) { 442 return null; // no message found??? 443 } 444 445 String senderName = Address.toFriendly(Address.unpack(message.mFrom)); 446 if (senderName == null) { 447 senderName = ""; // Happens when a message has no from. 448 } 449 final boolean multipleUnseen = unseenMessageCount > 1; 450 final Bitmap senderPhoto = multipleUnseen 451 ? mGenericMultipleSenderIcon 452 : getSenderPhoto(message); 453 final SpannableString title = getNewMessageTitle(senderName, unseenMessageCount); 454 // TODO: add in display name on the second line for the text, once framework supports 455 // multiline texts. 456 final String text = multipleUnseen 457 ? account.mDisplayName 458 : message.mSubject; 459 final Bitmap largeIcon = senderPhoto != null ? senderPhoto : mGenericSenderIcon; 460 final Integer number = unreadCount > 1 ? unreadCount : null; 461 final Intent intent; 462 if (unseenMessageCount > 1) { 463 intent = Welcome.createOpenAccountInboxIntent(mContext, accountId); 464 } else { 465 intent = Welcome.createOpenMessageIntent( 466 mContext, accountId, mailboxId, newestMessageId); 467 } 468 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 469 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 470 long now = mClock.getTime(); 471 boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS; 472 final Notification.Builder builder = createBaseAccountNotificationBuilder( 473 account, title.toString(), title, text, 474 intent, largeIcon, number, enableAudio, false); 475 if (isRunningJellybeanOrLater()) { 476 // For a new-style notification 477 if (multipleUnseen) { 478 if (messageCursor != null) { 479 final int maxNumDigestItems = mContext.getResources().getInteger( 480 R.integer.max_num_notification_digest_items); 481 // The body of the notification is the account name, or the label name. 482 builder.setSubText(text); 483 484 Notification.InboxStyle digest = new Notification.InboxStyle(builder); 485 486 digest.setBigContentTitle(title); 487 488 int numDigestItems = 0; 489 // We can assume that the current position of the cursor is on the 490 // newest message 491 do { 492 final long messageId = 493 messageCursor.getLong(EmailContent.ID_PROJECTION_COLUMN); 494 495 // Get the latest message 496 final Message digestMessage = 497 Message.restoreMessageWithId(mContext, messageId); 498 if (digestMessage != null) { 499 final CharSequence digestLine = 500 getSingleMessageInboxLine(mContext, digestMessage); 501 digest.addLine(digestLine); 502 numDigestItems++; 503 } 504 } while (numDigestItems <= maxNumDigestItems && messageCursor.moveToNext()); 505 506 // We want to clear the content text in this case. The content text would have 507 // been set in createBaseAccountNotificationBuilder, but since the same string 508 // was set in as the subtext, we don't want to show a duplicate string. 509 builder.setContentText(null); 510 } 511 } else { 512 // The notification content will be the subject of the conversation. 513 builder.setContentText(getSingleMessageLittleText(mContext, message.mSubject)); 514 515 // The notification subtext will be the subject of the conversation for inbox 516 // notifications, or will based on the the label name for user label notifications. 517 builder.setSubText(account.mDisplayName); 518 519 final Notification.BigTextStyle bigText = new Notification.BigTextStyle(builder); 520 bigText.bigText(getSingleMessageBigText(mContext, message)); 521 } 522 } 523 524 mLastMessageNotifyTime = now; 525 return builder.getNotification(); 526 } 527 528 /** 529 * Sets the bigtext for a notification for a single new conversation 530 * @param context 531 * @param message New message that triggered the notification. 532 * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle} 533 */ getSingleMessageInboxLine(Context context, Message message)534 private static CharSequence getSingleMessageInboxLine(Context context, Message message) { 535 final String subject = message.mSubject; 536 final String snippet = message.mSnippet; 537 final String senders = Address.toFriendly(Address.unpack(message.mFrom)); 538 539 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet; 540 541 final TextAppearanceSpan notificationPrimarySpan = 542 new TextAppearanceSpan(context, R.style.NotificationPrimaryText); 543 544 if (TextUtils.isEmpty(senders)) { 545 // If the senders are empty, just use the subject/snippet. 546 return subjectSnippet; 547 } 548 else if (TextUtils.isEmpty(subjectSnippet)) { 549 // If the subject/snippet is empty, just use the senders. 550 final SpannableString spannableString = new SpannableString(senders); 551 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0); 552 553 return spannableString; 554 } else { 555 final String formatString = context.getResources().getString( 556 R.string.multiple_new_message_notification_item); 557 final TextAppearanceSpan notificationSecondarySpan = 558 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 559 560 final String instantiatedString = String.format(formatString, senders, subjectSnippet); 561 562 final SpannableString spannableString = new SpannableString(instantiatedString); 563 564 final boolean isOrderReversed = formatString.indexOf("%2$s") < 565 formatString.indexOf("%1$s"); 566 final int primaryOffset = 567 (isOrderReversed ? instantiatedString.lastIndexOf(senders) : 568 instantiatedString.indexOf(senders)); 569 final int secondaryOffset = 570 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) : 571 instantiatedString.indexOf(subjectSnippet)); 572 spannableString.setSpan(notificationPrimarySpan, 573 primaryOffset, primaryOffset + senders.length(), 0); 574 spannableString.setSpan(notificationSecondarySpan, 575 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0); 576 return spannableString; 577 } 578 } 579 580 /** 581 * Sets the bigtext for a notification for a single new conversation 582 * @param context 583 * @param subject Subject of the new message that triggered the notification 584 * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText} 585 */ 586 private static CharSequence getSingleMessageLittleText(Context context, String subject) { 587 if (subject == null) { 588 return null; 589 } 590 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 591 context, R.style.NotificationPrimaryText); 592 593 final SpannableString spannableString = new SpannableString(subject); 594 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 595 596 return spannableString; 597 } 598 599 600 /** 601 * Sets the bigtext for a notification for a single new conversation 602 * @param context 603 * @param message New message that triggered the notification 604 * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle} 605 */ 606 private static CharSequence getSingleMessageBigText(Context context, Message message) { 607 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 608 context, R.style.NotificationPrimaryText); 609 610 final String subject = message.mSubject; 611 final String snippet = message.mSnippet; 612 613 if (TextUtils.isEmpty(subject)) { 614 // If the subject is empty, just use the snippet. 615 return snippet; 616 } 617 else if (TextUtils.isEmpty(snippet)) { 618 // If the snippet is empty, just use the subject. 619 final SpannableString spannableString = new SpannableString(subject); 620 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 621 622 return spannableString; 623 } else { 624 final String notificationBigTextFormat = context.getResources().getString( 625 R.string.single_new_message_notification_big_text); 626 627 // Localizers may change the order of the parameters, look at how the format 628 // string is structured. 629 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") > 630 notificationBigTextFormat.indexOf("%1$s"); 631 final String bigText = String.format(notificationBigTextFormat, subject, snippet); 632 final SpannableString spannableString = new SpannableString(bigText); 633 634 final int subjectOffset = 635 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject)); 636 spannableString.setSpan(notificationSubjectSpan, 637 subjectOffset, subjectOffset + subject.length(), 0); 638 639 return spannableString; 640 } 641 } 642 643 /** 644 * Creates a notification title for a new message. If there is only a single message, 645 * show the sender name. Otherwise, show "X new messages". 646 */ 647 @VisibleForTesting getNewMessageTitle(String sender, int unseenCount)648 SpannableString getNewMessageTitle(String sender, int unseenCount) { 649 String title; 650 if (unseenCount > 1) { 651 title = String.format( 652 mContext.getString(R.string.notification_multiple_new_messages_fmt), 653 unseenCount); 654 } else { 655 title = sender; 656 } 657 return new SpannableString(title); 658 } 659 660 /** Returns the system's current ringer mode */ 661 @VisibleForTesting getRingerMode()662 int getRingerMode() { 663 return mAudioManager.getRingerMode(); 664 } 665 666 /** Sets up the notification's sound and vibration based upon account details. */ 667 @VisibleForTesting setupSoundAndVibration(Notification.Builder builder, Account account)668 void setupSoundAndVibration(Notification.Builder builder, Account account) { 669 final int flags = account.mFlags; 670 final String ringtoneUri = account.mRingtoneUri; 671 final boolean vibrate = (flags & Account.FLAGS_VIBRATE) != 0; 672 673 int defaults = Notification.DEFAULT_LIGHTS; 674 if (vibrate) { 675 defaults |= Notification.DEFAULT_VIBRATE; 676 } 677 678 builder.setSound(TextUtils.isEmpty(ringtoneUri) ? null : Uri.parse(ringtoneUri)) 679 .setDefaults(defaults); 680 } 681 682 /** 683 * Show (or update) a notification that the given attachment could not be forwarded. This 684 * is a very unusual case, and perhaps we shouldn't even send a notification. For now, 685 * it's helpful for debugging. 686 * 687 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 688 */ showDownloadForwardFailedNotification(Attachment attachment)689 public void showDownloadForwardFailedNotification(Attachment attachment) { 690 final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey); 691 if (account == null) return; 692 showAccountNotification(account, 693 mContext.getString(R.string.forward_download_failed_ticker), 694 mContext.getString(R.string.forward_download_failed_title), 695 attachment.mFileName, 696 null, 697 NOTIFICATION_ID_ATTACHMENT_WARNING); 698 } 699 700 /** 701 * Returns a notification ID for login failed notifications for the given account account. 702 */ getLoginFailedNotificationId(long accountId)703 private int getLoginFailedNotificationId(long accountId) { 704 return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId; 705 } 706 707 /** 708 * Show (or update) a notification that there was a login failure for the given account. 709 * 710 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 711 */ showLoginFailedNotification(long accountId)712 public void showLoginFailedNotification(long accountId) { 713 final Account account = Account.restoreAccountWithId(mContext, accountId); 714 if (account == null) return; 715 showAccountNotification(account, 716 mContext.getString(R.string.login_failed_ticker, account.mDisplayName), 717 mContext.getString(R.string.login_failed_title), 718 account.getDisplayName(), 719 AccountSettings.createAccountSettingsIntent(mContext, accountId, 720 account.mDisplayName), 721 getLoginFailedNotificationId(accountId)); 722 } 723 724 /** 725 * Cancels the login failed notification for the given account. 726 */ cancelLoginFailedNotification(long accountId)727 public void cancelLoginFailedNotification(long accountId) { 728 mNotificationManager.cancel(getLoginFailedNotificationId(accountId)); 729 } 730 731 /** 732 * Show (or update) a notification that the user's password is expiring. The given account 733 * is used to update the display text, but, all accounts share the same notification ID. 734 * 735 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 736 */ showPasswordExpiringNotification(long accountId)737 public void showPasswordExpiringNotification(long accountId) { 738 Account account = Account.restoreAccountWithId(mContext, accountId); 739 if (account == null) return; 740 741 Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext, 742 accountId, false); 743 String accountName = account.getDisplayName(); 744 String ticker = 745 mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName); 746 String title = mContext.getString(R.string.password_expire_warning_content_title); 747 showAccountNotification(account, ticker, title, accountName, intent, 748 NOTIFICATION_ID_PASSWORD_EXPIRING); 749 } 750 751 /** 752 * Show (or update) a notification that the user's password has expired. The given account 753 * is used to update the display text, but, all accounts share the same notification ID. 754 * 755 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 756 */ showPasswordExpiredNotification(long accountId)757 public void showPasswordExpiredNotification(long accountId) { 758 Account account = Account.restoreAccountWithId(mContext, accountId); 759 if (account == null) return; 760 761 Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext, 762 accountId, true); 763 String accountName = account.getDisplayName(); 764 String ticker = mContext.getString(R.string.password_expired_ticker); 765 String title = mContext.getString(R.string.password_expired_content_title); 766 showAccountNotification(account, ticker, title, accountName, intent, 767 NOTIFICATION_ID_PASSWORD_EXPIRED); 768 } 769 770 /** 771 * Cancels any password expire notifications [both expired & expiring]. 772 */ cancelPasswordExpirationNotifications()773 public void cancelPasswordExpirationNotifications() { 774 mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING); 775 mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED); 776 } 777 778 /** 779 * Show (or update) a security needed notification. The given account is used to update 780 * the display text, but, all accounts share the same notification ID. 781 */ showSecurityNeededNotification(Account account)782 public void showSecurityNeededNotification(Account account) { 783 Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true); 784 String accountName = account.getDisplayName(); 785 String ticker = 786 mContext.getString(R.string.security_notification_ticker_fmt, accountName); 787 String title = mContext.getString(R.string.security_notification_content_title); 788 showAccountNotification(account, ticker, title, accountName, intent, 789 NOTIFICATION_ID_SECURITY_NEEDED); 790 } 791 792 /** 793 * Cancels the security needed notification. 794 */ cancelSecurityNeededNotification()795 public void cancelSecurityNeededNotification() { 796 mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED); 797 } 798 799 /** 800 * Observer invoked whenever a message we're notifying the user about changes. 801 */ 802 private static class MessageContentObserver extends ContentObserver { 803 /** A selection to get messages the user hasn't seen before */ 804 private final static String MESSAGE_SELECTION = 805 MessageColumns.MAILBOX_KEY + "=? AND " 806 + MessageColumns.ID + ">? AND " 807 + MessageColumns.FLAG_READ + "=0 AND " 808 + Message.FLAG_LOADED_SELECTION; 809 private final Context mContext; 810 private final long mMailboxId; 811 private final long mAccountId; 812 MessageContentObserver( Handler handler, Context context, long mailboxId, long accountId)813 public MessageContentObserver( 814 Handler handler, Context context, long mailboxId, long accountId) { 815 super(handler); 816 mContext = context; 817 mMailboxId = mailboxId; 818 mAccountId = accountId; 819 } 820 821 @Override onChange(boolean selfChange)822 public void onChange(boolean selfChange) { 823 if (mAccountId == sInstance.mSuspendAccountId 824 || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 825 return; 826 } 827 828 ContentObserver observer = sInstance.mNotificationMap.get(mAccountId); 829 if (observer == null) { 830 // Notification for a mailbox that we aren't observing; account is probably 831 // being deleted. 832 Log.w(Logging.LOG_TAG, "Received notification when observer data was null"); 833 return; 834 } 835 Account account = Account.restoreAccountWithId(mContext, mAccountId); 836 if (account == null) { 837 Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification"); 838 return; 839 } 840 long oldMessageId = account.mNotifiedMessageId; 841 int oldMessageCount = account.mNotifiedMessageCount; 842 843 ContentResolver resolver = mContext.getContentResolver(); 844 Long lastSeenMessageId = Utility.getFirstRowLong( 845 mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId), 846 new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY }, 847 null, null, null, 0); 848 if (lastSeenMessageId == null) { 849 // Mailbox got nuked. Could be that the account is in the process of being deleted 850 Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification"); 851 return; 852 } 853 854 Cursor c = resolver.query( 855 Message.CONTENT_URI, EmailContent.ID_PROJECTION, 856 MESSAGE_SELECTION, 857 new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) }, 858 MessageColumns.ID + " DESC"); 859 if (c == null) { 860 // Couldn't find message info - things may be getting deleted in bulk. 861 Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query"); 862 return; 863 } 864 try { 865 int newMessageCount = c.getCount(); 866 long newMessageId = 0L; 867 if (c.moveToNext()) { 868 newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 869 } 870 871 if (newMessageCount == 0) { 872 // No messages to notify for; clear the notification 873 int notificationId = sInstance.getNewMessageNotificationId(mAccountId); 874 sInstance.mNotificationManager.cancel(notificationId); 875 } else if (newMessageCount != oldMessageCount 876 || (newMessageId != 0 && newMessageId != oldMessageId)) { 877 // Either the count or last message has changed; update the notification 878 Integer unreadCount = Utility.getFirstRowInt( 879 mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId), 880 new String[] { MailboxColumns.UNREAD_COUNT }, 881 null, null, null, 0); 882 if (unreadCount == null) { 883 Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox"); 884 return; 885 } 886 887 Notification n = sInstance.createNewMessageNotification( 888 mAccountId, mMailboxId, c, newMessageId, 889 newMessageCount, unreadCount); 890 if (n != null) { 891 // Make the notification visible 892 sInstance.mNotificationManager.notify( 893 sInstance.getNewMessageNotificationId(mAccountId), n); 894 } 895 } 896 // Save away the new values 897 ContentValues cv = new ContentValues(); 898 cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId); 899 cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount); 900 resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv, 901 null, null); 902 } finally { 903 c.close(); 904 } 905 } 906 } 907 908 /** 909 * Observer invoked whenever an account is modified. This could mean the user changed the 910 * notification settings. 911 */ 912 private static class AccountContentObserver extends ContentObserver { 913 private final Context mContext; AccountContentObserver(Handler handler, Context context)914 public AccountContentObserver(Handler handler, Context context) { 915 super(handler); 916 mContext = context; 917 } 918 919 @Override onChange(boolean selfChange)920 public void onChange(boolean selfChange) { 921 final ContentResolver resolver = mContext.getContentResolver(); 922 final Cursor c = resolver.query( 923 Account.CONTENT_URI, EmailContent.ID_PROJECTION, 924 NOTIFIED_ACCOUNT_SELECTION, null, null); 925 final HashSet<Long> newAccountList = new HashSet<Long>(); 926 final HashSet<Long> removedAccountList = new HashSet<Long>(); 927 if (c == null) { 928 // Suspender time ... theoretically, this will never happen 929 Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query"); 930 return; 931 } 932 try { 933 while (c.moveToNext()) { 934 long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 935 newAccountList.add(accountId); 936 } 937 } finally { 938 if (c != null) { 939 c.close(); 940 } 941 } 942 // NOTE: Looping over three lists is not necessarily the most efficient. However, the 943 // account lists are going to be very small, so, this will not be necessarily bad. 944 // Cycle through existing notification list and adjust as necessary 945 for (long accountId : sInstance.mNotificationMap.keySet()) { 946 if (!newAccountList.remove(accountId)) { 947 // account id not in the current set of notifiable accounts 948 removedAccountList.add(accountId); 949 } 950 } 951 // A new account was added to the notification list 952 for (long accountId : newAccountList) { 953 sInstance.registerMessageNotification(accountId); 954 } 955 // An account was removed from the notification list 956 for (long accountId : removedAccountList) { 957 sInstance.unregisterMessageNotification(accountId); 958 int notificationId = sInstance.getNewMessageNotificationId(accountId); 959 sInstance.mNotificationManager.cancel(notificationId); 960 } 961 } 962 } 963 964 /** 965 * Thread to handle all notification actions through its own {@link Looper}. 966 */ 967 private static class NotificationThread implements Runnable { 968 /** Lock to ensure proper initialization */ 969 private final Object mLock = new Object(); 970 /** The {@link Looper} that handles messages for this thread */ 971 private Looper mLooper; 972 NotificationThread()973 NotificationThread() { 974 new Thread(null, this, "EmailNotification").start(); 975 synchronized (mLock) { 976 while (mLooper == null) { 977 try { 978 mLock.wait(); 979 } catch (InterruptedException ex) { 980 } 981 } 982 } 983 } 984 985 @Override run()986 public void run() { 987 synchronized (mLock) { 988 Looper.prepare(); 989 mLooper = Looper.myLooper(); 990 mLock.notifyAll(); 991 } 992 Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); 993 Looper.loop(); 994 } quit()995 void quit() { 996 mLooper.quit(); 997 } getLooper()998 Looper getLooper() { 999 return mLooper; 1000 } 1001 } 1002 } 1003