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 119 private static boolean isRunningJellybeanOrLater() { 120 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; 121 } 122 123 /** Constructor */ 124 @VisibleForTesting 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 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 */ 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 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 662 int getRingerMode() { 663 return mAudioManager.getRingerMode(); 664 } 665 666 /** Sets up the notification's sound and vibration based upon account details. */ 667 @VisibleForTesting 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 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 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 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; 914 public AccountContentObserver(Handler handler, Context context) { 915 super(handler); 916 mContext = context; 917 } 918 919 @Override 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 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 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 } 995 void quit() { 996 mLooper.quit(); 997 } 998 Looper getLooper() { 999 return mLooper; 1000 } 1001 } 1002 } 1003