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) != 0;
    672 
    673         int defaults = Notification.DEFAULT_LIGHTS;
    674         if (vibrate) {
    675             defaults |= Notification.DEFAULT_VIBRATE;
    676         }
    677 
    678         builder.setSound(TextUtils.isEmpty(ringtoneUri) ? null : Uri.parse(ringtoneUri))
    679             .setDefaults(defaults);
    680     }
    681 
    682     /**
    683      * Show (or update) a notification that the given attachment could not be forwarded. This
    684      * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
    685      * it's helpful for debugging.
    686      *
    687      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    688      */
    689     public void showDownloadForwardFailedNotification(Attachment attachment) {
    690         final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey);
    691         if (account == null) return;
    692         showAccountNotification(account,
    693                 mContext.getString(R.string.forward_download_failed_ticker),
    694                 mContext.getString(R.string.forward_download_failed_title),
    695                 attachment.mFileName,
    696                 null,
    697                 NOTIFICATION_ID_ATTACHMENT_WARNING);
    698     }
    699 
    700     /**
    701      * Returns a notification ID for login failed notifications for the given account account.
    702      */
    703     private int getLoginFailedNotificationId(long accountId) {
    704         return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
    705     }
    706 
    707     /**
    708      * Show (or update) a notification that there was a login failure for the given account.
    709      *
    710      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    711      */
    712     public void showLoginFailedNotification(long accountId) {
    713         final Account account = Account.restoreAccountWithId(mContext, accountId);
    714         if (account == null) return;
    715         showAccountNotification(account,
    716                 mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
    717                 mContext.getString(R.string.login_failed_title),
    718                 account.getDisplayName(),
    719                 AccountSettings.createAccountSettingsIntent(mContext, accountId,
    720                         account.mDisplayName),
    721                 getLoginFailedNotificationId(accountId));
    722     }
    723 
    724     /**
    725      * Cancels the login failed notification for the given account.
    726      */
    727     public void cancelLoginFailedNotification(long accountId) {
    728         mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
    729     }
    730 
    731     /**
    732      * Show (or update) a notification that the user's password is expiring. The given account
    733      * is used to update the display text, but, all accounts share the same notification ID.
    734      *
    735      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    736      */
    737     public void showPasswordExpiringNotification(long accountId) {
    738         Account account = Account.restoreAccountWithId(mContext, accountId);
    739         if (account == null) return;
    740 
    741         Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
    742                 accountId, false);
    743         String accountName = account.getDisplayName();
    744         String ticker =
    745             mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
    746         String title = mContext.getString(R.string.password_expire_warning_content_title);
    747         showAccountNotification(account, ticker, title, accountName, intent,
    748                 NOTIFICATION_ID_PASSWORD_EXPIRING);
    749     }
    750 
    751     /**
    752      * Show (or update) a notification that the user's password has expired. The given account
    753      * is used to update the display text, but, all accounts share the same notification ID.
    754      *
    755      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
    756      */
    757     public void showPasswordExpiredNotification(long accountId) {
    758         Account account = Account.restoreAccountWithId(mContext, accountId);
    759         if (account == null) return;
    760 
    761         Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
    762                 accountId, true);
    763         String accountName = account.getDisplayName();
    764         String ticker = mContext.getString(R.string.password_expired_ticker);
    765         String title = mContext.getString(R.string.password_expired_content_title);
    766         showAccountNotification(account, ticker, title, accountName, intent,
    767                 NOTIFICATION_ID_PASSWORD_EXPIRED);
    768     }
    769 
    770     /**
    771      * Cancels any password expire notifications [both expired & expiring].
    772      */
    773     public void cancelPasswordExpirationNotifications() {
    774         mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
    775         mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
    776     }
    777 
    778     /**
    779      * Show (or update) a security needed notification. The given account is used to update
    780      * the display text, but, all accounts share the same notification ID.
    781      */
    782     public void showSecurityNeededNotification(Account account) {
    783         Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
    784         String accountName = account.getDisplayName();
    785         String ticker =
    786             mContext.getString(R.string.security_notification_ticker_fmt, accountName);
    787         String title = mContext.getString(R.string.security_notification_content_title);
    788         showAccountNotification(account, ticker, title, accountName, intent,
    789                 NOTIFICATION_ID_SECURITY_NEEDED);
    790     }
    791 
    792     /**
    793      * Cancels the security needed notification.
    794      */
    795     public void cancelSecurityNeededNotification() {
    796         mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED);
    797     }
    798 
    799     /**
    800      * Observer invoked whenever a message we're notifying the user about changes.
    801      */
    802     private static class MessageContentObserver extends ContentObserver {
    803         /** A selection to get messages the user hasn't seen before */
    804         private final static String MESSAGE_SELECTION =
    805                 MessageColumns.MAILBOX_KEY + "=? AND "
    806                 + MessageColumns.ID + ">? AND "
    807                 + MessageColumns.FLAG_READ + "=0 AND "
    808                 + Message.FLAG_LOADED_SELECTION;
    809         private final Context mContext;
    810         private final long mMailboxId;
    811         private final long mAccountId;
    812 
    813         public MessageContentObserver(
    814                 Handler handler, Context context, long mailboxId, long accountId) {
    815             super(handler);
    816             mContext = context;
    817             mMailboxId = mailboxId;
    818             mAccountId = accountId;
    819         }
    820 
    821         @Override
    822         public void onChange(boolean selfChange) {
    823             if (mAccountId == sInstance.mSuspendAccountId
    824                     || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
    825                 return;
    826             }
    827 
    828             ContentObserver observer = sInstance.mNotificationMap.get(mAccountId);
    829             if (observer == null) {
    830                 // Notification for a mailbox that we aren't observing; account is probably
    831                 // being deleted.
    832                 Log.w(Logging.LOG_TAG, "Received notification when observer data was null");
    833                 return;
    834             }
    835             Account account = Account.restoreAccountWithId(mContext, mAccountId);
    836             if (account == null) {
    837                 Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification");
    838                 return;
    839             }
    840             long oldMessageId = account.mNotifiedMessageId;
    841             int oldMessageCount = account.mNotifiedMessageCount;
    842 
    843             ContentResolver resolver = mContext.getContentResolver();
    844             Long lastSeenMessageId = Utility.getFirstRowLong(
    845                     mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
    846                     new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
    847                     null, null, null, 0);
    848             if (lastSeenMessageId == null) {
    849                 // Mailbox got nuked. Could be that the account is in the process of being deleted
    850                 Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification");
    851                 return;
    852             }
    853 
    854             Cursor c = resolver.query(
    855                     Message.CONTENT_URI, EmailContent.ID_PROJECTION,
    856                     MESSAGE_SELECTION,
    857                     new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) },
    858                     MessageColumns.ID + " DESC");
    859             if (c == null) {
    860                 // Couldn't find message info - things may be getting deleted in bulk.
    861                 Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query");
    862                 return;
    863             }
    864             try {
    865                 int newMessageCount = c.getCount();
    866                 long newMessageId = 0L;
    867                 if (c.moveToNext()) {
    868                     newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
    869                 }
    870 
    871                 if (newMessageCount == 0) {
    872                     // No messages to notify for; clear the notification
    873                     int notificationId = sInstance.getNewMessageNotificationId(mAccountId);
    874                     sInstance.mNotificationManager.cancel(notificationId);
    875                 } else if (newMessageCount != oldMessageCount
    876                         || (newMessageId != 0 && newMessageId != oldMessageId)) {
    877                     // Either the count or last message has changed; update the notification
    878                     Integer unreadCount = Utility.getFirstRowInt(
    879                             mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
    880                             new String[] { MailboxColumns.UNREAD_COUNT },
    881                             null, null, null, 0);
    882                     if (unreadCount == null) {
    883                         Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox");
    884                         return;
    885                     }
    886 
    887                     Notification n = sInstance.createNewMessageNotification(
    888                             mAccountId, mMailboxId, c, newMessageId,
    889                             newMessageCount, unreadCount);
    890                     if (n != null) {
    891                         // Make the notification visible
    892                         sInstance.mNotificationManager.notify(
    893                                 sInstance.getNewMessageNotificationId(mAccountId), n);
    894                     }
    895                 }
    896                 // Save away the new values
    897                 ContentValues cv = new ContentValues();
    898                 cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId);
    899                 cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount);
    900                 resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv,
    901                         null, null);
    902             } finally {
    903                 c.close();
    904             }
    905         }
    906     }
    907 
    908     /**
    909      * Observer invoked whenever an account is modified. This could mean the user changed the
    910      * notification settings.
    911      */
    912     private static class AccountContentObserver extends ContentObserver {
    913         private final Context mContext;
    914         public AccountContentObserver(Handler handler, Context context) {
    915             super(handler);
    916             mContext = context;
    917         }
    918 
    919         @Override
    920         public void onChange(boolean selfChange) {
    921             final ContentResolver resolver = mContext.getContentResolver();
    922             final Cursor c = resolver.query(
    923                 Account.CONTENT_URI, EmailContent.ID_PROJECTION,
    924                 NOTIFIED_ACCOUNT_SELECTION, null, null);
    925             final HashSet<Long> newAccountList = new HashSet<Long>();
    926             final HashSet<Long> removedAccountList = new HashSet<Long>();
    927             if (c == null) {
    928                 // Suspender time ... theoretically, this will never happen
    929                 Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query");
    930                 return;
    931             }
    932             try {
    933                 while (c.moveToNext()) {
    934                     long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
    935                     newAccountList.add(accountId);
    936                 }
    937             } finally {
    938                 if (c != null) {
    939                     c.close();
    940                 }
    941             }
    942             // NOTE: Looping over three lists is not necessarily the most efficient. However, the
    943             // account lists are going to be very small, so, this will not be necessarily bad.
    944             // Cycle through existing notification list and adjust as necessary
    945             for (long accountId : sInstance.mNotificationMap.keySet()) {
    946                 if (!newAccountList.remove(accountId)) {
    947                     // account id not in the current set of notifiable accounts
    948                     removedAccountList.add(accountId);
    949                 }
    950             }
    951             // A new account was added to the notification list
    952             for (long accountId : newAccountList) {
    953                 sInstance.registerMessageNotification(accountId);
    954             }
    955             // An account was removed from the notification list
    956             for (long accountId : removedAccountList) {
    957                 sInstance.unregisterMessageNotification(accountId);
    958                 int notificationId = sInstance.getNewMessageNotificationId(accountId);
    959                 sInstance.mNotificationManager.cancel(notificationId);
    960             }
    961         }
    962     }
    963 
    964     /**
    965      * Thread to handle all notification actions through its own {@link Looper}.
    966      */
    967     private static class NotificationThread implements Runnable {
    968         /** Lock to ensure proper initialization */
    969         private final Object mLock = new Object();
    970         /** The {@link Looper} that handles messages for this thread */
    971         private Looper mLooper;
    972 
    973         NotificationThread() {
    974             new Thread(null, this, "EmailNotification").start();
    975             synchronized (mLock) {
    976                 while (mLooper == null) {
    977                     try {
    978                         mLock.wait();
    979                     } catch (InterruptedException ex) {
    980                     }
    981                 }
    982             }
    983         }
    984 
    985         @Override
    986         public void run() {
    987             synchronized (mLock) {
    988                 Looper.prepare();
    989                 mLooper = Looper.myLooper();
    990                 mLock.notifyAll();
    991             }
    992             Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
    993             Looper.loop();
    994         }
    995         void quit() {
    996             mLooper.quit();
    997         }
    998         Looper getLooper() {
    999             return mLooper;
   1000         }
   1001     }
   1002 }
   1003