EmailWidget.java revision f419287f22ae44f25e1ba1f757ec33c7941bbfa8
1/* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.email; 18 19import android.app.Notification; 20import android.app.Notification.Builder; 21import android.app.NotificationManager; 22import android.app.PendingIntent; 23import android.content.ContentResolver; 24import android.content.ContentUris; 25import android.content.ContentValues; 26import android.content.Context; 27import android.content.Intent; 28import android.content.res.Resources; 29import android.database.ContentObserver; 30import android.database.Cursor; 31import android.graphics.Bitmap; 32import android.graphics.BitmapFactory; 33import android.media.AudioManager; 34import android.net.Uri; 35import android.os.Handler; 36import android.os.Looper; 37import android.os.Process; 38import android.text.SpannableString; 39import android.text.TextUtils; 40import android.text.style.TextAppearanceSpan; 41import android.util.Log; 42 43import com.android.email.activity.ContactStatusLoader; 44import com.android.email.activity.setup.AccountSecurity; 45import com.android.email.activity.setup.AccountSettings; 46import com.android.email.provider.EmailProvider; 47import com.android.email.service.EmailBroadcastProcessorService; 48import com.android.email2.ui.MailActivityEmail; 49import com.android.emailcommon.Logging; 50import com.android.emailcommon.mail.Address; 51import com.android.emailcommon.provider.Account; 52import com.android.emailcommon.provider.EmailContent; 53import com.android.emailcommon.provider.EmailContent.Attachment; 54import com.android.emailcommon.provider.EmailContent.MailboxColumns; 55import com.android.emailcommon.provider.EmailContent.Message; 56import com.android.emailcommon.provider.Mailbox; 57import com.android.emailcommon.utility.EmailAsyncTask; 58import com.android.emailcommon.utility.Utility; 59import com.android.mail.providers.Conversation; 60import com.android.mail.providers.Folder; 61import com.android.mail.providers.UIProvider; 62import com.android.mail.utils.Utils; 63import com.google.common.annotations.VisibleForTesting; 64 65import java.util.HashMap; 66import java.util.HashSet; 67 68/** 69 * Class that manages notifications. 70 */ 71public class NotificationController { 72 private static final String TAG = "NotificationController"; 73 74 /** Reserved for {@link com.android.exchange.CalendarSyncEnabler} */ 75 @SuppressWarnings("unused") 76 private static final int NOTIFICATION_ID_EXCHANGE_CALENDAR_ADDED = 2; 77 private static final int NOTIFICATION_ID_ATTACHMENT_WARNING = 3; 78 private static final int NOTIFICATION_ID_PASSWORD_EXPIRING = 4; 79 private static final int NOTIFICATION_ID_PASSWORD_EXPIRED = 5; 80 81 private static final int NOTIFICATION_ID_BASE_MASK = 0xF0000000; 82 private static final int NOTIFICATION_ID_BASE_NEW_MESSAGES = 0x10000000; 83 private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000; 84 private static final int NOTIFICATION_ID_BASE_SECURITY_NEEDED = 0x30000000; 85 private static final int NOTIFICATION_ID_BASE_SECURITY_CHANGED = 0x40000000; 86 87 /** Selection to retrieve accounts that should we notify user for changes */ 88 private final static String NOTIFIED_ACCOUNT_SELECTION = 89 Account.FLAGS + "&" + Account.FLAGS_NOTIFY_NEW_MAIL + " != 0"; 90 91 private static final String NEW_MAIL_MAILBOX_ID = "com.android.email.new_mail.mailboxId"; 92 private static final String NEW_MAIL_MESSAGE_ID = "com.android.email.new_mail.messageId"; 93 private static final String NEW_MAIL_MESSAGE_COUNT = "com.android.email.new_mail.messageCount"; 94 private static final String NEW_MAIL_UNREAD_COUNT = "com.android.email.new_mail.unreadCount"; 95 96 private static NotificationThread sNotificationThread; 97 private static Handler sNotificationHandler; 98 private static NotificationController sInstance; 99 private final Context mContext; 100 private final NotificationManager mNotificationManager; 101 private final AudioManager mAudioManager; 102 private final Bitmap mGenericSenderIcon; 103 private final Bitmap mGenericMultipleSenderIcon; 104 private final Clock mClock; 105 /** Maps account id to its observer */ 106 private final HashMap<Long, ContentObserver> mNotificationMap; 107 private ContentObserver mAccountObserver; 108 109 /** 110 * Timestamp indicating when the last message notification sound was played. 111 * Used for throttling. 112 */ 113 private long mLastMessageNotifyTime; 114 115 /** 116 * Minimum interval between notification sounds. 117 * Since a long sync (either on account setup or after a long period of being offline) can cause 118 * several notifications consecutively, it can be pretty overwhelming to get a barrage of 119 * notification sounds. Throttle them using this value. 120 */ 121 private static final long MIN_SOUND_INTERVAL_MS = 15 * 1000; // 15 seconds 122 123 /** Constructor */ 124 @VisibleForTesting 125 NotificationController(Context context, Clock clock) { 126 mContext = context.getApplicationContext(); 127 mNotificationManager = (NotificationManager) context.getSystemService( 128 Context.NOTIFICATION_SERVICE); 129 mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); 130 mGenericSenderIcon = BitmapFactory.decodeResource(mContext.getResources(), 131 R.drawable.ic_contact_picture); 132 mGenericMultipleSenderIcon = BitmapFactory.decodeResource(mContext.getResources(), 133 R.drawable.ic_notification_multiple_mail_holo_dark); 134 mClock = clock; 135 mNotificationMap = new HashMap<Long, ContentObserver>(); 136 } 137 138 /** Singleton access */ 139 public static synchronized NotificationController getInstance(Context context) { 140 if (sInstance == null) { 141 sInstance = new NotificationController(context, Clock.INSTANCE); 142 } 143 return sInstance; 144 } 145 146 /** 147 * Return whether or not a notification, based on the passed-in id, needs to be "ongoing" 148 * @param notificationId the notification id to check 149 * @return whether or not the notification must be "ongoing" 150 */ 151 private boolean needsOngoingNotification(int notificationId) { 152 // "Security needed" must be ongoing so that the user doesn't close it; otherwise, sync will 153 // be prevented until a reboot. Consider also doing this for password expired. 154 return (notificationId & NOTIFICATION_ID_BASE_MASK) == NOTIFICATION_ID_BASE_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 accountId The id of the account this notification is being built for. 163 * @param ticker Text displayed when the notification is first shown. May be {@code null}. 164 * @param title The first line of text. May NOT be {@code null}. 165 * @param contentText The second line of text. May NOT be {@code null}. 166 * @param intent The intent to start if the user clicks on the notification. 167 * @param largeIcon A large icon. May be {@code null} 168 * @param number A number to display using {@link Builder#setNumber(int)}. May 169 * be {@code null}. 170 * @param enableAudio If {@code false}, do not play any sound. Otherwise, play sound according 171 * to the settings for the given account. 172 * @return A {@link Notification} that can be sent to the notification service. 173 */ 174 private Notification.Builder createBaseAccountNotificationBuilder(long accountId, String ticker, 175 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 Account account = Account.restoreAccountWithId(mContext, accountId); 198 setupSoundAndVibration(builder, account); 199 } 200 201 return builder; 202 } 203 204 /** 205 * Generic notifier for any account. Uses notification rules from account. 206 * 207 * @param accountId The account id this notification is being built for. 208 * @param ticker Text displayed when the notification is first shown. May be {@code null}. 209 * @param title The first line of text. May NOT be {@code null}. 210 * @param contentText The second line of text. May NOT be {@code null}. 211 * @param intent The intent to start if the user clicks on the notification. 212 * @param notificationId The ID of the notification to register with the service. 213 */ 214 private void showNotification(long accountId, String ticker, String title, 215 String contentText, Intent intent, int notificationId) { 216 final Notification.Builder builder = createBaseAccountNotificationBuilder(accountId, ticker, 217 title, contentText, intent, null, null, true, 218 needsOngoingNotification(notificationId)); 219 mNotificationManager.notify(notificationId, builder.getNotification()); 220 } 221 222 /** 223 * Returns a notification ID for new message notifications for the given account. 224 */ 225 private int getNewMessageNotificationId(long mailboxId) { 226 // We assume accountId will always be less than 0x0FFFFFFF; is there a better way? 227 return (int) (NOTIFICATION_ID_BASE_NEW_MESSAGES + mailboxId); 228 } 229 230 /** 231 * Tells the notification controller if it should be watching for changes to the message table. 232 * This is the main life cycle method for message notifications. When we stop observing 233 * database changes, we save the state [e.g. message ID and count] of the most recent 234 * notification shown to the user. And, when we start observing database changes, we restore 235 * the saved state. 236 * @param watch If {@code true}, we register observers for all accounts whose settings have 237 * notifications enabled. Otherwise, all observers are unregistered. 238 */ 239 public void watchForMessages(final boolean watch) { 240 if (MailActivityEmail.DEBUG) { 241 Log.d(Logging.LOG_TAG, "Notifications being toggled: " + watch); 242 } 243 // Don't create the thread if we're only going to stop watching 244 if (!watch && sNotificationThread == null) return; 245 246 ensureHandlerExists(); 247 // Run this on the message notification handler 248 sNotificationHandler.post(new Runnable() { 249 @Override 250 public void run() { 251 ContentResolver resolver = mContext.getContentResolver(); 252 if (!watch) { 253 unregisterMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW); 254 if (mAccountObserver != null) { 255 resolver.unregisterContentObserver(mAccountObserver); 256 mAccountObserver = null; 257 } 258 259 // tear down the event loop 260 sNotificationThread.quit(); 261 sNotificationThread = null; 262 return; 263 } 264 265 // otherwise, start new observers for all notified accounts 266 registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW); 267 // If we're already observing account changes, don't do anything else 268 if (mAccountObserver == null) { 269 if (MailActivityEmail.DEBUG) { 270 Log.i(Logging.LOG_TAG, "Observing account changes for notifications"); 271 } 272 mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext); 273 resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver); 274 } 275 } 276 }); 277 } 278 279 /** 280 * Ensures the notification handler exists and is ready to handle requests. 281 */ 282 private static synchronized void ensureHandlerExists() { 283 if (sNotificationThread == null) { 284 sNotificationThread = new NotificationThread(); 285 sNotificationHandler = new Handler(sNotificationThread.getLooper()); 286 } 287 } 288 289 /** 290 * Registers an observer for changes to mailboxes in the given account. 291 * NOTE: This must be called on the notification handler thread. 292 * @param accountId The ID of the account to register the observer for. May be 293 * {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all 294 * accounts that allow for user notification. 295 */ 296 private void registerMessageNotification(long accountId) { 297 ContentResolver resolver = mContext.getContentResolver(); 298 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 299 Cursor c = resolver.query( 300 Account.CONTENT_URI, EmailContent.ID_PROJECTION, 301 NOTIFIED_ACCOUNT_SELECTION, null, null); 302 try { 303 while (c.moveToNext()) { 304 long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 305 registerMessageNotification(id); 306 } 307 } finally { 308 c.close(); 309 } 310 } else { 311 ContentObserver obs = mNotificationMap.get(accountId); 312 if (obs != null) return; // we're already observing; nothing to do 313 if (MailActivityEmail.DEBUG) { 314 Log.i(Logging.LOG_TAG, "Registering for notifications for account " + accountId); 315 } 316 ContentObserver observer = new MessageContentObserver( 317 sNotificationHandler, mContext, accountId); 318 resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer); 319 mNotificationMap.put(accountId, observer); 320 // Now, ping the observer for any initial notifications 321 observer.onChange(true); 322 } 323 } 324 325 /** 326 * Unregisters the observer for the given account. If the specified account does not have 327 * a registered observer, no action is performed. This will not clear any existing notification 328 * for the specified account. Use {@link NotificationManager#cancel(int)}. 329 * NOTE: This must be called on the notification handler thread. 330 * @param accountId The ID of the account to unregister from. To unregister all accounts that 331 * have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}. 332 */ 333 private void unregisterMessageNotification(long accountId) { 334 ContentResolver resolver = mContext.getContentResolver(); 335 if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) { 336 if (MailActivityEmail.DEBUG) { 337 Log.i(Logging.LOG_TAG, "Unregistering notifications for all accounts"); 338 } 339 // cancel all existing message observers 340 for (ContentObserver observer : mNotificationMap.values()) { 341 resolver.unregisterContentObserver(observer); 342 } 343 mNotificationMap.clear(); 344 } else { 345 if (MailActivityEmail.DEBUG) { 346 Log.i(Logging.LOG_TAG, "Unregistering notifications for account " + accountId); 347 } 348 ContentObserver observer = mNotificationMap.remove(accountId); 349 if (observer != null) { 350 resolver.unregisterContentObserver(observer); 351 } 352 } 353 } 354 355 /** 356 * Returns a picture of the sender of the given message. If no picture is available, returns 357 * {@code null}. 358 * 359 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 360 */ 361 private Bitmap getSenderPhoto(Message message) { 362 Address sender = Address.unpackFirst(message.mFrom); 363 if (sender == null) { 364 return null; 365 } 366 String email = sender.getAddress(); 367 if (TextUtils.isEmpty(email)) { 368 return null; 369 } 370 Bitmap photo = ContactStatusLoader.getContactInfo(mContext, email).mPhoto; 371 372 if (photo != null) { 373 final Resources res = mContext.getResources(); 374 final int idealIconHeight = 375 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height); 376 final int idealIconWidth = 377 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width); 378 379 if (photo.getHeight() < idealIconHeight) { 380 // We should scale this image to fit the intended size 381 photo = Bitmap.createScaledBitmap( 382 photo, idealIconWidth, idealIconHeight, true); 383 } 384 } 385 return photo; 386 } 387 388 public static final String EXTRA_ACCOUNT = "account"; 389 public static final String EXTRA_CONVERSATION = "conversationUri"; 390 public static final String EXTRA_FOLDER = "folder"; 391 392 private Intent createViewConversationIntent(Conversation conversation, Folder folder, 393 com.android.mail.providers.Account account) { 394 final Intent intent = new Intent(Intent.ACTION_VIEW); 395 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); 396 intent.setDataAndType(conversation.uri, account.mimeType); 397 intent.putExtra(EXTRA_ACCOUNT, account); 398 intent.putExtra(EXTRA_FOLDER, folder); 399 intent.putExtra(EXTRA_CONVERSATION, conversation); 400 return intent; 401 } 402 403 private Cursor getUiCursor(Uri uri, String[] projection) { 404 Cursor c = mContext.getContentResolver().query(uri, projection, null, null, null); 405 if (c == null) return null; 406 if (c.moveToFirst()) { 407 return c; 408 } else { 409 c.close(); 410 return null; 411 } 412 } 413 414 private Intent createViewConversationIntent(Message message) { 415 Cursor c = getUiCursor(EmailProvider.uiUri("uiaccount", message.mAccountKey), 416 UIProvider.ACCOUNTS_PROJECTION); 417 if (c == null) { 418 Log.w(TAG, "Can't find account for message " + message.mId); 419 return null; 420 } 421 com.android.mail.providers.Account acct = new com.android.mail.providers.Account(c); 422 c.close(); 423 c = getUiCursor(EmailProvider.uiUri("uifolder", message.mMailboxKey), 424 UIProvider.FOLDERS_PROJECTION); 425 if (c == null) { 426 Log.w(TAG, "Can't find folder for message " + message.mId + ", folder " + 427 message.mMailboxKey); 428 return null; 429 } 430 Folder folder = new Folder(c); 431 c.close(); 432 c = getUiCursor(EmailProvider.uiUri("uiconversation", message.mId), 433 UIProvider.CONVERSATION_PROJECTION); 434 if (c == null) { 435 Log.w(TAG, "Can't find conversation for message " + message.mId); 436 return null; 437 } 438 Conversation conv = new Conversation(c); 439 c.close(); 440 return createViewConversationIntent(conv, folder, acct); 441 } 442 443 /** 444 * Returns a "new message" notification for the given account. 445 * 446 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 447 */ 448 @VisibleForTesting 449 Notification createNewMessageNotification(long mailboxId, long newMessageId, 450 int unseenMessageCount, int unreadCount) { 451 final Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, mailboxId); 452 if (mailbox == null) { 453 return null; 454 } 455 final Account account = Account.restoreAccountWithId(mContext, mailbox.mAccountKey); 456 if (account == null) { 457 return null; 458 } 459 // Get the latest message 460 final Message message = Message.restoreMessageWithId(mContext, newMessageId); 461 if (message == null) { 462 return null; // no message found??? 463 } 464 465 String senderName = Address.toFriendly(Address.unpack(message.mFrom)); 466 if (senderName == null) { 467 senderName = ""; // Happens when a message has no from. 468 } 469 final boolean multipleUnseen = unseenMessageCount > 1; 470 final Bitmap senderPhoto = multipleUnseen 471 ? mGenericMultipleSenderIcon 472 : getSenderPhoto(message); 473 final SpannableString title = getNewMessageTitle(senderName, unseenMessageCount); 474 // TODO: add in display name on the second line for the text, once framework supports 475 // multiline texts. 476 // Show account name if an inbox; otherwise mailbox name 477 final String text = multipleUnseen 478 ? ((mailbox.mType == Mailbox.TYPE_INBOX) ? account.mDisplayName : 479 mailbox.mDisplayName) 480 : message.mSubject; 481 final Bitmap largeIcon = senderPhoto != null ? senderPhoto : mGenericSenderIcon; 482 final Integer number = unreadCount > 1 ? unreadCount : null; 483 Intent intent = createViewConversationIntent(message); 484 if (intent == null) { 485 return null; 486 } 487 intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 488 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 489 long now = mClock.getTime(); 490 boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS; 491 final Notification.Builder builder = createBaseAccountNotificationBuilder( 492 mailbox.mAccountKey, title.toString(), title, text, 493 intent, largeIcon, number, enableAudio, false); 494 if (Utils.isRunningJellybeanOrLater()) { 495 // For a new-style notification 496 if (multipleUnseen) { 497 final Cursor messageCursor = 498 mContext.getContentResolver().query(ContentUris.withAppendedId( 499 EmailContent.MAILBOX_NOTIFICATION_URI, mailbox.mAccountKey), 500 EmailContent.NOTIFICATION_PROJECTION, null, null, null); 501 502 try { 503 if (messageCursor != null && messageCursor.getCount() > 0) { 504 final int maxNumDigestItems = mContext.getResources().getInteger( 505 R.integer.max_num_notification_digest_items); 506 // The body of the notification is the account name, or the label name. 507 builder.setSubText(text); 508 509 Notification.InboxStyle digest = new Notification.InboxStyle(builder); 510 511 digest.setBigContentTitle(title); 512 513 int numDigestItems = 0; 514 // We can assume that the current position of the cursor is on the 515 // newest message 516 messageCursor.moveToFirst(); 517 do { 518 final long messageId = 519 messageCursor.getLong(EmailContent.ID_PROJECTION_COLUMN); 520 521 // Get the latest message 522 final Message digestMessage = 523 Message.restoreMessageWithId(mContext, messageId); 524 if (digestMessage != null) { 525 final CharSequence digestLine = 526 getSingleMessageInboxLine(mContext, digestMessage); 527 digest.addLine(digestLine); 528 numDigestItems++; 529 } 530 } while (numDigestItems <= maxNumDigestItems && messageCursor.moveToNext()); 531 532 // We want to clear the content text in this case. The content text would 533 // have been set in createBaseAccountNotificationBuilder, but since the 534 // same string was set in as the subtext, we don't want to show a 535 // duplicate string. 536 builder.setContentText(null); 537 } 538 } finally { 539 if (messageCursor != null) { 540 messageCursor.close(); 541 } 542 } 543 } else { 544 // The notification content will be the subject of the conversation. 545 builder.setContentText(getSingleMessageLittleText(mContext, message.mSubject)); 546 547 // The notification subtext will be the subject of the conversation for inbox 548 // notifications, or will based on the the label name for user label notifications. 549 builder.setSubText(account.mDisplayName); 550 551 final Notification.BigTextStyle bigText = new Notification.BigTextStyle(builder); 552 bigText.bigText(getSingleMessageBigText(mContext, message)); 553 } 554 } 555 556 mLastMessageNotifyTime = now; 557 return builder.getNotification(); 558 } 559 560 /** 561 * Sets the bigtext for a notification for a single new conversation 562 * @param context 563 * @param message New message that triggered the notification. 564 * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle} 565 */ 566 private static CharSequence getSingleMessageInboxLine(Context context, Message message) { 567 final String subject = message.mSubject; 568 final String snippet = message.mSnippet; 569 final String senders = Address.toFriendly(Address.unpack(message.mFrom)); 570 571 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet; 572 573 final TextAppearanceSpan notificationPrimarySpan = 574 new TextAppearanceSpan(context, R.style.NotificationPrimaryText); 575 576 if (TextUtils.isEmpty(senders)) { 577 // If the senders are empty, just use the subject/snippet. 578 return subjectSnippet; 579 } 580 else if (TextUtils.isEmpty(subjectSnippet)) { 581 // If the subject/snippet is empty, just use the senders. 582 final SpannableString spannableString = new SpannableString(senders); 583 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0); 584 585 return spannableString; 586 } else { 587 final String formatString = context.getResources().getString( 588 R.string.multiple_new_message_notification_item); 589 final TextAppearanceSpan notificationSecondarySpan = 590 new TextAppearanceSpan(context, R.style.NotificationSecondaryText); 591 592 final String instantiatedString = String.format(formatString, senders, subjectSnippet); 593 594 final SpannableString spannableString = new SpannableString(instantiatedString); 595 596 final boolean isOrderReversed = formatString.indexOf("%2$s") < 597 formatString.indexOf("%1$s"); 598 final int primaryOffset = 599 (isOrderReversed ? instantiatedString.lastIndexOf(senders) : 600 instantiatedString.indexOf(senders)); 601 final int secondaryOffset = 602 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) : 603 instantiatedString.indexOf(subjectSnippet)); 604 spannableString.setSpan(notificationPrimarySpan, 605 primaryOffset, primaryOffset + senders.length(), 0); 606 spannableString.setSpan(notificationSecondarySpan, 607 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0); 608 return spannableString; 609 } 610 } 611 612 /** 613 * Sets the bigtext for a notification for a single new conversation 614 * @param context 615 * @param subject Subject of the new message that triggered the notification 616 * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText} 617 */ 618 private static CharSequence getSingleMessageLittleText(Context context, String subject) { 619 if (subject == null) { 620 return null; 621 } 622 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 623 context, R.style.NotificationPrimaryText); 624 625 final SpannableString spannableString = new SpannableString(subject); 626 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 627 628 return spannableString; 629 } 630 631 632 /** 633 * Sets the bigtext for a notification for a single new conversation 634 * @param context 635 * @param message New message that triggered the notification 636 * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle} 637 */ 638 private static CharSequence getSingleMessageBigText(Context context, Message message) { 639 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan( 640 context, R.style.NotificationPrimaryText); 641 642 final String subject = message.mSubject; 643 final String snippet = message.mSnippet; 644 645 if (TextUtils.isEmpty(subject)) { 646 // If the subject is empty, just use the snippet. 647 return snippet; 648 } 649 else if (TextUtils.isEmpty(snippet)) { 650 // If the snippet is empty, just use the subject. 651 final SpannableString spannableString = new SpannableString(subject); 652 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0); 653 654 return spannableString; 655 } else { 656 final String notificationBigTextFormat = context.getResources().getString( 657 R.string.single_new_message_notification_big_text); 658 659 // Localizers may change the order of the parameters, look at how the format 660 // string is structured. 661 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") > 662 notificationBigTextFormat.indexOf("%1$s"); 663 final String bigText = String.format(notificationBigTextFormat, subject, snippet); 664 final SpannableString spannableString = new SpannableString(bigText); 665 666 final int subjectOffset = 667 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject)); 668 spannableString.setSpan(notificationSubjectSpan, 669 subjectOffset, subjectOffset + subject.length(), 0); 670 671 return spannableString; 672 } 673 } 674 675 /** 676 * Creates a notification title for a new message. If there is only a single message, 677 * show the sender name. Otherwise, show "X new messages". 678 */ 679 @VisibleForTesting 680 SpannableString getNewMessageTitle(String sender, int unseenCount) { 681 String title; 682 if (unseenCount > 1) { 683 title = String.format( 684 mContext.getString(R.string.notification_multiple_new_messages_fmt), 685 unseenCount); 686 } else { 687 title = sender; 688 } 689 return new SpannableString(title); 690 } 691 692 /** Returns the system's current ringer mode */ 693 @VisibleForTesting 694 int getRingerMode() { 695 return mAudioManager.getRingerMode(); 696 } 697 698 /** Sets up the notification's sound and vibration based upon account details. */ 699 @VisibleForTesting 700 void setupSoundAndVibration(Notification.Builder builder, Account account) { 701 final int flags = account.mFlags; 702 final String ringtoneUri = account.mRingtoneUri; 703 final boolean vibrate = (flags & Account.FLAGS_VIBRATE_ALWAYS) != 0; 704 final boolean vibrateWhenSilent = (flags & Account.FLAGS_VIBRATE_WHEN_SILENT) != 0; 705 final boolean isRingerSilent = getRingerMode() != AudioManager.RINGER_MODE_NORMAL; 706 707 int defaults = Notification.DEFAULT_LIGHTS; 708 if (vibrate || (vibrateWhenSilent && isRingerSilent)) { 709 defaults |= Notification.DEFAULT_VIBRATE; 710 } 711 712 builder.setSound((ringtoneUri == null) ? null : Uri.parse(ringtoneUri)) 713 .setDefaults(defaults); 714 } 715 716 /** 717 * Show (or update) a notification that the given attachment could not be forwarded. This 718 * is a very unusual case, and perhaps we shouldn't even send a notification. For now, 719 * it's helpful for debugging. 720 * 721 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 722 */ 723 public void showDownloadForwardFailedNotification(Attachment attachment) { 724 Message message = Message.restoreMessageWithId(mContext, attachment.mMessageKey); 725 if (message == null) return; 726 Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, message.mMailboxKey); 727 showNotification(mailbox.mAccountKey, 728 mContext.getString(R.string.forward_download_failed_ticker), 729 mContext.getString(R.string.forward_download_failed_title), 730 attachment.mFileName, 731 null, 732 NOTIFICATION_ID_ATTACHMENT_WARNING); 733 } 734 735 /** 736 * Returns a notification ID for login failed notifications for the given account account. 737 */ 738 private int getLoginFailedNotificationId(long accountId) { 739 return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId; 740 } 741 742 /** 743 * Show (or update) a notification that there was a login failure for the given account. 744 * 745 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 746 */ 747 public void showLoginFailedNotification(long accountId) { 748 final Account account = Account.restoreAccountWithId(mContext, accountId); 749 if (account == null) return; 750 final Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, account.mId, 751 Mailbox.TYPE_INBOX); 752 if (mailbox == null) return; 753 showNotification(mailbox.mAccountKey, 754 mContext.getString(R.string.login_failed_ticker, account.mDisplayName), 755 mContext.getString(R.string.login_failed_title), 756 account.getDisplayName(), 757 AccountSettings.createAccountSettingsIntent(mContext, accountId, 758 account.mDisplayName), 759 getLoginFailedNotificationId(accountId)); 760 } 761 762 /** 763 * Cancels the login failed notification for the given account. 764 */ 765 public void cancelLoginFailedNotification(long accountId) { 766 mNotificationManager.cancel(getLoginFailedNotificationId(accountId)); 767 } 768 769 /** 770 * Cancels the new message notification for a given mailbox 771 */ 772 public void cancelNewMessageNotification(long mailboxId) { 773 mNotificationManager.cancel(getNewMessageNotificationId(mailboxId)); 774 } 775 776 /** 777 * Show (or update) a notification that the user's password is expiring. The given account 778 * is used to update the display text, but, all accounts share the same notification ID. 779 * 780 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 781 */ 782 public void showPasswordExpiringNotification(long accountId) { 783 Account account = Account.restoreAccountWithId(mContext, accountId); 784 if (account == null) return; 785 786 Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext, 787 accountId, false); 788 String accountName = account.getDisplayName(); 789 String ticker = 790 mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName); 791 String title = mContext.getString(R.string.password_expire_warning_content_title); 792 showNotification(accountId, ticker, title, accountName, intent, 793 NOTIFICATION_ID_PASSWORD_EXPIRING); 794 } 795 796 /** 797 * Show (or update) a notification that the user's password has expired. The given account 798 * is used to update the display text, but, all accounts share the same notification ID. 799 * 800 * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS) 801 */ 802 public void showPasswordExpiredNotification(long accountId) { 803 Account account = Account.restoreAccountWithId(mContext, accountId); 804 if (account == null) return; 805 806 Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext, 807 accountId, true); 808 String accountName = account.getDisplayName(); 809 String ticker = mContext.getString(R.string.password_expired_ticker); 810 String title = mContext.getString(R.string.password_expired_content_title); 811 showNotification(accountId, ticker, title, accountName, intent, 812 NOTIFICATION_ID_PASSWORD_EXPIRED); 813 } 814 815 /** 816 * Cancels any password expire notifications [both expired & expiring]. 817 */ 818 public void cancelPasswordExpirationNotifications() { 819 mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING); 820 mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED); 821 } 822 823 /** 824 * Show (or update) a security needed notification. If tapped, the user is taken to a 825 * dialog asking whether he wants to update his settings. 826 */ 827 public void showSecurityNeededNotification(Account account) { 828 Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true); 829 String accountName = account.getDisplayName(); 830 String ticker = 831 mContext.getString(R.string.security_needed_ticker_fmt, accountName); 832 String title = mContext.getString(R.string.security_notification_content_update_title); 833 showNotification(account.mId, ticker, title, accountName, intent, 834 (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId)); 835 } 836 837 /** 838 * Show (or update) a security changed notification. If tapped, the user is taken to the 839 * account settings screen where he can view the list of enforced policies 840 */ 841 public void showSecurityChangedNotification(Account account) { 842 Intent intent = AccountSettings.createAccountSettingsIntent(mContext, account.mId, null); 843 String accountName = account.getDisplayName(); 844 String ticker = 845 mContext.getString(R.string.security_changed_ticker_fmt, accountName); 846 String title = mContext.getString(R.string.security_notification_content_change_title); 847 showNotification(account.mId, ticker, title, accountName, intent, 848 (int)(NOTIFICATION_ID_BASE_SECURITY_CHANGED + account.mId)); 849 } 850 851 /** 852 * Show (or update) a security unsupported notification. If tapped, the user is taken to the 853 * account settings screen where he can view the list of unsupported policies 854 */ 855 public void showSecurityUnsupportedNotification(Account account) { 856 Intent intent = AccountSettings.createAccountSettingsIntent(mContext, account.mId, null); 857 String accountName = account.getDisplayName(); 858 String ticker = 859 mContext.getString(R.string.security_unsupported_ticker_fmt, accountName); 860 String title = mContext.getString(R.string.security_notification_content_unsupported_title); 861 showNotification(account.mId, ticker, title, accountName, intent, 862 (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId)); 863 } 864 865 /** 866 * Cancels all security needed notifications. 867 */ 868 public void cancelSecurityNeededNotification() { 869 EmailAsyncTask.runAsyncParallel(new Runnable() { 870 @Override 871 public void run() { 872 Cursor c = mContext.getContentResolver().query(Account.CONTENT_URI, 873 Account.ID_PROJECTION, null, null, null); 874 try { 875 while (c.moveToNext()) { 876 long id = c.getLong(Account.ID_PROJECTION_COLUMN); 877 mNotificationManager.cancel( 878 (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + id)); 879 } 880 } 881 finally { 882 c.close(); 883 } 884 }}); 885 } 886 887 /** 888 * Observer invoked whenever a message we're notifying the user about changes. 889 */ 890 private static class MessageContentObserver extends ContentObserver { 891 private final Context mContext; 892 private final long mAccountId; 893 894 public MessageContentObserver( 895 Handler handler, Context context, long accountId) { 896 super(handler); 897 mContext = context; 898 mAccountId = accountId; 899 } 900 901 @Override 902 public void onChange(boolean selfChange) { 903 ContentObserver observer = sInstance.mNotificationMap.get(mAccountId); 904 Account account = Account.restoreAccountWithId(mContext, mAccountId); 905 if (observer == null || account == null) { 906 Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification"); 907 return; 908 } 909 910 ContentResolver resolver = mContext.getContentResolver(); 911 Cursor c = resolver.query(ContentUris.withAppendedId( 912 EmailContent.MAILBOX_NOTIFICATION_URI, mAccountId), 913 EmailContent.NOTIFICATION_PROJECTION, null, null, null); 914 try { 915 while (c.moveToNext()) { 916 long mailboxId = c.getLong(EmailContent.NOTIFICATION_MAILBOX_ID_COLUMN); 917 if (mailboxId == 0) continue; 918 int messageCount = 919 c.getInt(EmailContent.NOTIFICATION_MAILBOX_MESSAGE_COUNT_COLUMN); 920 int unreadCount = 921 c.getInt(EmailContent.NOTIFICATION_MAILBOX_UNREAD_COUNT_COLUMN); 922 923 Mailbox m = Mailbox.restoreMailboxWithId(mContext, mailboxId); 924 long newMessageId = Utility.getFirstRowLong(mContext, 925 ContentUris.withAppendedId( 926 EmailContent.MAILBOX_MOST_RECENT_MESSAGE_URI, mailboxId), 927 Message.ID_COLUMN_PROJECTION, null, null, null, 928 Message.ID_MAILBOX_COLUMN_ID, -1L); 929 Log.d(Logging.LOG_TAG, "Changes to " + account.mDisplayName + "/" + 930 m.mDisplayName + ", count: " + messageCount + ", lastNotified: " + 931 m.mLastNotifiedMessageKey + ", mostRecent: " + newMessageId); 932 // Broadcast intent here 933 Intent i = new Intent(EmailBroadcastProcessorService.ACTION_NOTIFY_NEW_MAIL); 934 // Required by UIProvider 935 i.setType(EmailProvider.EMAIL_APP_MIME_TYPE); 936 i.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_FOLDER, 937 Uri.parse(EmailProvider.uiUriString("uifolder", mailboxId))); 938 i.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_ACCOUNT, 939 Uri.parse(EmailProvider.uiUriString("uiaccount", m.mAccountKey))); 940 i.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_UPDATED_UNREAD_COUNT, 941 unreadCount); 942 // Required by our notification controller 943 i.putExtra(NEW_MAIL_MAILBOX_ID, mailboxId); 944 i.putExtra(NEW_MAIL_MESSAGE_ID, newMessageId); 945 i.putExtra(NEW_MAIL_MESSAGE_COUNT, messageCount); 946 i.putExtra(NEW_MAIL_UNREAD_COUNT, unreadCount); 947 mContext.sendOrderedBroadcast(i, null); 948 } 949 } finally { 950 c.close(); 951 } 952 } 953 } 954 955 public static void notifyNewMail(Context context, Intent i) { 956 Log.d(Logging.LOG_TAG, "Sending notification to system..."); 957 NotificationController nc = NotificationController.getInstance(context); 958 ContentResolver resolver = context.getContentResolver(); 959 long mailboxId = i.getLongExtra(NEW_MAIL_MAILBOX_ID, -1); 960 long newMessageId = i.getLongExtra(NEW_MAIL_MESSAGE_ID, -1); 961 int messageCount = i.getIntExtra(NEW_MAIL_MESSAGE_COUNT, 0); 962 int unreadCount = i.getIntExtra(NEW_MAIL_UNREAD_COUNT, 0); 963 Notification n = nc.createNewMessageNotification(mailboxId, newMessageId, 964 messageCount, unreadCount); 965 if (n != null) { 966 // Make the notification visible 967 nc.mNotificationManager.notify(nc.getNewMessageNotificationId(mailboxId), n); 968 } 969 // Save away the new values 970 ContentValues cv = new ContentValues(); 971 cv.put(MailboxColumns.LAST_NOTIFIED_MESSAGE_KEY, newMessageId); 972 cv.put(MailboxColumns.LAST_NOTIFIED_MESSAGE_COUNT, messageCount); 973 resolver.update(ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailboxId), cv, 974 null, null); 975 } 976 977 /** 978 * Observer invoked whenever an account is modified. This could mean the user changed the 979 * notification settings. 980 */ 981 private static class AccountContentObserver extends ContentObserver { 982 private final Context mContext; 983 public AccountContentObserver(Handler handler, Context context) { 984 super(handler); 985 mContext = context; 986 } 987 988 @Override 989 public void onChange(boolean selfChange) { 990 final ContentResolver resolver = mContext.getContentResolver(); 991 final Cursor c = resolver.query(Account.CONTENT_URI, EmailContent.ID_PROJECTION, 992 NOTIFIED_ACCOUNT_SELECTION, null, null); 993 final HashSet<Long> newAccountList = new HashSet<Long>(); 994 final HashSet<Long> removedAccountList = new HashSet<Long>(); 995 if (c == null) { 996 // Suspender time ... theoretically, this will never happen 997 Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query"); 998 return; 999 } 1000 try { 1001 while (c.moveToNext()) { 1002 long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); 1003 newAccountList.add(accountId); 1004 } 1005 } finally { 1006 if (c != null) { 1007 c.close(); 1008 } 1009 } 1010 // NOTE: Looping over three lists is not necessarily the most efficient. However, the 1011 // account lists are going to be very small, so, this will not be necessarily bad. 1012 // Cycle through existing notification list and adjust as necessary 1013 for (long accountId : sInstance.mNotificationMap.keySet()) { 1014 if (!newAccountList.remove(accountId)) { 1015 // account id not in the current set of notifiable accounts 1016 removedAccountList.add(accountId); 1017 } 1018 } 1019 // A new account was added to the notification list 1020 for (long accountId : newAccountList) { 1021 sInstance.registerMessageNotification(accountId); 1022 } 1023 // An account was removed from the notification list 1024 for (long accountId : removedAccountList) { 1025 sInstance.unregisterMessageNotification(accountId); 1026 int notificationId = sInstance.getNewMessageNotificationId(accountId); 1027 sInstance.mNotificationManager.cancel(notificationId); 1028 } 1029 } 1030 } 1031 1032 /** 1033 * Thread to handle all notification actions through its own {@link Looper}. 1034 */ 1035 private static class NotificationThread implements Runnable { 1036 /** Lock to ensure proper initialization */ 1037 private final Object mLock = new Object(); 1038 /** The {@link Looper} that handles messages for this thread */ 1039 private Looper mLooper; 1040 1041 NotificationThread() { 1042 new Thread(null, this, "EmailNotification").start(); 1043 synchronized (mLock) { 1044 while (mLooper == null) { 1045 try { 1046 mLock.wait(); 1047 } catch (InterruptedException ex) { 1048 } 1049 } 1050 } 1051 } 1052 1053 @Override 1054 public void run() { 1055 synchronized (mLock) { 1056 Looper.prepare(); 1057 mLooper = Looper.myLooper(); 1058 mLock.notifyAll(); 1059 } 1060 Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); 1061 Looper.loop(); 1062 } 1063 void quit() { 1064 mLooper.quit(); 1065 } 1066 Looper getLooper() { 1067 return mLooper; 1068 } 1069 } 1070} 1071