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 
17 package com.android.email;
18 
19 import android.app.Notification;
20 import android.app.Notification.Builder;
21 import android.app.NotificationManager;
22 import android.app.PendingIntent;
23 import android.content.ContentResolver;
24 import android.content.ContentUris;
25 import android.content.Context;
26 import android.content.Intent;
27 import android.database.ContentObserver;
28 import android.database.Cursor;
29 import android.graphics.Bitmap;
30 import android.net.Uri;
31 import android.os.Handler;
32 import android.os.Looper;
33 import android.os.Process;
34 import android.provider.Settings;
35 import android.support.v4.app.NotificationCompat;
36 import android.text.TextUtils;
37 import android.text.format.DateUtils;
38 
39 import com.android.email.activity.setup.AccountSecurity;
40 import com.android.email.activity.setup.HeadlessAccountSettingsLoader;
41 import com.android.email.provider.EmailProvider;
42 import com.android.email.service.EmailServiceUtils;
43 import com.android.emailcommon.provider.Account;
44 import com.android.emailcommon.provider.EmailContent;
45 import com.android.emailcommon.provider.EmailContent.Attachment;
46 import com.android.emailcommon.provider.EmailContent.Message;
47 import com.android.emailcommon.provider.Mailbox;
48 import com.android.emailcommon.utility.EmailAsyncTask;
49 import com.android.mail.preferences.FolderPreferences;
50 import com.android.mail.providers.Folder;
51 import com.android.mail.providers.UIProvider;
52 import com.android.mail.utils.Clock;
53 import com.android.mail.utils.LogTag;
54 import com.android.mail.utils.LogUtils;
55 import com.android.mail.utils.NotificationUtils;
56 
57 import java.util.HashMap;
58 import java.util.HashSet;
59 import java.util.Map;
60 import java.util.Set;
61 
62 /**
63  * Class that manages notifications.
64  */
65 public 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 */
EmailNotificationController(Context context, Clock clock)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 */
getInstance(Context context)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      */
needsOngoingNotification(int notificationId)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      */
createBaseAccountNotificationBuilder(long accountId, String ticker, CharSequence title, String contentText, Intent intent, Integer number, boolean enableAudio, boolean ongoing)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      */
showNotification(long accountId, String ticker, String title, String contentText, Intent intent, int notificationId)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
watchForMessages()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 
ensureHandlerExists()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      */
registerMessageNotification(final long accountId)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      */
unregisterMessageNotification(final long accountId)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. */
setupSoundAndVibration( NotificationCompat.Builder builder, Account account)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
showDownloadForwardFailedNotificationSynchronous(Attachment attachment)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      */
getLoginFailedNotificationId(long accountId)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
showLoginFailedNotificationSynchronous(long accountId, boolean incoming)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
cancelLoginFailedNotification(long accountId)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
showPasswordExpiringNotificationSynchronous(long accountId)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
showPasswordExpiredNotificationSynchronous(long accountId)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
cancelPasswordExpirationNotifications()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
showSecurityNeededNotification(Account account)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
showSecurityChangedNotification(Account account)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
showSecurityUnsupportedNotification(Account account)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
cancelSecurityNeededNotification()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
cancelNotifications(final Context context, final Account account)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 
refreshNotificationsForAccount(final Context context, final long accountId)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 
refreshNotificationsForAccountInternal(final Context context, final long accountId)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
handleUpdateNotificationIntent(Context context, Intent intent)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 
refreshAllNotifications(final Context context)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 
refreshAllNotificationsInternal(final Context context)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 
MessageContentObserver( final Handler handler, final Context context, final long accountId)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
onChange(final boolean selfChange)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;
AccountContentObserver(final Handler handler, final Context context)771         public AccountContentObserver(final Handler handler, final Context context) {
772             super(handler);
773             mContext = context;
774         }
775 
776         @Override
onChange(final boolean selfChange)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 
NotificationThread()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
run()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 
getLooper()851         public Looper getLooper() {
852             return mLooper;
853         }
854     }
855 }
856