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