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.Context;
26import android.content.Intent;
27import android.database.ContentObserver;
28import android.database.Cursor;
29import android.graphics.Bitmap;
30import android.net.Uri;
31import android.os.Handler;
32import android.os.Looper;
33import android.os.Process;
34import android.provider.Settings;
35import android.support.v4.app.NotificationCompat;
36import android.text.TextUtils;
37import android.text.format.DateUtils;
38
39import com.android.email.activity.setup.AccountSecurity;
40import com.android.email.activity.setup.HeadlessAccountSettingsLoader;
41import com.android.email.provider.EmailProvider;
42import com.android.email.service.EmailServiceUtils;
43import com.android.emailcommon.provider.Account;
44import com.android.emailcommon.provider.EmailContent;
45import com.android.emailcommon.provider.EmailContent.Attachment;
46import com.android.emailcommon.provider.EmailContent.Message;
47import com.android.emailcommon.provider.Mailbox;
48import com.android.emailcommon.utility.EmailAsyncTask;
49import com.android.mail.preferences.FolderPreferences;
50import com.android.mail.providers.Folder;
51import com.android.mail.providers.UIProvider;
52import com.android.mail.utils.Clock;
53import com.android.mail.utils.LogTag;
54import com.android.mail.utils.LogUtils;
55import com.android.mail.utils.NotificationUtils;
56
57import java.util.HashMap;
58import java.util.HashSet;
59import java.util.Map;
60import java.util.Set;
61
62/**
63 * Class that manages notifications.
64 */
65public class EmailNotificationController implements NotificationController {
66    private static final String LOG_TAG = LogTag.getLogTag();
67
68    private static final int NOTIFICATION_ID_ATTACHMENT_WARNING = 3;
69    private static final int NOTIFICATION_ID_PASSWORD_EXPIRING = 4;
70    private static final int NOTIFICATION_ID_PASSWORD_EXPIRED = 5;
71
72    private static final int NOTIFICATION_ID_BASE_MASK = 0xF0000000;
73    private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000;
74    private static final int NOTIFICATION_ID_BASE_SECURITY_NEEDED = 0x30000000;
75    private static final int NOTIFICATION_ID_BASE_SECURITY_CHANGED = 0x40000000;
76
77    private static NotificationThread sNotificationThread;
78    private static Handler sNotificationHandler;
79    private static EmailNotificationController sInstance;
80    private final Context mContext;
81    private final NotificationManager mNotificationManager;
82    private final Clock mClock;
83    /** Maps account id to its observer */
84    private final Map<Long, ContentObserver> mNotificationMap =
85            new HashMap<Long, ContentObserver>();
86    private ContentObserver mAccountObserver;
87
88    /** Constructor */
89    protected EmailNotificationController(Context context, Clock clock) {
90        mContext = context.getApplicationContext();
91        EmailContent.init(context);
92        mNotificationManager = (NotificationManager) context.getSystemService(
93                Context.NOTIFICATION_SERVICE);
94        mClock = clock;
95    }
96
97    /** Singleton access */
98    public static synchronized EmailNotificationController getInstance(Context context) {
99        if (sInstance == null) {
100            sInstance = new EmailNotificationController(context, Clock.INSTANCE);
101        }
102        return sInstance;
103    }
104
105    /**
106     * Return whether or not a notification, based on the passed-in id, needs to be "ongoing"
107     * @param notificationId the notification id to check
108     * @return whether or not the notification must be "ongoing"
109     */
110    private static boolean needsOngoingNotification(int notificationId) {
111        // "Security needed" must be ongoing so that the user doesn't close it; otherwise, sync will
112        // be prevented until a reboot.  Consider also doing this for password expired.
113        return (notificationId & NOTIFICATION_ID_BASE_MASK) == NOTIFICATION_ID_BASE_SECURITY_NEEDED;
114    }
115
116    /**
117     * Returns a {@link android.support.v4.app.NotificationCompat.Builder} for an event with the
118     * given account. The account contains specific rules on ring tone usage and these will be used
119     * to modify the notification behaviour.
120     *
121     * @param accountId The id of the account this notification is being built for.
122     * @param ticker Text displayed when the notification is first shown. May be {@code null}.
123     * @param title The first line of text. May NOT be {@code null}.
124     * @param contentText The second line of text. May NOT be {@code null}.
125     * @param intent The intent to start if the user clicks on the notification.
126     * @param number A number to display using {@link Builder#setNumber(int)}. May be {@code null}.
127     * @param enableAudio If {@code false}, do not play any sound. Otherwise, play sound according
128     *        to the settings for the given account.
129     * @return A {@link Notification} that can be sent to the notification service.
130     */
131    private NotificationCompat.Builder createBaseAccountNotificationBuilder(long accountId,
132            String ticker, CharSequence title, String contentText, Intent intent,
133            Integer number, boolean enableAudio, boolean ongoing) {
134        // Pending Intent
135        PendingIntent pending = null;
136        if (intent != null) {
137            pending = PendingIntent.getActivity(
138                    mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
139        }
140
141        // NOTE: the ticker is not shown for notifications in the Holo UX
142        final NotificationCompat.Builder builder = new NotificationCompat.Builder(mContext)
143                .setContentTitle(title)
144                .setContentText(contentText)
145                .setContentIntent(pending)
146                .setNumber(number == null ? 0 : number)
147                .setSmallIcon(R.drawable.ic_notification_mail_24dp)
148                .setWhen(mClock.getTime())
149                .setTicker(ticker)
150                .setOngoing(ongoing);
151
152        if (enableAudio) {
153            Account account = Account.restoreAccountWithId(mContext, accountId);
154            setupSoundAndVibration(builder, account);
155        }
156
157        return builder;
158    }
159
160    /**
161     * Generic notifier for any account.  Uses notification rules from account.
162     *
163     * @param accountId The account id this notification is being built for.
164     * @param ticker Text displayed when the notification is first shown. May be {@code null}.
165     * @param title The first line of text. May NOT be {@code null}.
166     * @param contentText The second line of text. May NOT be {@code null}.
167     * @param intent The intent to start if the user clicks on the notification.
168     * @param notificationId The ID of the notification to register with the service.
169     */
170    private void showNotification(long accountId, String ticker, String title,
171            String contentText, Intent intent, int notificationId) {
172        final NotificationCompat.Builder builder = createBaseAccountNotificationBuilder(accountId,
173                ticker, title, contentText, intent, null, true,
174                needsOngoingNotification(notificationId));
175        mNotificationManager.notify(notificationId, builder.build());
176    }
177
178    /**
179     * Tells the notification controller if it should be watching for changes to the message table.
180     * This is the main life cycle method for message notifications. When we stop observing
181     * database changes, we save the state [e.g. message ID and count] of the most recent
182     * notification shown to the user. And, when we start observing database changes, we restore
183     * the saved state.
184     */
185    @Override
186    public void watchForMessages() {
187        ensureHandlerExists();
188        // Run this on the message notification handler
189        sNotificationHandler.post(new Runnable() {
190            @Override
191            public void run() {
192                ContentResolver resolver = mContext.getContentResolver();
193
194                // otherwise, start new observers for all notified accounts
195                registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
196                // If we're already observing account changes, don't do anything else
197                if (mAccountObserver == null) {
198                    LogUtils.i(LOG_TAG, "Observing account changes for notifications");
199                    mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext);
200                    resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver);
201                }
202            }
203        });
204    }
205
206    /**
207     * Ensures the notification handler exists and is ready to handle requests.
208     */
209
210    /**
211     * TODO: Notifications jump around too much because we get too many content updates.
212     * We should try to make the provider generate fewer updates instead.
213     */
214
215    private static final int NOTIFICATION_DELAYED_MESSAGE = 0;
216    private static final long NOTIFICATION_DELAY = 15 * DateUtils.SECOND_IN_MILLIS;
217    // True if we're coalescing notification updates
218    private static boolean sNotificationDelayedMessagePending;
219    // True if accounts have changed and we need to refresh everything
220    private static boolean sRefreshAllNeeded;
221    // Set of accounts we need to regenerate notifications for
222    private static final HashSet<Long> sRefreshAccountSet = new HashSet<Long>();
223    // These should all be accessed on-thread, but just in case...
224    private static final Object sNotificationDelayedMessageLock = new Object();
225
226    private static synchronized void ensureHandlerExists() {
227        if (sNotificationThread == null) {
228            sNotificationThread = new NotificationThread();
229            sNotificationHandler = new Handler(sNotificationThread.getLooper(),
230                    new Handler.Callback() {
231                        @Override
232                        public boolean handleMessage(final android.os.Message message) {
233                            /**
234                             * To reduce spamming the notifications, we quiesce updates for a few
235                             * seconds to batch them up, then handle them here.
236                             */
237                            LogUtils.d(LOG_TAG, "Delayed notification processing");
238                            synchronized (sNotificationDelayedMessageLock) {
239                                sNotificationDelayedMessagePending = false;
240                                final Context context = (Context)message.obj;
241                                if (sRefreshAllNeeded) {
242                                    sRefreshAllNeeded = false;
243                                    refreshAllNotificationsInternal(context);
244                                }
245                                for (final Long accountId : sRefreshAccountSet) {
246                                    refreshNotificationsForAccountInternal(context, accountId);
247                                }
248                                sRefreshAccountSet.clear();
249                            }
250                            return true;
251                        }
252                    });
253        }
254    }
255
256    /**
257     * Registers an observer for changes to mailboxes in the given account.
258     * NOTE: This must be called on the notification handler thread.
259     * @param accountId The ID of the account to register the observer for. May be
260     *                  {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all
261     *                  accounts that allow for user notification.
262     */
263    private void registerMessageNotification(final long accountId) {
264        ContentResolver resolver = mContext.getContentResolver();
265        if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
266            Cursor c = resolver.query(
267                    Account.CONTENT_URI, EmailContent.ID_PROJECTION,
268                    null, null, null);
269            try {
270                while (c.moveToNext()) {
271                    long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
272                    registerMessageNotification(id);
273                }
274            } finally {
275                c.close();
276            }
277        } else {
278            ContentObserver obs = mNotificationMap.get(accountId);
279            if (obs != null) return;  // we're already observing; nothing to do
280            LogUtils.i(LOG_TAG, "Registering for notifications for account " + accountId);
281            ContentObserver observer = new MessageContentObserver(
282                    sNotificationHandler, mContext, accountId);
283            resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer);
284            mNotificationMap.put(accountId, observer);
285            // Now, ping the observer for any initial notifications
286            observer.onChange(true);
287        }
288    }
289
290    /**
291     * Unregisters the observer for the given account. If the specified account does not have
292     * a registered observer, no action is performed. This will not clear any existing notification
293     * for the specified account. Use {@link NotificationManager#cancel(int)}.
294     * NOTE: This must be called on the notification handler thread.
295     * @param accountId The ID of the account to unregister from. To unregister all accounts that
296     *                  have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}.
297     */
298    private void unregisterMessageNotification(final long accountId) {
299        ContentResolver resolver = mContext.getContentResolver();
300        if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
301            LogUtils.i(LOG_TAG, "Unregistering notifications for all accounts");
302            // cancel all existing message observers
303            for (ContentObserver observer : mNotificationMap.values()) {
304                resolver.unregisterContentObserver(observer);
305            }
306            mNotificationMap.clear();
307        } else {
308            LogUtils.i(LOG_TAG, "Unregistering notifications for account " + accountId);
309            ContentObserver observer = mNotificationMap.remove(accountId);
310            if (observer != null) {
311                resolver.unregisterContentObserver(observer);
312            }
313        }
314    }
315
316    public static final String EXTRA_ACCOUNT = "account";
317    public static final String EXTRA_CONVERSATION = "conversationUri";
318    public static final String EXTRA_FOLDER = "folder";
319
320    /** Sets up the notification's sound and vibration based upon account details. */
321    private void setupSoundAndVibration(
322            NotificationCompat.Builder builder, Account account) {
323        String ringtoneUri = Settings.System.DEFAULT_NOTIFICATION_URI.toString();
324        boolean vibrate = false;
325
326        // Use the Inbox notification preferences
327        final Cursor accountCursor = mContext.getContentResolver().query(EmailProvider.uiUri(
328                "uiaccount", account.mId), UIProvider.ACCOUNTS_PROJECTION, null, null, null);
329
330        com.android.mail.providers.Account uiAccount = null;
331        try {
332            if (accountCursor.moveToFirst()) {
333                uiAccount = com.android.mail.providers.Account.builder().buildFrom(accountCursor);
334            }
335        } finally {
336            accountCursor.close();
337        }
338
339        if (uiAccount != null) {
340            final Cursor folderCursor =
341                    mContext.getContentResolver().query(uiAccount.settings.defaultInbox,
342                            UIProvider.FOLDERS_PROJECTION, null, null, null);
343
344            if (folderCursor == null) {
345                // This can happen when the notification is for the security policy notification
346                // that happens before the account is setup
347                LogUtils.w(LOG_TAG, "Null folder cursor for mailbox %s",
348                        uiAccount.settings.defaultInbox);
349            } else {
350                Folder folder = null;
351                try {
352                    if (folderCursor.moveToFirst()) {
353                        folder = new Folder(folderCursor);
354                    }
355                } finally {
356                    folderCursor.close();
357                }
358
359                if (folder != null) {
360                    final FolderPreferences folderPreferences = new FolderPreferences(
361                            mContext, uiAccount.getEmailAddress(), folder, true /* inbox */);
362
363                    ringtoneUri = folderPreferences.getNotificationRingtoneUri();
364                    vibrate = folderPreferences.isNotificationVibrateEnabled();
365                } else {
366                    LogUtils.e(LOG_TAG,
367                            "Null folder for mailbox %s", uiAccount.settings.defaultInbox);
368                }
369            }
370        } else {
371            LogUtils.e(LOG_TAG, "Null uiAccount for account id %d", account.mId);
372        }
373
374        int defaults = Notification.DEFAULT_LIGHTS;
375        if (vibrate) {
376            defaults |= Notification.DEFAULT_VIBRATE;
377        }
378
379        builder.setSound(TextUtils.isEmpty(ringtoneUri) ? null : Uri.parse(ringtoneUri))
380            .setDefaults(defaults);
381    }
382
383    /**
384     * Show (or update) a notification that the given attachment could not be forwarded. This
385     * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
386     * it's helpful for debugging.
387     *
388     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
389     */
390    @Override
391    public void showDownloadForwardFailedNotificationSynchronous(Attachment attachment) {
392        final Message message = Message.restoreMessageWithId(mContext, attachment.mMessageKey);
393        if (message == null) return;
394        final Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, message.mMailboxKey);
395        showNotification(mailbox.mAccountKey,
396                mContext.getString(R.string.forward_download_failed_ticker),
397                mContext.getString(R.string.forward_download_failed_title),
398                attachment.mFileName,
399                null,
400                NOTIFICATION_ID_ATTACHMENT_WARNING);
401    }
402
403    /**
404     * Returns a notification ID for login failed notifications for the given account account.
405     */
406    private static int getLoginFailedNotificationId(long accountId) {
407        return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
408    }
409
410    /**
411     * Show (or update) a notification that there was a login failure for the given account.
412     *
413     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
414     */
415    @Override
416    public void showLoginFailedNotificationSynchronous(long accountId, boolean incoming) {
417        final Account account = Account.restoreAccountWithId(mContext, accountId);
418        if (account == null) return;
419        final Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, accountId,
420                Mailbox.TYPE_INBOX);
421        if (mailbox == null) return;
422
423        final Intent settingsIntent;
424        if (incoming) {
425            settingsIntent = new Intent(Intent.ACTION_VIEW,
426                    EmailProvider.getIncomingSettingsUri(accountId));
427        } else {
428            settingsIntent = new Intent(Intent.ACTION_VIEW,
429                    HeadlessAccountSettingsLoader.getOutgoingSettingsUri(accountId));
430        }
431        showNotification(mailbox.mAccountKey,
432                mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
433                mContext.getString(R.string.login_failed_title),
434                account.getDisplayName(),
435                settingsIntent,
436                getLoginFailedNotificationId(accountId));
437    }
438
439    /**
440     * Cancels the login failed notification for the given account.
441     */
442    @Override
443    public void cancelLoginFailedNotification(long accountId) {
444        mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
445    }
446
447    /**
448     * Show (or update) a notification that the user's password is expiring. The given account
449     * is used to update the display text, but, all accounts share the same notification ID.
450     *
451     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
452     */
453    @Override
454    public void showPasswordExpiringNotificationSynchronous(long accountId) {
455        final Account account = Account.restoreAccountWithId(mContext, accountId);
456        if (account == null) return;
457
458        final Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
459                accountId, false);
460        final String accountName = account.getDisplayName();
461        final String ticker =
462            mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
463        final String title = mContext.getString(R.string.password_expire_warning_content_title);
464        showNotification(accountId, ticker, title, accountName, intent,
465                NOTIFICATION_ID_PASSWORD_EXPIRING);
466    }
467
468    /**
469     * Show (or update) a notification that the user's password has expired. The given account
470     * is used to update the display text, but, all accounts share the same notification ID.
471     *
472     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
473     */
474    @Override
475    public void showPasswordExpiredNotificationSynchronous(long accountId) {
476        final Account account = Account.restoreAccountWithId(mContext, accountId);
477        if (account == null) return;
478
479        final Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
480                accountId, true);
481        final String accountName = account.getDisplayName();
482        final String ticker = mContext.getString(R.string.password_expired_ticker);
483        final String title = mContext.getString(R.string.password_expired_content_title);
484        showNotification(accountId, ticker, title, accountName, intent,
485                NOTIFICATION_ID_PASSWORD_EXPIRED);
486    }
487
488    /**
489     * Cancels any password expire notifications [both expired & expiring].
490     */
491    @Override
492    public void cancelPasswordExpirationNotifications() {
493        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
494        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
495    }
496
497    /**
498     * Show (or update) a security needed notification. If tapped, the user is taken to a
499     * dialog asking whether he wants to update his settings.
500     */
501    @Override
502    public void showSecurityNeededNotification(Account account) {
503        Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
504        String accountName = account.getDisplayName();
505        String ticker =
506            mContext.getString(R.string.security_needed_ticker_fmt, accountName);
507        String title = mContext.getString(R.string.security_notification_content_update_title);
508        showNotification(account.mId, ticker, title, accountName, intent,
509                (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId));
510    }
511
512    /**
513     * Show (or update) a security changed notification. If tapped, the user is taken to the
514     * account settings screen where he can view the list of enforced policies
515     */
516    @Override
517    public void showSecurityChangedNotification(Account account) {
518        final Intent intent = new Intent(Intent.ACTION_VIEW,
519                EmailProvider.getIncomingSettingsUri(account.getId()));
520        final String accountName = account.getDisplayName();
521        final String ticker =
522            mContext.getString(R.string.security_changed_ticker_fmt, accountName);
523        final String title =
524                mContext.getString(R.string.security_notification_content_change_title);
525        showNotification(account.mId, ticker, title, accountName, intent,
526                (int)(NOTIFICATION_ID_BASE_SECURITY_CHANGED + account.mId));
527    }
528
529    /**
530     * Show (or update) a security unsupported notification. If tapped, the user is taken to the
531     * account settings screen where he can view the list of unsupported policies
532     */
533    @Override
534    public void showSecurityUnsupportedNotification(Account account) {
535        final Intent intent = new Intent(Intent.ACTION_VIEW,
536                EmailProvider.getIncomingSettingsUri(account.getId()));
537        final String accountName = account.getDisplayName();
538        final String ticker =
539            mContext.getString(R.string.security_unsupported_ticker_fmt, accountName);
540        final String title =
541                mContext.getString(R.string.security_notification_content_unsupported_title);
542        showNotification(account.mId, ticker, title, accountName, intent,
543                (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId));
544   }
545
546    /**
547     * Cancels all security needed notifications.
548     */
549    @Override
550    public void cancelSecurityNeededNotification() {
551        EmailAsyncTask.runAsyncParallel(new Runnable() {
552            @Override
553            public void run() {
554                Cursor c = mContext.getContentResolver().query(Account.CONTENT_URI,
555                        Account.ID_PROJECTION, null, null, null);
556                try {
557                    while (c.moveToNext()) {
558                        long id = c.getLong(Account.ID_PROJECTION_COLUMN);
559                        mNotificationManager.cancel(
560                               (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + id));
561                    }
562                }
563                finally {
564                    c.close();
565                }
566            }});
567    }
568
569    /**
570     * Cancels all notifications for the specified account id. This includes new mail notifications,
571     * as well as special login/security notifications.
572     */
573    @Override
574    public void cancelNotifications(final Context context, final Account account) {
575        final EmailServiceUtils.EmailServiceInfo serviceInfo
576                = EmailServiceUtils.getServiceInfoForAccount(context, account.mId);
577        if (serviceInfo == null) {
578            LogUtils.d(LOG_TAG, "Can't cancel notification for missing account %d", account.mId);
579            return;
580        }
581        final android.accounts.Account notifAccount
582                = account.getAccountManagerAccount(serviceInfo.accountType);
583
584        NotificationUtils.clearAccountNotifications(context, notifAccount);
585
586        final NotificationManager notificationManager = getInstance(context).mNotificationManager;
587
588        notificationManager.cancel((int) (NOTIFICATION_ID_BASE_LOGIN_WARNING + account.mId));
589        notificationManager.cancel((int) (NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId));
590        notificationManager.cancel((int) (NOTIFICATION_ID_BASE_SECURITY_CHANGED + account.mId));
591    }
592
593    private static void refreshNotificationsForAccount(final Context context,
594            final long accountId) {
595        synchronized (sNotificationDelayedMessageLock) {
596            if (sNotificationDelayedMessagePending) {
597                sRefreshAccountSet.add(accountId);
598            } else {
599                ensureHandlerExists();
600                sNotificationHandler.sendMessageDelayed(
601                        android.os.Message.obtain(sNotificationHandler,
602                                NOTIFICATION_DELAYED_MESSAGE, context), NOTIFICATION_DELAY);
603                sNotificationDelayedMessagePending = true;
604                refreshNotificationsForAccountInternal(context, accountId);
605            }
606        }
607    }
608
609    private static void refreshNotificationsForAccountInternal(final Context context,
610            final long accountId) {
611        final Uri accountUri = EmailProvider.uiUri("uiaccount", accountId);
612
613        final ContentResolver contentResolver = context.getContentResolver();
614
615        final Cursor mailboxCursor = contentResolver.query(
616                ContentUris.withAppendedId(EmailContent.MAILBOX_NOTIFICATION_URI, accountId),
617                null, null, null, null);
618        try {
619            while (mailboxCursor.moveToNext()) {
620                final long mailboxId =
621                        mailboxCursor.getLong(EmailContent.NOTIFICATION_MAILBOX_ID_COLUMN);
622                if (mailboxId == 0) continue;
623
624                final int unseenCount = mailboxCursor.getInt(
625                        EmailContent.NOTIFICATION_MAILBOX_UNSEEN_COUNT_COLUMN);
626
627                final int unreadCount;
628                // If nothing is unseen, clear the notification
629                if (unseenCount == 0) {
630                    unreadCount = 0;
631                } else {
632                    unreadCount = mailboxCursor.getInt(
633                            EmailContent.NOTIFICATION_MAILBOX_UNREAD_COUNT_COLUMN);
634                }
635
636                final Uri folderUri = EmailProvider.uiUri("uifolder", mailboxId);
637
638
639                LogUtils.d(LOG_TAG, "Changes to account " + accountId + ", folder: "
640                        + mailboxId + ", unreadCount: " + unreadCount + ", unseenCount: "
641                        + unseenCount);
642
643                final Intent intent = new Intent(UIProvider.ACTION_UPDATE_NOTIFICATION);
644                intent.setPackage(context.getPackageName());
645                intent.setType(EmailProvider.EMAIL_APP_MIME_TYPE);
646
647                intent.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_ACCOUNT, accountUri);
648                intent.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_FOLDER, folderUri);
649                intent.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_UPDATED_UNREAD_COUNT,
650                        unreadCount);
651                intent.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_UPDATED_UNSEEN_COUNT,
652                        unseenCount);
653
654                context.sendOrderedBroadcast(intent, null);
655            }
656        } finally {
657            mailboxCursor.close();
658        }
659    }
660
661    @Override
662    public void handleUpdateNotificationIntent(Context context, Intent intent) {
663        final Uri accountUri =
664                intent.getParcelableExtra(UIProvider.UpdateNotificationExtras.EXTRA_ACCOUNT);
665        final Uri folderUri =
666                intent.getParcelableExtra(UIProvider.UpdateNotificationExtras.EXTRA_FOLDER);
667        final int unreadCount = intent.getIntExtra(
668                UIProvider.UpdateNotificationExtras.EXTRA_UPDATED_UNREAD_COUNT, 0);
669        final int unseenCount = intent.getIntExtra(
670                UIProvider.UpdateNotificationExtras.EXTRA_UPDATED_UNSEEN_COUNT, 0);
671
672        final ContentResolver contentResolver = context.getContentResolver();
673
674        final Cursor accountCursor = contentResolver.query(accountUri,
675                UIProvider.ACCOUNTS_PROJECTION,  null, null, null);
676
677        if (accountCursor == null) {
678            LogUtils.e(LOG_TAG, "Null account cursor for account " + accountUri);
679            return;
680        }
681
682        com.android.mail.providers.Account account = null;
683        try {
684            if (accountCursor.moveToFirst()) {
685                account = com.android.mail.providers.Account.builder().buildFrom(accountCursor);
686            }
687        } finally {
688            accountCursor.close();
689        }
690
691        if (account == null) {
692            LogUtils.d(LOG_TAG, "Tried to create a notification for a missing account "
693                    + accountUri);
694            return;
695        }
696
697        final Cursor folderCursor = contentResolver.query(folderUri, UIProvider.FOLDERS_PROJECTION,
698                null, null, null);
699
700        if (folderCursor == null) {
701            LogUtils.e(LOG_TAG, "Null folder cursor for account " + accountUri + ", mailbox "
702                    + folderUri);
703            return;
704        }
705
706        Folder folder = null;
707        try {
708            if (folderCursor.moveToFirst()) {
709                folder = new Folder(folderCursor);
710            } else {
711                LogUtils.e(LOG_TAG, "Empty folder cursor for account " + accountUri + ", mailbox "
712                        + folderUri);
713                return;
714            }
715        } finally {
716            folderCursor.close();
717        }
718
719        // TODO: we don't always want getAttention to be true, but we don't necessarily have a
720        // good heuristic for when it should or shouldn't be.
721        NotificationUtils.sendSetNewEmailIndicatorIntent(context, unreadCount, unseenCount,
722                account, folder, true /* getAttention */);
723    }
724
725    private static void refreshAllNotifications(final Context context) {
726        synchronized (sNotificationDelayedMessageLock) {
727            if (sNotificationDelayedMessagePending) {
728                sRefreshAllNeeded = true;
729            } else {
730                ensureHandlerExists();
731                sNotificationHandler.sendMessageDelayed(
732                        android.os.Message.obtain(sNotificationHandler,
733                                NOTIFICATION_DELAYED_MESSAGE, context), NOTIFICATION_DELAY);
734                sNotificationDelayedMessagePending = true;
735                refreshAllNotificationsInternal(context);
736            }
737        }
738    }
739
740    private static void refreshAllNotificationsInternal(final Context context) {
741        NotificationUtils.resendNotifications(
742                context, false, null, null, null /* ContactFetcher */);
743    }
744
745    /**
746     * Observer invoked whenever a message we're notifying the user about changes.
747     */
748    private static class MessageContentObserver extends ContentObserver {
749        private final Context mContext;
750        private final long mAccountId;
751
752        public MessageContentObserver(
753                final Handler handler, final Context context, final long accountId) {
754            super(handler);
755            mContext = context;
756            mAccountId = accountId;
757        }
758
759        @Override
760        public void onChange(final boolean selfChange) {
761            refreshNotificationsForAccount(mContext, mAccountId);
762        }
763    }
764
765    /**
766     * Observer invoked whenever an account is modified. This could mean the user changed the
767     * notification settings.
768     */
769    private static class AccountContentObserver extends ContentObserver {
770        private final Context mContext;
771        public AccountContentObserver(final Handler handler, final Context context) {
772            super(handler);
773            mContext = context;
774        }
775
776        @Override
777        public void onChange(final boolean selfChange) {
778            final ContentResolver resolver = mContext.getContentResolver();
779            final Cursor c = resolver.query(Account.CONTENT_URI, EmailContent.ID_PROJECTION,
780                null, null, null);
781            final Set<Long> newAccountList = new HashSet<Long>();
782            final Set<Long> removedAccountList = new HashSet<Long>();
783            if (c == null) {
784                // Suspender time ... theoretically, this will never happen
785                LogUtils.wtf(LOG_TAG, "#onChange(); NULL response for account id query");
786                return;
787            }
788            try {
789                while (c.moveToNext()) {
790                    long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
791                    newAccountList.add(accountId);
792                }
793            } finally {
794                c.close();
795            }
796            // NOTE: Looping over three lists is not necessarily the most efficient. However, the
797            // account lists are going to be very small, so, this will not be necessarily bad.
798            // Cycle through existing notification list and adjust as necessary
799            for (final long accountId : sInstance.mNotificationMap.keySet()) {
800                if (!newAccountList.remove(accountId)) {
801                    // account id not in the current set of notifiable accounts
802                    removedAccountList.add(accountId);
803                }
804            }
805            // A new account was added to the notification list
806            for (final long accountId : newAccountList) {
807                sInstance.registerMessageNotification(accountId);
808            }
809            // An account was removed from the notification list
810            for (final long accountId : removedAccountList) {
811                sInstance.unregisterMessageNotification(accountId);
812            }
813
814            refreshAllNotifications(mContext);
815        }
816    }
817
818    /**
819     * Thread to handle all notification actions through its own {@link Looper}.
820     */
821    private static class NotificationThread implements Runnable {
822        /** Lock to ensure proper initialization */
823        private final Object mLock = new Object();
824        /** The {@link Looper} that handles messages for this thread */
825        private Looper mLooper;
826
827        public NotificationThread() {
828            new Thread(null, this, "EmailNotification").start();
829            synchronized (mLock) {
830                while (mLooper == null) {
831                    try {
832                        mLock.wait();
833                    } catch (InterruptedException ex) {
834                        // Loop around and wait again
835                    }
836                }
837            }
838        }
839
840        @Override
841        public void run() {
842            synchronized (mLock) {
843                Looper.prepare();
844                mLooper = Looper.myLooper();
845                mLock.notifyAll();
846            }
847            Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
848            Looper.loop();
849        }
850
851        public Looper getLooper() {
852            return mLooper;
853        }
854    }
855}
856