Home | History | Annotate | Download | only in email
      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 
    119     private static boolean isRunningJellybeanOrLater() {
    120         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
    121     }
    122 
    123     /** Constructor */
    124     @VisibleForTesting
    125     NotificationController(Context context, Clock clock) {
    126         mContext = context.getApplicationContext();
    127         mNotificationManager = (NotificationManager) context.getSystemService(
    128                 Context.NOTIFICATION_SERVICE);
    129         mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
    130         mGenericSenderIcon = BitmapFactory.decodeResource(mContext.getResources(),
    131                 R.drawable.ic_contact_picture);
    132         mGenericMultipleSenderIcon = BitmapFactory.decodeResource(mContext.getResources(),
    133                 R.drawable.ic_notification_multiple_mail_holo_dark);
    134         mClock = clock;
    135         mNotificationMap = new HashMap<Long, ContentObserver>();
    136     }
    137 
    138     /** Singleton access */
    139     public static synchronized NotificationController getInstance(Context context) {
    140         if (sInstance == null) {
    141             sInstance = new NotificationController(context, Clock.INSTANCE);
    142         }
    143         return sInstance;
    144     }
    145 
    146     /**
    147      * Return whether or not a notification, based on the passed-in id, needs to be "ongoing"
    148      * @param notificationId the notification id to check
    149      * @return whether or not the notification must be "ongoing"
    150      */
    151     private boolean needsOngoingNotification(int notificationId) {
    152         // "Security needed" must be ongoing so that the user doesn't close it; otherwise, sync will
    153         // be prevented until a reboot.  Consider also doing this for password expired.
    154         return notificationId == NOTIFICATION_ID_SECURITY_NEEDED;
    155     }
    156 
    157     /**
    158      * Returns a {@link Notification.Builder} for an event with the given account. The account
    159      * contains specific rules on ring tone usage and these will be used to modify the notification
    160      * behaviour.
    161      *
    162      * @param account The account this notification is being built for.
    163      * @param ticker Text displayed when the notification is first shown. May be {@code null}.
    164      * @param title The first line of text. May NOT be {@code null}.
    165      * @param contentText The second line of text. May NOT be {@code null}.
    166      * @param intent The intent to start if the user clicks on the notification.
    167      * @param largeIcon A large icon. May be {@code null}
    168      * @param number A number to display using {@link Builder#setNumber(int)}. May
    169      *        be {@code null}.
    170      * @param enableAudio If {@code false}, do not play any sound. Otherwise, play sound according
    171      *        to the settings for the given account.
    172      * @return A {@link Notification} that can be sent to the notification service.
    173      */
    174     private Notification.Builder createBaseAccountNotificationBuilder(Account account,
    175             String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon,
    176             Integer number, boolean enableAudio, boolean ongoing) {
    177         // Pending Intent
    178         PendingIntent pending = null;
    179         if (intent != null) {
    180             pending = PendingIntent.getActivity(
    181                     mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
    182         }
    183 
    184         // NOTE: the ticker is not shown for notifications in the Holo UX
    185         final Notification.Builder builder = new Notification.Builder(mContext)
    186                 .setContentTitle(title)
    187                 .setContentText(contentText)
    188                 .setContentIntent(pending)
    189                 .setLargeIcon(largeIcon)
    190                 .setNumber(number == null ? 0 : number)
    191                 .setSmallIcon(R.drawable.stat_notify_email_generic)
    192                 .setWhen(mClock.getTime())
    193                 .setTicker(ticker)
    194                 .setOngoing(ongoing);
    195 
    196         if (enableAudio) {
    197             setupSoundAndVibration(builder, account);
    198         }
    199 
    200         return builder;
    201     }
    202 
    203     /**
    204      * Generic notifier for any account.  Uses notification rules from account.
    205      *
    206      * @param account The account this notification is being built for.
    207      * @param ticker Text displayed when the notification is first shown. May be {@code null}.
    208      * @param title The first line of text. May NOT be {@code null}.
    209      * @param contentText The second line of text. May NOT be {@code null}.
    210      * @param intent The intent to start if the user clicks on the notification.
    211      * @param notificationId The ID of the notification to register with the service.
    212      */
    213     private void showAccountNotification(Account account, String ticker, String title,
    214             String contentText, Intent intent, int notificationId) {
    215         Notification.Builder builder = createBaseAccountNotificationBuilder(account, ticker, title,
    216                 contentText, intent, null, null, true, needsOngoingNotification(notificationId));
    217         mNotificationManager.notify(notificationId, builder.getNotification());
    218     }
    219 
    220     /**
    221      * Returns a notification ID for new message notifications for the given account.
    222      */
    223     private int getNewMessageNotificationId(long accountId) {
    224         // We assume accountId will always be less than 0x0FFFFFFF; is there a better way?
    225         return (int) (NOTIFICATION_ID_BASE_NEW_MESSAGES + accountId);
    226     }
    227 
    228     /**
    229      * Tells the notification controller if it should be watching for changes to the message table.
    230      * This is the main life cycle method for message notifications. When we stop observing
    231      * database changes, we save the state [e.g. message ID and count] of the most recent
    232      * notification shown to the user. And, when we start observing database changes, we restore
    233      * the saved state.
    234      * @param watch If {@code true}, we register observers for all accounts whose settings have
    235      *              notifications enabled. Otherwise, all observers are unregistered.
    236      */
    237     public void watchForMessages(final boolean watch) {
    238         if (Email.DEBUG) {
    239             Log.i(Logging.LOG_TAG, "Notifications being toggled: " + watch);
    240         }
    241         // Don't create the thread if we're only going to stop watching
    242         if (!watch && sNotificationThread == null) return;
    243 
    244         ensureHandlerExists();
    245         // Run this on the message notification handler
    246         sNotificationHandler.post(new Runnable() {
    247             @Override
    248             public void run() {
    249                 ContentResolver resolver = mContext.getContentResolver();
    250                 if (!watch) {
    251                     unregisterMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
    252                     if (mAccountObserver != null) {
    253                         resolver.unregisterContentObserver(mAccountObserver);
    254                         mAccountObserver = null;
    255                     }
    256 
    257                     // tear down the event loop
    258                     sNotificationThread.quit();
    259                     sNotificationThread = null;
    260                     return;
    261                 }
    262 
    263                 // otherwise, start new observers for all notified accounts
    264                 registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
    265                 // If we're already observing account changes, don't do anything else
    266                 if (mAccountObserver == null) {
    267                     if (Email.DEBUG) {
    268                         Log.i(Logging.LOG_TAG, "Observing account changes for notifications");
    269                     }
    270                     mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext);
    271                     resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver);
    272                 }
    273             }
    274         });
    275     }
    276 
    277     /**
    278      * Temporarily suspend a single account from receiving notifications. NOTE: only a single
    279      * account may ever be suspended at a time. So, if this method is invoked a second time,
    280      * notifications for the previously suspended account will automatically be re-activated.
    281      * @param suspend If {@code true}, suspend notifications for the given account. Otherwise,
    282      *              re-activate notifications for the previously suspended account.
    283      * @param accountId The ID of the account. If this is the special account ID
    284      *              {@link Account#ACCOUNT_ID_COMBINED_VIEW},  notifications for all accounts are
    285      *              suspended. If {@code suspend} is {@code false}, the account ID is ignored.
    286      */
    287     public void suspendMessageNotification(boolean suspend, long accountId) {
    288         if (mSuspendAccountId != Account.NO_ACCOUNT) {
    289             // we're already suspending an account; un-suspend it
    290             mSuspendAccountId = Account.NO_ACCOUNT;
    291         }
    292         if (suspend && accountId != Account.NO_ACCOUNT && accountId > 0L) {
    293             mSuspendAccountId = accountId;
    294             if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
    295                 // Only go onto the notification handler if we really, absolutely need to
    296                 ensureHandlerExists();
    297                 sNotificationHandler.post(new Runnable() {
    298                     @Override
    299                     public void run() {
    300                         for (long accountId : mNotificationMap.keySet()) {
    301                             mNotificationManager.cancel(getNewMessageNotificationId(accountId));
    302                         }
    303                     }
    304                 });
    305             } else {
    306                 mNotificationManager.cancel(getNewMessageNotificationId(accountId));
    307             }
    308         }
    309     }
    310 
    311     /**
    312      * Ensures the notification handler exists and is ready to handle requests.
    313      */
    314     private static synchronized void ensureHandlerExists() {
    315         if (sNotificationThread == null) {
    316             sNotificationThread = new NotificationThread();
    317             sNotificationHandler = new Handler(sNotificationThread.getLooper());
    318         }
    319     }
    320 
    321     /**
    322      * Registers an observer for changes to the INBOX for the given account. Since accounts
    323      * may only have a single INBOX, we will never have more than one observer for an account.
    324      * NOTE: This must be called on the notification handler thread.
    325      * @param accountId The ID of the account to register the observer for. May be
    326      *                  {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all
    327      *                  accounts that allow for user notification.
    328      */
    329     private void registerMessageNotification(long accountId) {
    330         ContentResolver resolver = mContext.getContentResolver();
    331         if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
    332             Cursor c = resolver.query(
    333                     Account.CONTENT_URI, EmailContent.ID_PROJECTION,
    334                     NOTIFIED_ACCOUNT_SELECTION, null, null);
    335             try {
    336                 while (c.moveToNext()) {
    337                     long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
    338                     registerMessageNotification(id);
    339                 }
    340             } finally {
    341                 c.close();
    342             }
    343         } else {
    344             ContentObserver obs = mNotificationMap.get(accountId);
    345             if (obs != null) return;  // we're already observing; nothing to do
    346 
    347             Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, accountId, Mailbox.TYPE_INBOX);
    348             if (mailbox == null) {
    349                 Log.w(Logging.LOG_TAG, "Could not load INBOX for account id: " + accountId);
    350                 return;
    351             }
    352             if (Email.DEBUG) {
    353                 Log.i(Logging.LOG_TAG, "Registering for notifications for account " + accountId);
    354             }
    355             ContentObserver observer = new MessageContentObserver(
    356                     sNotificationHandler, mContext, mailbox.mId, accountId);
    357             resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer);
    358             mNotificationMap.put(accountId, observer);
    359             // Now, ping the observer for any initial notifications
    360             observer.onChange(true);
    361         }
    362     }
    363 
    364     /**
    365      * Unregisters the observer for the given account. If the specified account does not have
    366      * a registered observer, no action is performed. This will not clear any existing notification
    367      * for the specified account. Use {@link NotificationManager#cancel(int)}.
    368      * NOTE: This must be called on the notification handler thread.
    369      * @param accountId The ID of the account to unregister from. To unregister all accounts that
    370      *                  have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}.
    371      */
    372     private void unregisterMessageNotification(long accountId) {
    373         ContentResolver resolver = mContext.getContentResolver();
    374         if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
    375             if (Email.DEBUG) {
    376                 Log.i(Logging.LOG_TAG, "Unregistering notifications for all accounts");
    377             }
    378             // cancel all existing message observers
    379             for (ContentObserver observer : mNotificationMap.values()) {
    380                 resolver.unregisterContentObserver(observer);
    381             }
    382             mNotificationMap.clear();
    383         } else {
    384             if (Email.DEBUG) {
    385                 Log.i(Logging.LOG_TAG, "Unregistering notifications for account " + accountId);
    386             }
    387             ContentObserver observer = mNotificationMap.remove(accountId);
    388             if (observer != null) {
    389                 resolver.unregisterContentObserver(observer);
    390             }
    391         }
    392     }
    393 
    394     /**
    395      * Returns a picture of the sender of the given message. If no picture is available, returns
    396      * {@code null}.
    397      *
    398      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    399      */
    400     private Bitmap getSenderPhoto(Message message) {
    401         Address sender = Address.unpackFirst(message.mFrom);
    402         if (sender == null) {
    403             return null;
    404         }
    405         String email = sender.getAddress();
    406         if (TextUtils.isEmpty(email)) {
    407             return null;
    408         }
    409         Bitmap photo = ContactStatusLoader.getContactInfo(mContext, email).mPhoto;
    410 
    411         if (photo != null) {
    412             final Resources res = mContext.getResources();
    413             final int idealIconHeight =
    414                     res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
    415             final int idealIconWidth =
    416                     res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
    417 
    418             if (photo.getHeight() < idealIconHeight) {
    419                 // We should scale this image to fit the intended size
    420                 photo = Bitmap.createScaledBitmap(
    421                         photo, idealIconWidth, idealIconHeight, true);
    422             }
    423         }
    424         return photo;
    425     }
    426 
    427     /**
    428      * Returns a "new message" notification for the given account.
    429      *
    430      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    431      */
    432     @VisibleForTesting
    433     Notification createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor,
    434             long newestMessageId, int unseenMessageCount, int unreadCount) {
    435         final Account account = Account.restoreAccountWithId(mContext, accountId);
    436         if (account == null) {
    437             return null;
    438         }
    439         // Get the latest message
    440         final Message message = Message.restoreMessageWithId(mContext, newestMessageId);
    441         if (message == null) {
    442             return null; // no message found???
    443         }
    444 
    445         String senderName = Address.toFriendly(Address.unpack(message.mFrom));
    446         if (senderName == null) {
    447             senderName = ""; // Happens when a message has no from.
    448         }
    449         final boolean multipleUnseen = unseenMessageCount > 1;
    450         final Bitmap senderPhoto = multipleUnseen
    451                 ? mGenericMultipleSenderIcon
    452                 : getSenderPhoto(message);
    453         final SpannableString title = getNewMessageTitle(senderName, unseenMessageCount);
    454         // TODO: add in display name on the second line for the text, once framework supports
    455         // multiline texts.
    456         final String text = multipleUnseen
    457                 ? account.mDisplayName
    458                 : message.mSubject;
    459         final Bitmap largeIcon = senderPhoto != null ? senderPhoto : mGenericSenderIcon;
    460         final Integer number = unreadCount > 1 ? unreadCount : null;
    461         final Intent intent;
    462         if (unseenMessageCount > 1) {
    463             intent = Welcome.createOpenAccountInboxIntent(mContext, accountId);
    464         } else {
    465             intent = Welcome.createOpenMessageIntent(
    466                     mContext, accountId, mailboxId, newestMessageId);
    467         }
    468         intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
    469                 Intent.FLAG_ACTIVITY_TASK_ON_HOME);
    470         long now = mClock.getTime();
    471         boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS;
    472         final Notification.Builder builder = createBaseAccountNotificationBuilder(
    473                 account, title.toString(), title, text,
    474                 intent, largeIcon, number, enableAudio, false);
    475         if (isRunningJellybeanOrLater()) {
    476             // For a new-style notification
    477             if (multipleUnseen) {
    478                 if (messageCursor != null) {
    479                     final int maxNumDigestItems = mContext.getResources().getInteger(
    480                             R.integer.max_num_notification_digest_items);
    481                     // The body of the notification is the account name, or the label name.
    482                     builder.setSubText(text);
    483 
    484                     Notification.InboxStyle digest = new Notification.InboxStyle(builder);
    485 
    486                     digest.setBigContentTitle(title);
    487 
    488                     int numDigestItems = 0;
    489                     // We can assume that the current position of the cursor is on the
    490                     // newest message
    491                     do {
    492                         final long messageId =
    493                                 messageCursor.getLong(EmailContent.ID_PROJECTION_COLUMN);
    494 
    495                         // Get the latest message
    496                         final Message digestMessage =
    497                                 Message.restoreMessageWithId(mContext, messageId);
    498                         if (digestMessage != null) {
    499                             final CharSequence digestLine =
    500                                     getSingleMessageInboxLine(mContext, digestMessage);
    501                             digest.addLine(digestLine);
    502                             numDigestItems++;
    503                         }
    504                     } while (numDigestItems <= maxNumDigestItems && messageCursor.moveToNext());
    505 
    506                     // We want to clear the content text in this case. The content text would have
    507                     // been set in createBaseAccountNotificationBuilder, but since the same string
    508                     // was set in as the subtext, we don't want to show a duplicate string.
    509                     builder.setContentText(null);
    510                 }
    511             } else {
    512                 // The notification content will be the subject of the conversation.
    513                 builder.setContentText(getSingleMessageLittleText(mContext, message.mSubject));
    514 
    515                 // The notification subtext will be the subject of the conversation for inbox
    516                 // notifications, or will based on the the label name for user label notifications.
    517                 builder.setSubText(account.mDisplayName);
    518 
    519                 final Notification.BigTextStyle bigText = new Notification.BigTextStyle(builder);
    520                 bigText.bigText(getSingleMessageBigText(mContext, message));
    521             }
    522         }
    523 
    524         mLastMessageNotifyTime = now;
    525         return builder.getNotification();
    526     }
    527 
    528     /**
    529      * Sets the bigtext for a notification for a single new conversation
    530      * @param context
    531      * @param message New message that triggered the notification.
    532      * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle}
    533      */
    534     private static CharSequence getSingleMessageInboxLine(Context context, Message message) {
    535         final String subject = message.mSubject;
    536         final String snippet = message.mSnippet;
    537         final String senders = Address.toFriendly(Address.unpack(message.mFrom));
    538 
    539         final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
    540 
    541         final TextAppearanceSpan notificationPrimarySpan =
    542                 new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
    543 
    544         if (TextUtils.isEmpty(senders)) {
    545             // If the senders are empty, just use the subject/snippet.
    546             return subjectSnippet;
    547         }
    548         else if (TextUtils.isEmpty(subjectSnippet)) {
    549             // If the subject/snippet is empty, just use the senders.
    550             final SpannableString spannableString = new SpannableString(senders);
    551             spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
    552 
    553             return spannableString;
    554         } else {
    555             final String formatString = context.getResources().getString(
    556                     R.string.multiple_new_message_notification_item);
    557             final TextAppearanceSpan notificationSecondarySpan =
    558                     new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
    559 
    560             final String instantiatedString = String.format(formatString, senders, subjectSnippet);
    561 
    562             final SpannableString spannableString = new SpannableString(instantiatedString);
    563 
    564             final boolean isOrderReversed = formatString.indexOf("%2$s") <
    565                     formatString.indexOf("%1$s");
    566             final int primaryOffset =
    567                     (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
    568                      instantiatedString.indexOf(senders));
    569             final int secondaryOffset =
    570                     (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
    571                      instantiatedString.indexOf(subjectSnippet));
    572             spannableString.setSpan(notificationPrimarySpan,
    573                     primaryOffset, primaryOffset + senders.length(), 0);
    574             spannableString.setSpan(notificationSecondarySpan,
    575                     secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
    576             return spannableString;
    577         }
    578     }
    579 
    580     /**
    581      * Sets the bigtext for a notification for a single new conversation
    582      * @param context
    583      * @param subject Subject of the new message that triggered the notification
    584      * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText}
    585      */
    586     private static CharSequence getSingleMessageLittleText(Context context, String subject) {
    587         if (subject == null) {
    588             return null;
    589         }
    590         final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
    591                 context, R.style.NotificationPrimaryText);
    592 
    593         final SpannableString spannableString = new SpannableString(subject);
    594         spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
    595 
    596         return spannableString;
    597     }
    598 
    599 
    600     /**
    601      * Sets the bigtext for a notification for a single new conversation
    602      * @param context
    603      * @param message New message that triggered the notification
    604      * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle}
    605      */
    606     private static CharSequence getSingleMessageBigText(Context context, Message message) {
    607         final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
    608                 context, R.style.NotificationPrimaryText);
    609 
    610         final String subject = message.mSubject;
    611         final String snippet = message.mSnippet;
    612 
    613         if (TextUtils.isEmpty(subject)) {
    614             // If the subject is empty, just use the snippet.
    615             return snippet;
    616         }
    617         else if (TextUtils.isEmpty(snippet)) {
    618             // If the snippet is empty, just use the subject.
    619             final SpannableString spannableString = new SpannableString(subject);
    620             spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
    621 
    622             return spannableString;
    623         } else {
    624             final String notificationBigTextFormat = context.getResources().getString(
    625                     R.string.single_new_message_notification_big_text);
    626 
    627             // Localizers may change the order of the parameters, look at how the format
    628             // string is structured.
    629             final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
    630                     notificationBigTextFormat.indexOf("%1$s");
    631             final String bigText = String.format(notificationBigTextFormat, subject, snippet);
    632             final SpannableString spannableString = new SpannableString(bigText);
    633 
    634             final int subjectOffset =
    635                     (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
    636             spannableString.setSpan(notificationSubjectSpan,
    637                     subjectOffset, subjectOffset + subject.length(), 0);
    638 
    639             return spannableString;
    640         }
    641     }
    642 
    643     /**
    644      * Creates a notification title for a new message. If there is only a single message,
    645      * show the sender name. Otherwise, show "X new messages".
    646      */
    647     @VisibleForTesting
    648     SpannableString getNewMessageTitle(String sender, int unseenCount) {
    649         String title;
    650         if (unseenCount > 1) {
    651             title = String.format(
    652                     mContext.getString(R.string.notification_multiple_new_messages_fmt),
    653                     unseenCount);
    654         } else {
    655             title = sender;
    656         }
    657         return new SpannableString(title);
    658     }
    659 
    660     /** Returns the system's current ringer mode */
    661     @VisibleForTesting
    662     int getRingerMode() {
    663         return mAudioManager.getRingerMode();
    664     }
    665 
    666     /** Sets up the notification's sound and vibration based upon account details. */
    667     @VisibleForTesting
    668     void setupSoundAndVibration(Notification.Builder builder, Account account) {
    669         final int flags = account.mFlags;
    670         final String ringtoneUri = account.mRingtoneUri;
    671         final boolean vibrate = (flags & Account.FLAGS_VIBRATE_ALWAYS) != 0;
    672         final boolean vibrateWhenSilent = (flags & Account.FLAGS_VIBRATE_WHEN_SILENT) != 0;
    673         final boolean isRingerSilent = getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
    674 
    675         int defaults = Notification.DEFAULT_LIGHTS;
    676         if (vibrate || (vibrateWhenSilent && isRingerSilent)) {
    677             defaults |= Notification.DEFAULT_VIBRATE;
    678         }
    679 
    680         builder.setSound((ringtoneUri == null) ? null : Uri.parse(ringtoneUri))
    681             .setDefaults(defaults);
    682     }
    683 
    684     /**
    685      * Show (or update) a notification that the given attachment could not be forwarded. This
    686      * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
    687      * it's helpful for debugging.
    688      *
    689      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    690      */
    691     public void showDownloadForwardFailedNotification(Attachment attachment) {
    692         final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey);
    693         if (account == null) return;
    694         showAccountNotification(account,
    695                 mContext.getString(R.string.forward_download_failed_ticker),
    696                 mContext.getString(R.string.forward_download_failed_title),
    697                 attachment.mFileName,
    698                 null,
    699                 NOTIFICATION_ID_ATTACHMENT_WARNING);
    700     }
    701 
    702     /**
    703      * Returns a notification ID for login failed notifications for the given account account.
    704      */
    705     private int getLoginFailedNotificationId(long accountId) {
    706         return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
    707     }
    708 
    709     /**
    710      * Show (or update) a notification that there was a login failure for the given account.
    711      *
    712      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    713      */
    714     public void showLoginFailedNotification(long accountId) {
    715         final Account account = Account.restoreAccountWithId(mContext, accountId);
    716         if (account == null) return;
    717         showAccountNotification(account,
    718                 mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
    719                 mContext.getString(R.string.login_failed_title),
    720                 account.getDisplayName(),
    721                 AccountSettings.createAccountSettingsIntent(mContext, accountId,
    722                         account.mDisplayName),
    723                 getLoginFailedNotificationId(accountId));
    724     }
    725 
    726     /**
    727      * Cancels the login failed notification for the given account.
    728      */
    729     public void cancelLoginFailedNotification(long accountId) {
    730         mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
    731     }
    732 
    733     /**
    734      * Show (or update) a notification that the user's password is expiring. The given account
    735      * is used to update the display text, but, all accounts share the same notification ID.
    736      *
    737      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    738      */
    739     public void showPasswordExpiringNotification(long accountId) {
    740         Account account = Account.restoreAccountWithId(mContext, accountId);
    741         if (account == null) return;
    742 
    743         Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
    744                 accountId, false);
    745         String accountName = account.getDisplayName();
    746         String ticker =
    747             mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
    748         String title = mContext.getString(R.string.password_expire_warning_content_title);
    749         showAccountNotification(account, ticker, title, accountName, intent,
    750                 NOTIFICATION_ID_PASSWORD_EXPIRING);
    751     }
    752 
    753     /**
    754      * Show (or update) a notification that the user's password has expired. The given account
    755      * is used to update the display text, but, all accounts share the same notification ID.
    756      *
    757      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    758      */
    759     public void showPasswordExpiredNotification(long accountId) {
    760         Account account = Account.restoreAccountWithId(mContext, accountId);
    761         if (account == null) return;
    762 
    763         Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
    764                 accountId, true);
    765         String accountName = account.getDisplayName();
    766         String ticker = mContext.getString(R.string.password_expired_ticker);
    767         String title = mContext.getString(R.string.password_expired_content_title);
    768         showAccountNotification(account, ticker, title, accountName, intent,
    769                 NOTIFICATION_ID_PASSWORD_EXPIRED);
    770     }
    771 
    772     /**
    773      * Cancels any password expire notifications [both expired & expiring].
    774      */
    775     public void cancelPasswordExpirationNotifications() {
    776         mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
    777         mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
    778     }
    779 
    780     /**
    781      * Show (or update) a security needed notification. The given account is used to update
    782      * the display text, but, all accounts share the same notification ID.
    783      */
    784     public void showSecurityNeededNotification(Account account) {
    785         Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
    786         String accountName = account.getDisplayName();
    787         String ticker =
    788             mContext.getString(R.string.security_notification_ticker_fmt, accountName);
    789         String title = mContext.getString(R.string.security_notification_content_title);
    790         showAccountNotification(account, ticker, title, accountName, intent,
    791                 NOTIFICATION_ID_SECURITY_NEEDED);
    792     }
    793 
    794     /**
    795      * Cancels the security needed notification.
    796      */
    797     public void cancelSecurityNeededNotification() {
    798         mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED);
    799     }
    800 
    801     /**
    802      * Observer invoked whenever a message we're notifying the user about changes.
    803      */
    804     private static class MessageContentObserver extends ContentObserver {
    805         /** A selection to get messages the user hasn't seen before */
    806         private final static String MESSAGE_SELECTION =
    807                 MessageColumns.MAILBOX_KEY + "=? AND "
    808                 + MessageColumns.ID + ">? AND "
    809                 + MessageColumns.FLAG_READ + "=0 AND "
    810                 + Message.FLAG_LOADED_SELECTION;
    811         private final Context mContext;
    812         private final long mMailboxId;
    813         private final long mAccountId;
    814 
    815         public MessageContentObserver(
    816                 Handler handler, Context context, long mailboxId, long accountId) {
    817             super(handler);
    818             mContext = context;
    819             mMailboxId = mailboxId;
    820             mAccountId = accountId;
    821         }
    822 
    823         @Override
    824         public void onChange(boolean selfChange) {
    825             if (mAccountId == sInstance.mSuspendAccountId
    826                     || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
    827                 return;
    828             }
    829 
    830             ContentObserver observer = sInstance.mNotificationMap.get(mAccountId);
    831             if (observer == null) {
    832                 // Notification for a mailbox that we aren't observing; account is probably
    833                 // being deleted.
    834                 Log.w(Logging.LOG_TAG, "Received notification when observer data was null");
    835                 return;
    836             }
    837             Account account = Account.restoreAccountWithId(mContext, mAccountId);
    838             if (account == null) {
    839                 Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification");
    840                 return;
    841             }
    842             long oldMessageId = account.mNotifiedMessageId;
    843             int oldMessageCount = account.mNotifiedMessageCount;
    844 
    845             ContentResolver resolver = mContext.getContentResolver();
    846             Long lastSeenMessageId = Utility.getFirstRowLong(
    847                     mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
    848                     new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
    849                     null, null, null, 0);
    850             if (lastSeenMessageId == null) {
    851                 // Mailbox got nuked. Could be that the account is in the process of being deleted
    852                 Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification");
    853                 return;
    854             }
    855 
    856             Cursor c = resolver.query(
    857                     Message.CONTENT_URI, EmailContent.ID_PROJECTION,
    858                     MESSAGE_SELECTION,
    859                     new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) },
    860                     MessageColumns.ID + " DESC");
    861             if (c == null) {
    862                 // Couldn't find message info - things may be getting deleted in bulk.
    863                 Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query");
    864                 return;
    865             }
    866             try {
    867                 int newMessageCount = c.getCount();
    868                 long newMessageId = 0L;
    869                 if (c.moveToNext()) {
    870                     newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
    871                 }
    872 
    873                 if (newMessageCount == 0) {
    874                     // No messages to notify for; clear the notification
    875                     int notificationId = sInstance.getNewMessageNotificationId(mAccountId);
    876                     sInstance.mNotificationManager.cancel(notificationId);
    877                 } else if (newMessageCount != oldMessageCount
    878                         || (newMessageId != 0 && newMessageId != oldMessageId)) {
    879                     // Either the count or last message has changed; update the notification
    880                     Integer unreadCount = Utility.getFirstRowInt(
    881                             mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
    882                             new String[] { MailboxColumns.UNREAD_COUNT },
    883                             null, null, null, 0);
    884                     if (unreadCount == null) {
    885                         Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox");
    886                         return;
    887                     }
    888 
    889                     Notification n = sInstance.createNewMessageNotification(
    890                             mAccountId, mMailboxId, c, newMessageId,
    891                             newMessageCount, unreadCount);
    892                     if (n != null) {
    893                         // Make the notification visible
    894                         sInstance.mNotificationManager.notify(
    895                                 sInstance.getNewMessageNotificationId(mAccountId), n);
    896                     }
    897                 }
    898                 // Save away the new values
    899                 ContentValues cv = new ContentValues();
    900                 cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId);
    901                 cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount);
    902                 resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv,
    903                         null, null);
    904             } finally {
    905                 c.close();
    906             }
    907         }
    908     }
    909 
    910     /**
    911      * Observer invoked whenever an account is modified. This could mean the user changed the
    912      * notification settings.
    913      */
    914     private static class AccountContentObserver extends ContentObserver {
    915         private final Context mContext;
    916         public AccountContentObserver(Handler handler, Context context) {
    917             super(handler);
    918             mContext = context;
    919         }
    920 
    921         @Override
    922         public void onChange(boolean selfChange) {
    923             final ContentResolver resolver = mContext.getContentResolver();
    924             final Cursor c = resolver.query(
    925                 Account.CONTENT_URI, EmailContent.ID_PROJECTION,
    926                 NOTIFIED_ACCOUNT_SELECTION, null, null);
    927             final HashSet<Long> newAccountList = new HashSet<Long>();
    928             final HashSet<Long> removedAccountList = new HashSet<Long>();
    929             if (c == null) {
    930                 // Suspender time ... theoretically, this will never happen
    931                 Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query");
    932                 return;
    933             }
    934             try {
    935                 while (c.moveToNext()) {
    936                     long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
    937                     newAccountList.add(accountId);
    938                 }
    939             } finally {
    940                 if (c != null) {
    941                     c.close();
    942                 }
    943             }
    944             // NOTE: Looping over three lists is not necessarily the most efficient. However, the
    945             // account lists are going to be very small, so, this will not be necessarily bad.
    946             // Cycle through existing notification list and adjust as necessary
    947             for (long accountId : sInstance.mNotificationMap.keySet()) {
    948                 if (!newAccountList.remove(accountId)) {
    949                     // account id not in the current set of notifiable accounts
    950                     removedAccountList.add(accountId);
    951                 }
    952             }
    953             // A new account was added to the notification list
    954             for (long accountId : newAccountList) {
    955                 sInstance.registerMessageNotification(accountId);
    956             }
    957             // An account was removed from the notification list
    958             for (long accountId : removedAccountList) {
    959                 sInstance.unregisterMessageNotification(accountId);
    960                 int notificationId = sInstance.getNewMessageNotificationId(accountId);
    961                 sInstance.mNotificationManager.cancel(notificationId);
    962             }
    963         }
    964     }
    965 
    966     /**
    967      * Thread to handle all notification actions through its own {@link Looper}.
    968      */
    969     private static class NotificationThread implements Runnable {
    970         /** Lock to ensure proper initialization */
    971         private final Object mLock = new Object();
    972         /** The {@link Looper} that handles messages for this thread */
    973         private Looper mLooper;
    974 
    975         NotificationThread() {
    976             new Thread(null, this, "EmailNotification").start();
    977             synchronized (mLock) {
    978                 while (mLooper == null) {
    979                     try {
    980                         mLock.wait();
    981                     } catch (InterruptedException ex) {
    982                     }
    983                 }
    984             }
    985         }
    986 
    987         @Override
    988         public void run() {
    989             synchronized (mLock) {
    990                 Looper.prepare();
    991                 mLooper = Looper.myLooper();
    992                 mLock.notifyAll();
    993             }
    994             Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
    995             Looper.loop();
    996         }
    997         void quit() {
    998             mLooper.quit();
    999         }
   1000         Looper getLooper() {
   1001             return mLooper;
   1002         }
   1003     }
   1004 }
   1005