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