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