NotificationController.java revision 65432baf25d06b58a9b50d3df9952c4eedc9b8a5
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email;
18
19import android.app.Notification;
20import android.app.Notification.Builder;
21import android.app.NotificationManager;
22import android.app.PendingIntent;
23import android.content.ContentResolver;
24import android.content.ContentUris;
25import android.content.ContentValues;
26import android.content.Context;
27import android.content.Intent;
28import android.database.ContentObserver;
29import android.database.Cursor;
30import android.graphics.Bitmap;
31import android.graphics.BitmapFactory;
32import android.media.AudioManager;
33import android.net.Uri;
34import android.os.Handler;
35import android.os.Looper;
36import android.os.Process;
37import android.text.SpannableString;
38import android.text.TextUtils;
39import android.util.Log;
40
41import com.android.email.activity.ContactStatusLoader;
42import com.android.email.activity.Welcome;
43import com.android.email.activity.setup.AccountSecurity;
44import com.android.email.activity.setup.AccountSettings;
45import com.android.emailcommon.Logging;
46import com.android.emailcommon.mail.Address;
47import com.android.emailcommon.provider.Account;
48import com.android.emailcommon.provider.EmailContent;
49import com.android.emailcommon.provider.EmailContent.AccountColumns;
50import com.android.emailcommon.provider.EmailContent.Attachment;
51import com.android.emailcommon.provider.EmailContent.MailboxColumns;
52import com.android.emailcommon.provider.EmailContent.Message;
53import com.android.emailcommon.provider.EmailContent.MessageColumns;
54import com.android.emailcommon.provider.Mailbox;
55import com.android.emailcommon.utility.Utility;
56import com.google.common.annotations.VisibleForTesting;
57
58import java.util.HashMap;
59import java.util.HashSet;
60
61/**
62 * Class that manages notifications.
63 */
64public 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                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
448        long now = mClock.getTime();
449        boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS;
450        Notification notification = createAccountNotification(
451                account, title.toString(), title, text,
452                intent, largeIcon, number, enableAudio, false);
453        mLastMessageNotifyTime = now;
454        return notification;
455    }
456
457    /**
458     * Creates a notification title for a new message. If there is only a single message,
459     * show the sender name. Otherwise, show "X new messages".
460     */
461    @VisibleForTesting
462    SpannableString getNewMessageTitle(String sender, int unseenCount) {
463        String title;
464        if (unseenCount > 1) {
465            title = String.format(
466                    mContext.getString(R.string.notification_multiple_new_messages_fmt),
467                    unseenCount);
468        } else {
469            title = sender;
470        }
471        return new SpannableString(title);
472    }
473
474    /** Returns the system's current ringer mode */
475    @VisibleForTesting
476    int getRingerMode() {
477        return mAudioManager.getRingerMode();
478    }
479
480    /** Sets up the notification's sound and vibration based upon account details. */
481    @VisibleForTesting
482    void setupSoundAndVibration(Notification.Builder builder, Account account) {
483        final int flags = account.mFlags;
484        final String ringtoneUri = account.mRingtoneUri;
485        final boolean vibrate = (flags & Account.FLAGS_VIBRATE_ALWAYS) != 0;
486        final boolean vibrateWhenSilent = (flags & Account.FLAGS_VIBRATE_WHEN_SILENT) != 0;
487        final boolean isRingerSilent = getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
488
489        int defaults = Notification.DEFAULT_LIGHTS;
490        if (vibrate || (vibrateWhenSilent && isRingerSilent)) {
491            defaults |= Notification.DEFAULT_VIBRATE;
492        }
493
494        builder.setSound((ringtoneUri == null) ? null : Uri.parse(ringtoneUri))
495            .setDefaults(defaults);
496    }
497
498    /**
499     * Show (or update) a notification that the given attachment could not be forwarded. This
500     * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
501     * it's helpful for debugging.
502     *
503     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
504     */
505    public void showDownloadForwardFailedNotification(Attachment attachment) {
506        final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey);
507        if (account == null) return;
508        showAccountNotification(account,
509                mContext.getString(R.string.forward_download_failed_ticker),
510                mContext.getString(R.string.forward_download_failed_title),
511                attachment.mFileName,
512                null,
513                NOTIFICATION_ID_ATTACHMENT_WARNING);
514    }
515
516    /**
517     * Returns a notification ID for login failed notifications for the given account account.
518     */
519    private int getLoginFailedNotificationId(long accountId) {
520        return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
521    }
522
523    /**
524     * Show (or update) a notification that there was a login failure for the given account.
525     *
526     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
527     */
528    public void showLoginFailedNotification(long accountId) {
529        final Account account = Account.restoreAccountWithId(mContext, accountId);
530        if (account == null) return;
531        showAccountNotification(account,
532                mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
533                mContext.getString(R.string.login_failed_title),
534                account.getDisplayName(),
535                AccountSettings.createAccountSettingsIntent(mContext, accountId,
536                        account.mDisplayName),
537                getLoginFailedNotificationId(accountId));
538    }
539
540    /**
541     * Cancels the login failed notification for the given account.
542     */
543    public void cancelLoginFailedNotification(long accountId) {
544        mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
545    }
546
547    /**
548     * Show (or update) a notification that the user's password is expiring. The given account
549     * is used to update the display text, but, all accounts share the same notification ID.
550     *
551     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
552     */
553    public void showPasswordExpiringNotification(long accountId) {
554        Account account = Account.restoreAccountWithId(mContext, accountId);
555        if (account == null) return;
556
557        Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
558                accountId, false);
559        String accountName = account.getDisplayName();
560        String ticker =
561            mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
562        String title = mContext.getString(R.string.password_expire_warning_content_title);
563        showAccountNotification(account, ticker, title, accountName, intent,
564                NOTIFICATION_ID_PASSWORD_EXPIRING);
565    }
566
567    /**
568     * Show (or update) a notification that the user's password has expired. The given account
569     * is used to update the display text, but, all accounts share the same notification ID.
570     *
571     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
572     */
573    public void showPasswordExpiredNotification(long accountId) {
574        Account account = Account.restoreAccountWithId(mContext, accountId);
575        if (account == null) return;
576
577        Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
578                accountId, true);
579        String accountName = account.getDisplayName();
580        String ticker = mContext.getString(R.string.password_expired_ticker);
581        String title = mContext.getString(R.string.password_expired_content_title);
582        showAccountNotification(account, ticker, title, accountName, intent,
583                NOTIFICATION_ID_PASSWORD_EXPIRED);
584    }
585
586    /**
587     * Cancels any password expire notifications [both expired & expiring].
588     */
589    public void cancelPasswordExpirationNotifications() {
590        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
591        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
592    }
593
594    /**
595     * Show (or update) a security needed notification. The given account is used to update
596     * the display text, but, all accounts share the same notification ID.
597     */
598    public void showSecurityNeededNotification(Account account) {
599        Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
600        String accountName = account.getDisplayName();
601        String ticker =
602            mContext.getString(R.string.security_notification_ticker_fmt, accountName);
603        String title = mContext.getString(R.string.security_notification_content_title);
604        showAccountNotification(account, ticker, title, accountName, intent,
605                NOTIFICATION_ID_SECURITY_NEEDED);
606    }
607
608    /**
609     * Cancels the security needed notification.
610     */
611    public void cancelSecurityNeededNotification() {
612        mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED);
613    }
614
615    /**
616     * Observer invoked whenever a message we're notifying the user about changes.
617     */
618    private static class MessageContentObserver extends ContentObserver {
619        /** A selection to get messages the user hasn't seen before */
620        private final static String MESSAGE_SELECTION =
621                MessageColumns.MAILBOX_KEY + "=? AND "
622                + MessageColumns.ID + ">? AND "
623                + MessageColumns.FLAG_READ + "=0 AND "
624                + Message.FLAG_LOADED_SELECTION;
625        private final Context mContext;
626        private final long mMailboxId;
627        private final long mAccountId;
628
629        public MessageContentObserver(
630                Handler handler, Context context, long mailboxId, long accountId) {
631            super(handler);
632            mContext = context;
633            mMailboxId = mailboxId;
634            mAccountId = accountId;
635        }
636
637        @Override
638        public void onChange(boolean selfChange) {
639            if (mAccountId == sInstance.mSuspendAccountId
640                    || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
641                return;
642            }
643
644            ContentObserver observer = sInstance.mNotificationMap.get(mAccountId);
645            if (observer == null) {
646                // Notification for a mailbox that we aren't observing; account is probably
647                // being deleted.
648                Log.w(Logging.LOG_TAG, "Received notification when observer data was null");
649                return;
650            }
651            Account account = Account.restoreAccountWithId(mContext, mAccountId);
652            if (account == null) {
653                Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification");
654                return;
655            }
656            long oldMessageId = account.mNotifiedMessageId;
657            int oldMessageCount = account.mNotifiedMessageCount;
658
659            ContentResolver resolver = mContext.getContentResolver();
660            Long lastSeenMessageId = Utility.getFirstRowLong(
661                    mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
662                    new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
663                    null, null, null, 0);
664            if (lastSeenMessageId == null) {
665                // Mailbox got nuked. Could be that the account is in the process of being deleted
666                Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification");
667                return;
668            }
669
670            Cursor c = resolver.query(
671                    Message.CONTENT_URI, EmailContent.ID_PROJECTION,
672                    MESSAGE_SELECTION,
673                    new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) },
674                    MessageColumns.ID + " DESC");
675            if (c == null) {
676                // Couldn't find message info - things may be getting deleted in bulk.
677                Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query");
678                return;
679            }
680            try {
681                int newMessageCount = c.getCount();
682                long newMessageId = 0L;
683                if (c.moveToNext()) {
684                    newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
685                }
686
687                if (newMessageCount == 0) {
688                    // No messages to notify for; clear the notification
689                    int notificationId = sInstance.getNewMessageNotificationId(mAccountId);
690                    sInstance.mNotificationManager.cancel(notificationId);
691                } else if (newMessageCount != oldMessageCount
692                        || (newMessageId != 0 && newMessageId != oldMessageId)) {
693                    // Either the count or last message has changed; update the notification
694                    Integer unreadCount = Utility.getFirstRowInt(
695                            mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
696                            new String[] { MailboxColumns.UNREAD_COUNT },
697                            null, null, null, 0);
698                    if (unreadCount == null) {
699                        Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox");
700                        return;
701                    }
702
703                    Notification n = sInstance.createNewMessageNotification(
704                            mAccountId, mMailboxId, newMessageId,
705                            newMessageCount, unreadCount);
706                    if (n != null) {
707                        // Make the notification visible
708                        sInstance.mNotificationManager.notify(
709                                sInstance.getNewMessageNotificationId(mAccountId), n);
710                    }
711                }
712                // Save away the new values
713                ContentValues cv = new ContentValues();
714                cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId);
715                cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount);
716                resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv,
717                        null, null);
718            } finally {
719                c.close();
720            }
721        }
722    }
723
724    /**
725     * Observer invoked whenever an account is modified. This could mean the user changed the
726     * notification settings.
727     */
728    private static class AccountContentObserver extends ContentObserver {
729        private final Context mContext;
730        public AccountContentObserver(Handler handler, Context context) {
731            super(handler);
732            mContext = context;
733        }
734
735        @Override
736        public void onChange(boolean selfChange) {
737            final ContentResolver resolver = mContext.getContentResolver();
738            final Cursor c = resolver.query(
739                Account.CONTENT_URI, EmailContent.ID_PROJECTION,
740                NOTIFIED_ACCOUNT_SELECTION, null, null);
741            final HashSet<Long> newAccountList = new HashSet<Long>();
742            final HashSet<Long> removedAccountList = new HashSet<Long>();
743            if (c == null) {
744                // Suspender time ... theoretically, this will never happen
745                Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query");
746                return;
747            }
748            try {
749                while (c.moveToNext()) {
750                    long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
751                    newAccountList.add(accountId);
752                }
753            } finally {
754                if (c != null) {
755                    c.close();
756                }
757            }
758            // NOTE: Looping over three lists is not necessarily the most efficient. However, the
759            // account lists are going to be very small, so, this will not be necessarily bad.
760            // Cycle through existing notification list and adjust as necessary
761            for (long accountId : sInstance.mNotificationMap.keySet()) {
762                if (!newAccountList.remove(accountId)) {
763                    // account id not in the current set of notifiable accounts
764                    removedAccountList.add(accountId);
765                }
766            }
767            // A new account was added to the notification list
768            for (long accountId : newAccountList) {
769                sInstance.registerMessageNotification(accountId);
770            }
771            // An account was removed from the notification list
772            for (long accountId : removedAccountList) {
773                sInstance.unregisterMessageNotification(accountId);
774                int notificationId = sInstance.getNewMessageNotificationId(accountId);
775                sInstance.mNotificationManager.cancel(notificationId);
776            }
777        }
778    }
779
780    /**
781     * Thread to handle all notification actions through its own {@link Looper}.
782     */
783    private static class NotificationThread implements Runnable {
784        /** Lock to ensure proper initialization */
785        private final Object mLock = new Object();
786        /** The {@link Looper} that handles messages for this thread */
787        private Looper mLooper;
788
789        NotificationThread() {
790            new Thread(null, this, "EmailNotification").start();
791            synchronized (mLock) {
792                while (mLooper == null) {
793                    try {
794                        mLock.wait();
795                    } catch (InterruptedException ex) {
796                    }
797                }
798            }
799        }
800
801        @Override
802        public void run() {
803            synchronized (mLock) {
804                Looper.prepare();
805                mLooper = Looper.myLooper();
806                mLock.notifyAll();
807            }
808            Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
809            Looper.loop();
810        }
811        void quit() {
812            mLooper.quit();
813        }
814        Looper getLooper() {
815            return mLooper;
816        }
817    }
818}
819