1 /* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.email; 18 19 import android.app.Notification; 20 import android.app.Notification.Builder; 21 import android.app.NotificationManager; 22 import android.app.PendingIntent; 23 import android.content.ContentResolver; 24 import android.content.ContentUris; 25 import android.content.ContentValues; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.res.Resources; 29 import android.database.ContentObserver; 30 import android.database.Cursor; 31 import android.graphics.Bitmap; 32 import android.graphics.BitmapFactory; 33 import android.media.AudioManager; 34 import android.net.Uri; 35 import android.os.Build; 36 import android.os.Handler; 37 import android.os.Looper; 38 import android.os.Process; 39 import android.text.SpannableString; 40 import android.text.TextUtils; 41 import android.text.style.TextAppearanceSpan; 42 import android.util.Log; 43 44 import com.android.email.activity.ContactStatusLoader; 45 import com.android.email.activity.Welcome; 46 import com.android.email.activity.setup.AccountSecurity; 47 import com.android.email.activity.setup.AccountSettings; 48 import com.android.emailcommon.Logging; 49 import com.android.emailcommon.mail.Address; 50 import com.android.emailcommon.provider.Account; 51 import com.android.emailcommon.provider.EmailContent; 52 import com.android.emailcommon.provider.EmailContent.AccountColumns; 53 import com.android.emailcommon.provider.EmailContent.Attachment; 54 import com.android.emailcommon.provider.EmailContent.MailboxColumns; 55 import com.android.emailcommon.provider.EmailContent.Message; 56 import com.android.emailcommon.provider.EmailContent.MessageColumns; 57 import com.android.emailcommon.provider.Mailbox; 58 import com.android.emailcommon.utility.Utility; 59 import com.google.common.annotations.VisibleForTesting; 60 61 import java.util.HashMap; 62 import java.util.HashSet; 63 64 /** 65 * Class that manages notifications. 66 */ 67 public class NotificationController { 68 private static final int NOTIFICATION_ID_SECURITY_NEEDED = 1; 69 /** Reserved for {@link com.android.exchange.CalendarSyncEnabler} */ 70 @SuppressWarnings("unused") 71 private static final int NOTIFICATION_ID_EXCHANGE_CALENDAR_ADDED = 2; 72 private static final int NOTIFICATION_ID_ATTACHMENT_WARNING = 3; 73 private static final int NOTIFICATION_ID_PASSWORD_EXPIRING = 4; 74 private static final int NOTIFICATION_ID_PASSWORD_EXPIRED = 5; 75 76 private static final int NOTIFICATION_ID_BASE_NEW_MESSAGES = 0x10000000; 77 private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000; 78 79 /** Selection to retrieve accounts that should we notify user for changes */ 80 private final static String NOTIFIED_ACCOUNT_SELECTION = 81 Account.FLAGS + "&" + Account.FLAGS_NOTIFY_NEW_MAIL + " != 0"; 82 83 private static NotificationThread sNotificationThread; 84 private static Handler sNotificationHandler; 85 private static NotificationController sInstance; 86 private final Context mContext; 87 private final NotificationManager mNotificationManager; 88 private final AudioManager mAudioManager; 89 private final Bitmap mGenericSenderIcon; 90 private final Bitmap mGenericMultipleSenderIcon; 91 private final Clock mClock; 92 // TODO We're maintaining all of our structures based upon the account ID. This is fine 93 // for now since the assumption is that we only ever look for changes in an account's 94 // INBOX. We should adjust our logic to use the mailbox ID instead. 95 /** Maps account id to the message data */ 96 private final HashMap<Long, ContentObserver> mNotificationMap; 97 private ContentObserver mAccountObserver; 98 /** 99 * Suspend notifications for this account. If {@link Account#NO_ACCOUNT}, no 100 * account notifications are suspended. If {@link Account#ACCOUNT_ID_COMBINED_VIEW}, 101 * notifications for all accounts are suspended. 102 */ 103 private long mSuspendAccountId = Account.NO_ACCOUNT; 104 105 /** 106 * Timestamp indicating when the last message notification sound was played. 107 * Used for throttling. 108 */ 109 private long mLastMessageNotifyTime; 110 111 /** 112 * Minimum interval between notification sounds. 113 * Since a long sync (either on account setup or after a long period of being offline) can cause 114 * several notifications consecutively, it can be pretty overwhelming to get a barrage of 115 * notification sounds. Throttle them using this value. 116 */ 117 private static final long MIN_SOUND_INTERVAL_MS = 15 * 1000; // 15 seconds 118 isRunningJellybeanOrLater()119 private static boolean isRunningJellybeanOrLater() { 120 return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN; 121 } 122 123 /** Constructor */ 124 @VisibleForTesting NotificationController(Context context, Clock clock)125 NotificationController(Context context, Clock clock) { 126 mContext = context.getApplicationContext(); 127 mNotificationManager = (NotificationManager) context.getSystemService( 128 Context.NOTIFICATION_SERVICE); 129 mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); 130 mGenericSenderIcon = BitmapFactory.decodeResource(mContext.getResources(), 131 R.drawable.ic_contact_picture); 132 mGenericMultipleSenderIcon = BitmapFactory.decodeResource(mContext.getResources(), 133 R.drawable.ic_notification_multiple_mail_holo_dark); 134 mClock = clock; 135 mNotificationMap = new HashMap<Long, ContentObserver>(); 136 } 137 138 /** Singleton access */ getInstance(Context context)139 public static synchronized NotificationController getInstance(Context context) { 140 if (sInstance == null) { 141 sInstance = new NotificationController(context, Clock.INSTANCE); 142 } 143 return sInstance; 144 } 145 146 /** 147 * Return whether or not a notification, based on the passed-in id, needs to be "ongoing" 148 * @param notificationId the notification id to check 149 * @return whether or not the notification must be "ongoing" 150 */ needsOngoingNotification(int notificationId)151 private boolean needsOngoingNotification(int notificationId) { 152 // "Security needed" must be ongoing so that the user doesn't close it; otherwise, sync will 153 // be prevented until a reboot. Consider also doing this for password expired. 154 return notificationId == NOTIFICATION_ID_SECURITY_NEEDED; 155 } 156 157 /** 158 * Returns a {@link Notification.Builder} for an event with the given account. The account 159 * contains specific rules on ring tone usage and these will be used to modify the notification 160 * behaviour. 161 * 162 * @param account The account this notification is being built for. 163 * @param ticker Text displayed when the notification is first shown. May be {@code null}. 164 * @param title The first line of text. May NOT be {@code null}. 165 * @param contentText The second line of text. May NOT be {@code null}. 166 * @param intent The intent to start if the user clicks on the notification. 167 * @param largeIcon A large icon. May be {@code null} 168 * @param number A number to display using {@link Builder#setNumber(int)}. May 169 * be {@code null}. 170 * @param enableAudio If {@code false}, do not play any sound. Otherwise, play sound according 171 * to the settings for the given account. 172 * @return A {@link Notification} that can be sent to the notification service. 173 */ createBaseAccountNotificationBuilder(Account account, String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon, Integer number, boolean enableAudio, boolean ongoing)174 private Notification.Builder createBaseAccountNotificationBuilder(Account account, 175 String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon, 176 Integer number, boolean enableAudio, boolean ongoing) { 177 // Pending Intent 178 PendingIntent pending = null; 179 if (intent != null) { 180 pending = PendingIntent.getActivity( 181 mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); 182 } 183 184 // NOTE: the ticker is not shown for notifications in the Holo UX 185 final Notification.Builder builder = new Notification.Builder(mContext) 186 .setContentTitle(title) 187 .setContentText(contentText) 188 .setContentIntent(pending) 189 .setLargeIcon(largeIcon) 190 .setNumber(number == null ? 0 : number) 191 .setSmallIcon(R.drawable.stat_notify_email_generic) 192 .setWhen(mClock.getTime()) 193 .setTicker(ticker) 194 .setOngoing(ongoing); 195 196 if (enableAudio) { 197 setupSoundAndVibration(builder, account); 198 } 199 200 return builder; 201 } 202 203 /** 204 * Generic notifier for any account. Uses notification rules from account. 205 * 206 * @param account The account this notification is being built for. 207 * @param ticker Text displayed when the notification is first shown. May be {@code null}. 208 * @param title The first line of text. May NOT be {@code null}. 209 * @param contentText The second line of text. May NOT be {@code null}. 210 * @param intent The intent to start if the user clicks on the notification. 211 * @param notificationId The ID of the notification to register with the service. 212 */ showAccountNotification(Account account, String ticker, String title, String contentText, Intent intent, int notificationId)213 private void showAccountNotification(Account account, String ticker, String title, 214 String contentText, Intent intent, int notificationId) { 215 Notification.Builder builder = createBaseAccountNotificationBuilder(account, ticker, title, 216 contentText, intent, null, null, true, needsOngoingNotification(notificationId)); 217 mNotificationManager.notify(notificationId, builder.getNotification()); 218 } 219 220 /** 221 * Returns a notification ID for new message notifications for the given account. 222 */ getNewMessageNotificationId(long accountId)223 private int getNewMessageNotificationId(long accountId) { 224 // We assume accountId will always be less than 0x0FFFFFFF; is there a better way? 225 return (int) (NOTIFICATION_ID_BASE_NEW_MESSAGES + accountId); 226 } 227 228 /** 229 * Tells the notification controller if it should be watching for changes to the message table. 230 * This is the main life cycle method for message notifications. When we stop observing 231 * database changes, we save the state [e.g. message ID and count] of the most recent 232 * notification shown to the user. And, when we start observing database changes, we restore 233 * the saved state. 234 * @param watch If {@code true}, we register observers for all accounts whose settings have 235 * notifications enabled. Otherwise, all observers are unregistered. 236 */ watchForMessages(final boolean watch)237 public void watchForMessages(final boolean watch) { 238 if (Email.DEBUG) { 239 Log.i(Logging.LOG_TAG, "Notifications being toggled: " + watch); 240 } 241 // Don't create the thread if we're only going to stop watching 242 if (!watch && sNotificationThread == null) return; 243 244 ensureHandlerExists(); 245 // Run this on the message notification handler 246 sNotificationHandler.post(new Runnable() { 247 @Override 248 public void run() { 249 ContentResolver resolver = mContext.getContentResolver(); 250 if (!watch) { 251 unregisterMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW); 252 if (mAccountObserver != null) { 253 resolver.unregisterContentObserver(mAccountObserver); 254 mAccountObserver = null; 255 } 256 257 // tear down the event loop 258 sNotificationThread.quit(); 259 sNotificationThread = null; 260 return; 261 } 262 263 // otherwise, start new observers for all notified accounts 264 registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW); 265 // If we're already observing account changes, don't do anything else 266 if (mAccountObserver == null) { 267 if (Email.DEBUG) { 268 Log.i(Logging.LOG_TAG, "Observing account changes for notifications"); 269 } 270 mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext); 271 resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver); 272 } 273 } 274 }); 275 } 276 277 /** 278 * Temporarily suspend a single account from receiving notifications. NOTE: only a single 279 * account may ever be suspended at a time. So, if this method is invoked a second time, 280 * notifications for the previously suspended account will automatically be re-activated. 281 * @param suspend If {@code true}, suspend notifications for the given account. Otherwise, 282 * re-activate notifications for the previously suspended account. 283 * @param accountId The ID of the account. If this is the special account ID 284 * {@link Account#ACCOUNT_ID_COMBINED_VIEW}, notifications for all accounts are 285 * suspended. If {@code suspend} is {@code false}, the account ID is ignored. 286 */ suspendMessageNotification(boolean suspend, long accountId)287 public void suspendMessageNotification(boolean suspend, long accountId) { 288 if (mSuspendAccountId != Account.NO_ACCOUNT) { 289 // we're already suspending an account; un-suspend it 290 mSuspendAccountId = Account.NO_ACCOUNT; 291 } 292 if (suspend && accountId != Account.NO_ACCOUNT && accountId > 0L) { 293 mSuspendAccountId = accountId; 294 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 295 // Only go onto the notification handler if we really, absolutely need to 296 ensureHandlerExists(); 297 sNotificationHandler.post(new Runnable() { 298 @Override 299 public void run() { 300 for (long accountId : mNotificationMap.keySet()) { 301 mNotificationManager.cancel(getNewMessageNotificationId(accountId)); 302 } 303 } 304 }); 305 } else { 306 mNotificationManager.cancel(getNewMessageNotificationId(accountId)); 307 } 308 } 309 } 310 311 /** 312 * Ensures the notification handler exists and is ready to handle requests. 313 */ ensureHandlerExists()314 private static synchronized void ensureHandlerExists() { 315 if (sNotificationThread == null) { 316 sNotificationThread = new NotificationThread(); 317 sNotificationHandler = new Handler(sNotificationThread.getLooper()); 318 } 319 } 320 321 /** 322 * Registers an observer for changes to the INBOX for the given account. Since accounts 323 * may only have a single INBOX, we will never have more than one observer for an account. 324 * NOTE: This must be called on the notification handler thread. 325 * @param accountId The ID of the account to register the observer for. May be 326 * {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all 327 * accounts that allow for user notification. 328 */ registerMessageNotification(long accountId)329 private void registerMessageNotification(long accountId) { 330 ContentResolver resolver = mContext.getContentResolver(); 331 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 332 Cursor c = resolver.query( 333 Account.CONTENT_URI, EmailContent.ID_PROJECTION, 334 NOTIFIED_ACCOUNT_SELECTION, null, null); 335 try { 336 while (c.moveToNext()) { 337 long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 338 registerMessageNotification(id); 339 } 340 } finally { 341 c.close(); 342 } 343 } else { 344 ContentObserver obs = mNotificationMap.get(accountId); 345 if (obs != null) return; // we're already observing; nothing to do 346 347 Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, accountId, Mailbox.TYPE_INBOX); 348 if (mailbox == null) { 349 Log.w(Logging.LOG_TAG, "Could not load INBOX for account id: " + accountId); 350 return; 351 } 352 if (Email.DEBUG) { 353 Log.i(Logging.LOG_TAG, "Registering for notifications for account " + accountId); 354 } 355 ContentObserver observer = new MessageContentObserver( 356 sNotificationHandler, mContext, mailbox.mId, accountId); 357 resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer); 358 mNotificationMap.put(accountId, observer); 359 // Now, ping the observer for any initial notifications 360 observer.onChange(true); 361 } 362 } 363 364 /** 365 * Unregisters the observer for the given account. If the specified account does not have 366 * a registered observer, no action is performed. This will not clear any existing notification 367 * for the specified account. Use {@link NotificationManager#cancel(int)}. 368 * NOTE: This must be called on the notification handler thread. 369 * @param accountId The ID of the account to unregister from. To unregister all accounts that 370 * have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}. 371 */ unregisterMessageNotification(long accountId)372 private void unregisterMessageNotification(long accountId) { 373 ContentResolver resolver = mContext.getContentResolver(); 374 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 375 if (Email.DEBUG) { 376 Log.i(Logging.LOG_TAG, "Unregistering notifications for all accounts"); 377 } 378 // cancel all existing message observers 379 for (ContentObserver observer : mNotificationMap.values()) { 380 resolver.unregisterContentObserver(observer); 381 } 382 mNotificationMap.clear(); 383 } else { 384 if (Email.DEBUG) { 385 Log.i(Logging.LOG_TAG, "Unregistering notifications for account " + accountId); 386 } 387 ContentObserver observer = mNotificationMap.remove(accountId); 388 if (observer != null) { 389 resolver.unregisterContentObserver(observer); 390 } 391 } 392 } 393 394 /** 395 * Returns a picture of the sender of the given message. If no picture is available, returns 396 * {@code null}. 397 * 398 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 399 */ getSenderPhoto(Message message)400 private Bitmap getSenderPhoto(Message message) { 401 Address sender = Address.unpackFirst(message.mFrom); 402 if (sender == null) { 403 return null; 404 } 405 String email = sender.getAddress(); 406 if (TextUtils.isEmpty(email)) { 407 return null; 408 } 409 Bitmap photo = ContactStatusLoader.getContactInfo(mContext, email).mPhoto; 410 411 if (photo != null) { 412 final Resources res = mContext.getResources(); 413 final int idealIconHeight = 414 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); 415 final int idealIconWidth = 416 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); 417 418 if (photo.getHeight() < idealIconHeight) { 419 // We should scale this image to fit the intended size 420 photo = Bitmap.createScaledBitmap( 421 photo, idealIconWidth, idealIconHeight, true); 422 } 423 } 424 return photo; 425 } 426 427 /** 428 * Returns a "new message" notification for the given account. 429 * 430 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 431 */ 432 @VisibleForTesting createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor, long newestMessageId, int unseenMessageCount, int unreadCount)433 Notification createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor, 434 long newestMessageId, int unseenMessageCount, int unreadCount) { 435 final Account account = Account.restoreAccountWithId(mContext, accountId); 436 if (account == null) { 437 return null; 438 } 439 // Get the latest message 440 final Message message = Message.restoreMessageWithId(mContext, newestMessageId); 441 if (message == null) { 442 return null; // no message found??? 443 } 444 445 String senderName = Address.toFriendly(Address.unpack(message.mFrom)); 446 if (senderName == null) { 447 senderName = ""; // Happens when a message has no from. 448 } 449 final boolean multipleUnseen = unseenMessageCount > 1; 450 final Bitmap senderPhoto = multipleUnseen 451 ? mGenericMultipleSenderIcon 452 : getSenderPhoto(message); 453 final SpannableString title = getNewMessageTitle(senderName, unseenMessageCount); 454 // TODO: add in display name on the second line for the text, once framework supports 455 // multiline texts. 456 final String text = multipleUnseen 457 ? account.mDisplayName 458 : message.mSubject; 459 final Bitmap largeIcon = senderPhoto != null ? senderPhoto : mGenericSenderIcon; 460 final Integer number = unreadCount > 1 ? unreadCount : null; 461 final Intent intent; 462 if (unseenMessageCount > 1) { 463 intent = Welcome.createOpenAccountInboxIntent(mContext, accountId); 464 } else { 465 intent = Welcome.createOpenMessageIntent( 466 mContext, accountId, mailboxId, newestMessageId); 467 } 468 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 469 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 470 long now = mClock.getTime(); 471 boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS; 472 final Notification.Builder builder = createBaseAccountNotificationBuilder( 473 account, title.toString(), title, text, 474 intent, largeIcon, number, enableAudio, false); 475 if (isRunningJellybeanOrLater()) { 476 // For a new-style notification 477 if (multipleUnseen) { 478 if (messageCursor != null) { 479 final int maxNumDigestItems = mContext.getResources().getInteger( 480 R.integer.max_num_notification_digest_items); 481 // The body of the notification is the account name, or the label name. 482 builder.setSubText(text); 483 484 Notification.InboxStyle digest = new Notification.InboxStyle(builder); 485 486 digest.setBigContentTitle(title); 487 488 int numDigestItems = 0; 489 // We can assume that the current position of the cursor is on the 490 // newest message 491 do { 492 final long messageId = 493 messageCursor.getLong(EmailContent.ID_PROJECTION_COLUMN); 494 495 // Get the latest message 496 final Message digestMessage = 497 Message.restoreMessageWithId(mContext, messageId); 498 if (digestMessage != null) { 499 final CharSequence digestLine = 500 getSingleMessageInboxLine(mContext, digestMessage); 501 digest.addLine(digestLine); 502 numDigestItems++; 503 } 504 } while (numDigestItems <= maxNumDigestItems && messageCursor.moveToNext()); 505 506 // We want to clear the content text in this case. The content text would have 507 // been set in createBaseAccountNotificationBuilder, but since the same string 508 // was set in as the subtext, we don't want to show a duplicate string. 509 builder.setContentText(null); 510 } 511 } else { 512 // The notification content will be the subject of the conversation. 513 builder.setContentText(getSingleMessageLittleText(mContext, message.mSubject)); 514 515 // The notification subtext will be the subject of the conversation for inbox 516 // notifications, or will based on the the label name for user label notifications. 517 builder.setSubText(account.mDisplayName); 518 519 final Notification.BigTextStyle bigText = new Notification.BigTextStyle(builder); 520 bigText.bigText(getSingleMessageBigText(mContext, message)); 521 } 522 } 523 524 mLastMessageNotifyTime = now; 525 return builder.getNotification(); 526 } 527 528 /** 529 * Sets the bigtext for a notification for a single new conversation 530 * @param context 531 * @param message New message that triggered the notification. 532 * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle} 533 */ getSingleMessageInboxLine(Context context, Message message)534 private static CharSequence getSingleMessageInboxLine(Context context, Message message) { 535 final String subject = message.mSubject; 536 final String snippet = message.mSnippet; 537 final String senders = Address.toFriendly(Address.unpack(message.mFrom)); 538 539 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet; 540 541 final TextAppearanceSpan notificationPrimarySpan = 542 new TextAppearanceSpan(context, R.style.NotificationPrimaryText); 543 544 if (TextUtils.isEmpty(senders)) { 545 // If the senders are empty, just use the subject/snippet. 546 return subjectSnippet; 547 } 548 else if (TextUtils.isEmpty(subjectSnippet)) { 549 // If the subject/snippet is empty, just use the senders. 550 final SpannableString spannableString = new SpannableString(senders); 551 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0); 552 553 return spannableString; 554 } else { 555 final String formatString = context.getResources().getString( 556 R.string.multiple_new_message_notification_item); 557 final TextAppearanceSpan notificationSecondarySpan = 558 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 559 560 final String instantiatedString = String.format(formatString, senders, subjectSnippet); 561 562 final SpannableString spannableString = new SpannableString(instantiatedString); 563 564 final boolean isOrderReversed = formatString.indexOf("%2$s") < 565 formatString.indexOf("%1$s"); 566 final int primaryOffset = 567 (isOrderReversed ? instantiatedString.lastIndexOf(senders) : 568 instantiatedString.indexOf(senders)); 569 final int secondaryOffset = 570 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) : 571 instantiatedString.indexOf(subjectSnippet)); 572 spannableString.setSpan(notificationPrimarySpan, 573 primaryOffset, primaryOffset + senders.length(), 0); 574 spannableString.setSpan(notificationSecondarySpan, 575 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0); 576 return spannableString; 577 } 578 } 579 580 /** 581 * Sets the bigtext for a notification for a single new conversation 582 * @param context 583 * @param subject Subject of the new message that triggered the notification 584 * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText} 585 */ 586 private static CharSequence getSingleMessageLittleText(Context context, String subject) { 587 if (subject == null) { 588 return null; 589 } 590 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 591 context, R.style.NotificationPrimaryText); 592 593 final SpannableString spannableString = new SpannableString(subject); 594 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 595 596 return spannableString; 597 } 598 599 600 /** 601 * Sets the bigtext for a notification for a single new conversation 602 * @param context 603 * @param message New message that triggered the notification 604 * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle} 605 */ 606 private static CharSequence getSingleMessageBigText(Context context, Message message) { 607 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 608 context, R.style.NotificationPrimaryText); 609 610 final String subject = message.mSubject; 611 final String snippet = message.mSnippet; 612 613 if (TextUtils.isEmpty(subject)) { 614 // If the subject is empty, just use the snippet. 615 return snippet; 616 } 617 else if (TextUtils.isEmpty(snippet)) { 618 // If the snippet is empty, just use the subject. 619 final SpannableString spannableString = new SpannableString(subject); 620 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 621 622 return spannableString; 623 } else { 624 final String notificationBigTextFormat = context.getResources().getString( 625 R.string.single_new_message_notification_big_text); 626 627 // Localizers may change the order of the parameters, look at how the format 628 // string is structured. 629 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") > 630 notificationBigTextFormat.indexOf("%1$s"); 631 final String bigText = String.format(notificationBigTextFormat, subject, snippet); 632 final SpannableString spannableString = new SpannableString(bigText); 633 634 final int subjectOffset = 635 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject)); 636 spannableString.setSpan(notificationSubjectSpan, 637 subjectOffset, subjectOffset + subject.length(), 0); 638 639 return spannableString; 640 } 641 } 642 643 /** 644 * Creates a notification title for a new message. If there is only a single message, 645 * show the sender name. Otherwise, show "X new messages". 646 */ 647 @VisibleForTesting getNewMessageTitle(String sender, int unseenCount)648 SpannableString getNewMessageTitle(String sender, int unseenCount) { 649 String title; 650 if (unseenCount > 1) { 651 title = String.format( 652 mContext.getString(R.string.notification_multiple_new_messages_fmt), 653 unseenCount); 654 } else { 655 title = sender; 656 } 657 return new SpannableString(title); 658 } 659 660 /** Returns the system's current ringer mode */ 661 @VisibleForTesting getRingerMode()662 int getRingerMode() { 663 return mAudioManager.getRingerMode(); 664 } 665 666 /** Sets up the notification's sound and vibration based upon account details. */ 667 @VisibleForTesting setupSoundAndVibration(Notification.Builder builder, Account account)668 void setupSoundAndVibration(Notification.Builder builder, Account account) { 669 final int flags = account.mFlags; 670 final String ringtoneUri = account.mRingtoneUri; 671 final boolean vibrate = (flags & Account.FLAGS_VIBRATE_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 */ showDownloadForwardFailedNotification(Attachment attachment)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 */ getLoginFailedNotificationId(long accountId)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 */ showLoginFailedNotification(long accountId)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 */ cancelLoginFailedNotification(long accountId)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 */ showPasswordExpiringNotification(long accountId)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 */ showPasswordExpiredNotification(long accountId)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 */ cancelPasswordExpirationNotifications()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 */ showSecurityNeededNotification(Account account)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 */ cancelSecurityNeededNotification()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 MessageContentObserver( Handler handler, Context context, long mailboxId, long accountId)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 onChange(boolean selfChange)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; AccountContentObserver(Handler handler, Context context)916 public AccountContentObserver(Handler handler, Context context) { 917 super(handler); 918 mContext = context; 919 } 920 921 @Override onChange(boolean selfChange)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 NotificationThread()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 run()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 } quit()997 void quit() { 998 mLooper.quit(); 999 } getLooper()1000 Looper getLooper() { 1001 return mLooper; 1002 } 1003 } 1004 } 1005