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