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