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 17package com.android.email; 18 19import android.app.Notification; 20import android.app.Notification.Builder; 21import android.app.NotificationManager; 22import android.app.PendingIntent; 23import android.content.ContentResolver; 24import android.content.ContentUris; 25import android.content.ContentValues; 26import android.content.Context; 27import android.content.Intent; 28import android.content.res.Resources; 29import android.database.ContentObserver; 30import android.database.Cursor; 31import android.graphics.Bitmap; 32import android.graphics.BitmapFactory; 33import android.media.AudioManager; 34import android.net.Uri; 35import android.os.Build; 36import android.os.Handler; 37import android.os.Looper; 38import android.os.Process; 39import android.text.SpannableString; 40import android.text.TextUtils; 41import android.text.style.TextAppearanceSpan; 42import android.util.Log; 43 44import com.android.email.activity.ContactStatusLoader; 45import com.android.email.activity.Welcome; 46import com.android.email.activity.setup.AccountSecurity; 47import com.android.email.activity.setup.AccountSettings; 48import com.android.emailcommon.Logging; 49import com.android.emailcommon.mail.Address; 50import com.android.emailcommon.provider.Account; 51import com.android.emailcommon.provider.EmailContent; 52import com.android.emailcommon.provider.EmailContent.AccountColumns; 53import com.android.emailcommon.provider.EmailContent.Attachment; 54import com.android.emailcommon.provider.EmailContent.MailboxColumns; 55import com.android.emailcommon.provider.EmailContent.Message; 56import com.android.emailcommon.provider.EmailContent.MessageColumns; 57import com.android.emailcommon.provider.Mailbox; 58import com.android.emailcommon.utility.Utility; 59import com.google.common.annotations.VisibleForTesting; 60 61import java.util.HashMap; 62import java.util.HashSet; 63 64/** 65 * Class that manages notifications. 66 */ 67public 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_ALWAYS) != 0; 672 final boolean vibrateWhenSilent = (flags & Account.FLAGS_VIBRATE_WHEN_SILENT) != 0; 673 final boolean isRingerSilent = getRingerMode() != AudioManager.RINGER_MODE_NORMAL; 674 675 int defaults = Notification.DEFAULT_LIGHTS; 676 if (vibrate || (vibrateWhenSilent && isRingerSilent)) { 677 defaults |= Notification.DEFAULT_VIBRATE; 678 } 679 680 builder.setSound((ringtoneUri == null) ? null : Uri.parse(ringtoneUri)) 681 .setDefaults(defaults); 682 } 683 684 /** 685 * Show (or update) a notification that the given attachment could not be forwarded. This 686 * is a very unusual case, and perhaps we shouldn't even send a notification. For now, 687 * it's helpful for debugging. 688 * 689 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 690 */ 691 public void showDownloadForwardFailedNotification(Attachment attachment) { 692 final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey); 693 if (account == null) return; 694 showAccountNotification(account, 695 mContext.getString(R.string.forward_download_failed_ticker), 696 mContext.getString(R.string.forward_download_failed_title), 697 attachment.mFileName, 698 null, 699 NOTIFICATION_ID_ATTACHMENT_WARNING); 700 } 701 702 /** 703 * Returns a notification ID for login failed notifications for the given account account. 704 */ 705 private int getLoginFailedNotificationId(long accountId) { 706 return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId; 707 } 708 709 /** 710 * Show (or update) a notification that there was a login failure for the given account. 711 * 712 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 713 */ 714 public void showLoginFailedNotification(long accountId) { 715 final Account account = Account.restoreAccountWithId(mContext, accountId); 716 if (account == null) return; 717 showAccountNotification(account, 718 mContext.getString(R.string.login_failed_ticker, account.mDisplayName), 719 mContext.getString(R.string.login_failed_title), 720 account.getDisplayName(), 721 AccountSettings.createAccountSettingsIntent(mContext, accountId, 722 account.mDisplayName), 723 getLoginFailedNotificationId(accountId)); 724 } 725 726 /** 727 * Cancels the login failed notification for the given account. 728 */ 729 public void cancelLoginFailedNotification(long accountId) { 730 mNotificationManager.cancel(getLoginFailedNotificationId(accountId)); 731 } 732 733 /** 734 * Show (or update) a notification that the user's password is expiring. The given account 735 * is used to update the display text, but, all accounts share the same notification ID. 736 * 737 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 738 */ 739 public void showPasswordExpiringNotification(long accountId) { 740 Account account = Account.restoreAccountWithId(mContext, accountId); 741 if (account == null) return; 742 743 Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext, 744 accountId, false); 745 String accountName = account.getDisplayName(); 746 String ticker = 747 mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName); 748 String title = mContext.getString(R.string.password_expire_warning_content_title); 749 showAccountNotification(account, ticker, title, accountName, intent, 750 NOTIFICATION_ID_PASSWORD_EXPIRING); 751 } 752 753 /** 754 * Show (or update) a notification that the user's password has expired. The given account 755 * is used to update the display text, but, all accounts share the same notification ID. 756 * 757 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 758 */ 759 public void showPasswordExpiredNotification(long accountId) { 760 Account account = Account.restoreAccountWithId(mContext, accountId); 761 if (account == null) return; 762 763 Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext, 764 accountId, true); 765 String accountName = account.getDisplayName(); 766 String ticker = mContext.getString(R.string.password_expired_ticker); 767 String title = mContext.getString(R.string.password_expired_content_title); 768 showAccountNotification(account, ticker, title, accountName, intent, 769 NOTIFICATION_ID_PASSWORD_EXPIRED); 770 } 771 772 /** 773 * Cancels any password expire notifications [both expired & expiring]. 774 */ 775 public void cancelPasswordExpirationNotifications() { 776 mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING); 777 mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED); 778 } 779 780 /** 781 * Show (or update) a security needed notification. The given account is used to update 782 * the display text, but, all accounts share the same notification ID. 783 */ 784 public void showSecurityNeededNotification(Account account) { 785 Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true); 786 String accountName = account.getDisplayName(); 787 String ticker = 788 mContext.getString(R.string.security_notification_ticker_fmt, accountName); 789 String title = mContext.getString(R.string.security_notification_content_title); 790 showAccountNotification(account, ticker, title, accountName, intent, 791 NOTIFICATION_ID_SECURITY_NEEDED); 792 } 793 794 /** 795 * Cancels the security needed notification. 796 */ 797 public void cancelSecurityNeededNotification() { 798 mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED); 799 } 800 801 /** 802 * Observer invoked whenever a message we're notifying the user about changes. 803 */ 804 private static class MessageContentObserver extends ContentObserver { 805 /** A selection to get messages the user hasn't seen before */ 806 private final static String MESSAGE_SELECTION = 807 MessageColumns.MAILBOX_KEY + "=? AND " 808 + MessageColumns.ID + ">? AND " 809 + MessageColumns.FLAG_READ + "=0 AND " 810 + Message.FLAG_LOADED_SELECTION; 811 private final Context mContext; 812 private final long mMailboxId; 813 private final long mAccountId; 814 815 public MessageContentObserver( 816 Handler handler, Context context, long mailboxId, long accountId) { 817 super(handler); 818 mContext = context; 819 mMailboxId = mailboxId; 820 mAccountId = accountId; 821 } 822 823 @Override 824 public void onChange(boolean selfChange) { 825 if (mAccountId == sInstance.mSuspendAccountId 826 || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 827 return; 828 } 829 830 ContentObserver observer = sInstance.mNotificationMap.get(mAccountId); 831 if (observer == null) { 832 // Notification for a mailbox that we aren't observing; account is probably 833 // being deleted. 834 Log.w(Logging.LOG_TAG, "Received notification when observer data was null"); 835 return; 836 } 837 Account account = Account.restoreAccountWithId(mContext, mAccountId); 838 if (account == null) { 839 Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification"); 840 return; 841 } 842 long oldMessageId = account.mNotifiedMessageId; 843 int oldMessageCount = account.mNotifiedMessageCount; 844 845 ContentResolver resolver = mContext.getContentResolver(); 846 Long lastSeenMessageId = Utility.getFirstRowLong( 847 mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId), 848 new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY }, 849 null, null, null, 0); 850 if (lastSeenMessageId == null) { 851 // Mailbox got nuked. Could be that the account is in the process of being deleted 852 Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification"); 853 return; 854 } 855 856 Cursor c = resolver.query( 857 Message.CONTENT_URI, EmailContent.ID_PROJECTION, 858 MESSAGE_SELECTION, 859 new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) }, 860 MessageColumns.ID + " DESC"); 861 if (c == null) { 862 // Couldn't find message info - things may be getting deleted in bulk. 863 Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query"); 864 return; 865 } 866 try { 867 int newMessageCount = c.getCount(); 868 long newMessageId = 0L; 869 if (c.moveToNext()) { 870 newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 871 } 872 873 if (newMessageCount == 0) { 874 // No messages to notify for; clear the notification 875 int notificationId = sInstance.getNewMessageNotificationId(mAccountId); 876 sInstance.mNotificationManager.cancel(notificationId); 877 } else if (newMessageCount != oldMessageCount 878 || (newMessageId != 0 && newMessageId != oldMessageId)) { 879 // Either the count or last message has changed; update the notification 880 Integer unreadCount = Utility.getFirstRowInt( 881 mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId), 882 new String[] { MailboxColumns.UNREAD_COUNT }, 883 null, null, null, 0); 884 if (unreadCount == null) { 885 Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox"); 886 return; 887 } 888 889 Notification n = sInstance.createNewMessageNotification( 890 mAccountId, mMailboxId, c, newMessageId, 891 newMessageCount, unreadCount); 892 if (n != null) { 893 // Make the notification visible 894 sInstance.mNotificationManager.notify( 895 sInstance.getNewMessageNotificationId(mAccountId), n); 896 } 897 } 898 // Save away the new values 899 ContentValues cv = new ContentValues(); 900 cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId); 901 cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount); 902 resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv, 903 null, null); 904 } finally { 905 c.close(); 906 } 907 } 908 } 909 910 /** 911 * Observer invoked whenever an account is modified. This could mean the user changed the 912 * notification settings. 913 */ 914 private static class AccountContentObserver extends ContentObserver { 915 private final Context mContext; 916 public AccountContentObserver(Handler handler, Context context) { 917 super(handler); 918 mContext = context; 919 } 920 921 @Override 922 public void onChange(boolean selfChange) { 923 final ContentResolver resolver = mContext.getContentResolver(); 924 final Cursor c = resolver.query( 925 Account.CONTENT_URI, EmailContent.ID_PROJECTION, 926 NOTIFIED_ACCOUNT_SELECTION, null, null); 927 final HashSet<Long> newAccountList = new HashSet<Long>(); 928 final HashSet<Long> removedAccountList = new HashSet<Long>(); 929 if (c == null) { 930 // Suspender time ... theoretically, this will never happen 931 Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query"); 932 return; 933 } 934 try { 935 while (c.moveToNext()) { 936 long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 937 newAccountList.add(accountId); 938 } 939 } finally { 940 if (c != null) { 941 c.close(); 942 } 943 } 944 // NOTE: Looping over three lists is not necessarily the most efficient. However, the 945 // account lists are going to be very small, so, this will not be necessarily bad. 946 // Cycle through existing notification list and adjust as necessary 947 for (long accountId : sInstance.mNotificationMap.keySet()) { 948 if (!newAccountList.remove(accountId)) { 949 // account id not in the current set of notifiable accounts 950 removedAccountList.add(accountId); 951 } 952 } 953 // A new account was added to the notification list 954 for (long accountId : newAccountList) { 955 sInstance.registerMessageNotification(accountId); 956 } 957 // An account was removed from the notification list 958 for (long accountId : removedAccountList) { 959 sInstance.unregisterMessageNotification(accountId); 960 int notificationId = sInstance.getNewMessageNotificationId(accountId); 961 sInstance.mNotificationManager.cancel(notificationId); 962 } 963 } 964 } 965 966 /** 967 * Thread to handle all notification actions through its own {@link Looper}. 968 */ 969 private static class NotificationThread implements Runnable { 970 /** Lock to ensure proper initialization */ 971 private final Object mLock = new Object(); 972 /** The {@link Looper} that handles messages for this thread */ 973 private Looper mLooper; 974 975 NotificationThread() { 976 new Thread(null, this, "EmailNotification").start(); 977 synchronized (mLock) { 978 while (mLooper == null) { 979 try { 980 mLock.wait(); 981 } catch (InterruptedException ex) { 982 } 983 } 984 } 985 } 986 987 @Override 988 public void run() { 989 synchronized (mLock) { 990 Looper.prepare(); 991 mLooper = Looper.myLooper(); 992 mLock.notifyAll(); 993 } 994 Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); 995 Looper.loop(); 996 } 997 void quit() { 998 mLooper.quit(); 999 } 1000 Looper getLooper() { 1001 return mLooper; 1002 } 1003 } 1004} 1005