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