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