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