• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.ContentValues;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.content.res.Resources;
29 import android.database.ContentObserver;
30 import android.database.Cursor;
31 import android.graphics.Bitmap;
32 import android.graphics.BitmapFactory;
33 import android.media.AudioManager;
34 import android.net.Uri;
35 import android.os.Build;
36 import android.os.Handler;
37 import android.os.Looper;
38 import android.os.Process;
39 import android.text.SpannableString;
40 import android.text.TextUtils;
41 import android.text.style.TextAppearanceSpan;
42 import android.util.Log;
43 
44 import com.android.email.activity.ContactStatusLoader;
45 import com.android.email.activity.Welcome;
46 import com.android.email.activity.setup.AccountSecurity;
47 import com.android.email.activity.setup.AccountSettings;
48 import com.android.emailcommon.Logging;
49 import com.android.emailcommon.mail.Address;
50 import com.android.emailcommon.provider.Account;
51 import com.android.emailcommon.provider.EmailContent;
52 import com.android.emailcommon.provider.EmailContent.AccountColumns;
53 import com.android.emailcommon.provider.EmailContent.Attachment;
54 import com.android.emailcommon.provider.EmailContent.MailboxColumns;
55 import com.android.emailcommon.provider.EmailContent.Message;
56 import com.android.emailcommon.provider.EmailContent.MessageColumns;
57 import com.android.emailcommon.provider.Mailbox;
58 import com.android.emailcommon.utility.Utility;
59 import com.google.common.annotations.VisibleForTesting;
60 
61 import java.util.HashMap;
62 import java.util.HashSet;
63 
64 /**
65  * Class that manages notifications.
66  */
67 public class NotificationController {
68     private static final int NOTIFICATION_ID_SECURITY_NEEDED = 1;
69     /** Reserved for {@link com.android.exchange.CalendarSyncEnabler} */
70     @SuppressWarnings("unused")
71     private static final int NOTIFICATION_ID_EXCHANGE_CALENDAR_ADDED = 2;
72     private static final int NOTIFICATION_ID_ATTACHMENT_WARNING = 3;
73     private static final int NOTIFICATION_ID_PASSWORD_EXPIRING = 4;
74     private static final int NOTIFICATION_ID_PASSWORD_EXPIRED = 5;
75 
76     private static final int NOTIFICATION_ID_BASE_NEW_MESSAGES = 0x10000000;
77     private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000;
78 
79     /** Selection to retrieve accounts that should we notify user for changes */
80     private final static String NOTIFIED_ACCOUNT_SELECTION =
81         Account.FLAGS + "&" + Account.FLAGS_NOTIFY_NEW_MAIL + " != 0";
82 
83     private static NotificationThread sNotificationThread;
84     private static Handler sNotificationHandler;
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 Bitmap mGenericMultipleSenderIcon;
91     private final Clock mClock;
92     // TODO We're maintaining all of our structures based upon the account ID. This is fine
93     // for now since the assumption is that we only ever look for changes in an account's
94     // INBOX. We should adjust our logic to use the mailbox ID instead.
95     /** Maps account id to the message data */
96     private final HashMap<Long, ContentObserver> mNotificationMap;
97     private ContentObserver mAccountObserver;
98     /**
99      * Suspend notifications for this account. If {@link Account#NO_ACCOUNT}, no
100      * account notifications are suspended. If {@link Account#ACCOUNT_ID_COMBINED_VIEW},
101      * notifications for all accounts are suspended.
102      */
103     private long mSuspendAccountId = Account.NO_ACCOUNT;
104 
105     /**
106      * Timestamp indicating when the last message notification sound was played.
107      * Used for throttling.
108      */
109     private long mLastMessageNotifyTime;
110 
111     /**
112      * Minimum interval between notification sounds.
113      * Since a long sync (either on account setup or after a long period of being offline) can cause
114      * several notifications consecutively, it can be pretty overwhelming to get a barrage of
115      * notification sounds. Throttle them using this value.
116      */
117     private static final long MIN_SOUND_INTERVAL_MS = 15 * 1000; // 15 seconds
118 
isRunningJellybeanOrLater()119     private static boolean isRunningJellybeanOrLater() {
120         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
121     }
122 
123     /** Constructor */
124     @VisibleForTesting
NotificationController(Context context, Clock clock)125     NotificationController(Context context, Clock clock) {
126         mContext = context.getApplicationContext();
127         mNotificationManager = (NotificationManager) context.getSystemService(
128                 Context.NOTIFICATION_SERVICE);
129         mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
130         mGenericSenderIcon = BitmapFactory.decodeResource(mContext.getResources(),
131                 R.drawable.ic_contact_picture);
132         mGenericMultipleSenderIcon = BitmapFactory.decodeResource(mContext.getResources(),
133                 R.drawable.ic_notification_multiple_mail_holo_dark);
134         mClock = clock;
135         mNotificationMap = new HashMap<Long, ContentObserver>();
136     }
137 
138     /** Singleton access */
getInstance(Context context)139     public static synchronized NotificationController getInstance(Context context) {
140         if (sInstance == null) {
141             sInstance = new NotificationController(context, Clock.INSTANCE);
142         }
143         return sInstance;
144     }
145 
146     /**
147      * Return whether or not a notification, based on the passed-in id, needs to be "ongoing"
148      * @param notificationId the notification id to check
149      * @return whether or not the notification must be "ongoing"
150      */
needsOngoingNotification(int notificationId)151     private boolean needsOngoingNotification(int notificationId) {
152         // "Security needed" must be ongoing so that the user doesn't close it; otherwise, sync will
153         // be prevented until a reboot.  Consider also doing this for password expired.
154         return notificationId == NOTIFICATION_ID_SECURITY_NEEDED;
155     }
156 
157     /**
158      * Returns a {@link Notification.Builder} for an event with the given account. The account
159      * contains specific rules on ring tone usage and these will be used to modify the notification
160      * behaviour.
161      *
162      * @param account The account this notification is being built for.
163      * @param ticker Text displayed when the notification is first shown. May be {@code null}.
164      * @param title The first line of text. May NOT be {@code null}.
165      * @param contentText The second line of text. May NOT be {@code null}.
166      * @param intent The intent to start if the user clicks on the notification.
167      * @param largeIcon A large icon. May be {@code null}
168      * @param number A number to display using {@link Builder#setNumber(int)}. May
169      *        be {@code null}.
170      * @param enableAudio If {@code false}, do not play any sound. Otherwise, play sound according
171      *        to the settings for the given account.
172      * @return A {@link Notification} that can be sent to the notification service.
173      */
createBaseAccountNotificationBuilder(Account account, String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon, Integer number, boolean enableAudio, boolean ongoing)174     private Notification.Builder createBaseAccountNotificationBuilder(Account account,
175             String ticker, CharSequence title, String contentText, Intent intent, Bitmap largeIcon,
176             Integer number, boolean enableAudio, boolean ongoing) {
177         // Pending Intent
178         PendingIntent pending = null;
179         if (intent != null) {
180             pending = PendingIntent.getActivity(
181                     mContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
182         }
183 
184         // NOTE: the ticker is not shown for notifications in the Holo UX
185         final Notification.Builder builder = new Notification.Builder(mContext)
186                 .setContentTitle(title)
187                 .setContentText(contentText)
188                 .setContentIntent(pending)
189                 .setLargeIcon(largeIcon)
190                 .setNumber(number == null ? 0 : number)
191                 .setSmallIcon(R.drawable.stat_notify_email_generic)
192                 .setWhen(mClock.getTime())
193                 .setTicker(ticker)
194                 .setOngoing(ongoing);
195 
196         if (enableAudio) {
197             setupSoundAndVibration(builder, account);
198         }
199 
200         return builder;
201     }
202 
203     /**
204      * Generic notifier for any account.  Uses notification rules from account.
205      *
206      * @param account The account this notification is being built for.
207      * @param ticker Text displayed when the notification is first shown. May be {@code null}.
208      * @param title The first line of text. May NOT be {@code null}.
209      * @param contentText The second line of text. May NOT be {@code null}.
210      * @param intent The intent to start if the user clicks on the notification.
211      * @param notificationId The ID of the notification to register with the service.
212      */
showAccountNotification(Account account, String ticker, String title, String contentText, Intent intent, int notificationId)213     private void showAccountNotification(Account account, String ticker, String title,
214             String contentText, Intent intent, int notificationId) {
215         Notification.Builder builder = createBaseAccountNotificationBuilder(account, ticker, title,
216                 contentText, intent, null, null, true, needsOngoingNotification(notificationId));
217         mNotificationManager.notify(notificationId, builder.getNotification());
218     }
219 
220     /**
221      * Returns a notification ID for new message notifications for the given account.
222      */
getNewMessageNotificationId(long accountId)223     private int getNewMessageNotificationId(long accountId) {
224         // We assume accountId will always be less than 0x0FFFFFFF; is there a better way?
225         return (int) (NOTIFICATION_ID_BASE_NEW_MESSAGES + accountId);
226     }
227 
228     /**
229      * Tells the notification controller if it should be watching for changes to the message table.
230      * This is the main life cycle method for message notifications. When we stop observing
231      * database changes, we save the state [e.g. message ID and count] of the most recent
232      * notification shown to the user. And, when we start observing database changes, we restore
233      * the saved state.
234      * @param watch If {@code true}, we register observers for all accounts whose settings have
235      *              notifications enabled. Otherwise, all observers are unregistered.
236      */
watchForMessages(final boolean watch)237     public void watchForMessages(final boolean watch) {
238         if (Email.DEBUG) {
239             Log.i(Logging.LOG_TAG, "Notifications being toggled: " + watch);
240         }
241         // Don't create the thread if we're only going to stop watching
242         if (!watch && sNotificationThread == null) return;
243 
244         ensureHandlerExists();
245         // Run this on the message notification handler
246         sNotificationHandler.post(new Runnable() {
247             @Override
248             public void run() {
249                 ContentResolver resolver = mContext.getContentResolver();
250                 if (!watch) {
251                     unregisterMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
252                     if (mAccountObserver != null) {
253                         resolver.unregisterContentObserver(mAccountObserver);
254                         mAccountObserver = null;
255                     }
256 
257                     // tear down the event loop
258                     sNotificationThread.quit();
259                     sNotificationThread = null;
260                     return;
261                 }
262 
263                 // otherwise, start new observers for all notified accounts
264                 registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
265                 // If we're already observing account changes, don't do anything else
266                 if (mAccountObserver == null) {
267                     if (Email.DEBUG) {
268                         Log.i(Logging.LOG_TAG, "Observing account changes for notifications");
269                     }
270                     mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext);
271                     resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver);
272                 }
273             }
274         });
275     }
276 
277     /**
278      * Temporarily suspend a single account from receiving notifications. NOTE: only a single
279      * account may ever be suspended at a time. So, if this method is invoked a second time,
280      * notifications for the previously suspended account will automatically be re-activated.
281      * @param suspend If {@code true}, suspend notifications for the given account. Otherwise,
282      *              re-activate notifications for the previously suspended account.
283      * @param accountId The ID of the account. If this is the special account ID
284      *              {@link Account#ACCOUNT_ID_COMBINED_VIEW},  notifications for all accounts are
285      *              suspended. If {@code suspend} is {@code false}, the account ID is ignored.
286      */
suspendMessageNotification(boolean suspend, long accountId)287     public void suspendMessageNotification(boolean suspend, long accountId) {
288         if (mSuspendAccountId != Account.NO_ACCOUNT) {
289             // we're already suspending an account; un-suspend it
290             mSuspendAccountId = Account.NO_ACCOUNT;
291         }
292         if (suspend && accountId != Account.NO_ACCOUNT && accountId > 0L) {
293             mSuspendAccountId = accountId;
294             if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
295                 // Only go onto the notification handler if we really, absolutely need to
296                 ensureHandlerExists();
297                 sNotificationHandler.post(new Runnable() {
298                     @Override
299                     public void run() {
300                         for (long accountId : mNotificationMap.keySet()) {
301                             mNotificationManager.cancel(getNewMessageNotificationId(accountId));
302                         }
303                     }
304                 });
305             } else {
306                 mNotificationManager.cancel(getNewMessageNotificationId(accountId));
307             }
308         }
309     }
310 
311     /**
312      * Ensures the notification handler exists and is ready to handle requests.
313      */
ensureHandlerExists()314     private static synchronized void ensureHandlerExists() {
315         if (sNotificationThread == null) {
316             sNotificationThread = new NotificationThread();
317             sNotificationHandler = new Handler(sNotificationThread.getLooper());
318         }
319     }
320 
321     /**
322      * Registers an observer for changes to the INBOX for the given account. Since accounts
323      * may only have a single INBOX, we will never have more than one observer for an account.
324      * NOTE: This must be called on the notification handler thread.
325      * @param accountId The ID of the account to register the observer for. May be
326      *                  {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all
327      *                  accounts that allow for user notification.
328      */
registerMessageNotification(long accountId)329     private void registerMessageNotification(long accountId) {
330         ContentResolver resolver = mContext.getContentResolver();
331         if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
332             Cursor c = resolver.query(
333                     Account.CONTENT_URI, EmailContent.ID_PROJECTION,
334                     NOTIFIED_ACCOUNT_SELECTION, null, null);
335             try {
336                 while (c.moveToNext()) {
337                     long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
338                     registerMessageNotification(id);
339                 }
340             } finally {
341                 c.close();
342             }
343         } else {
344             ContentObserver obs = mNotificationMap.get(accountId);
345             if (obs != null) return;  // we're already observing; nothing to do
346 
347             Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, accountId, Mailbox.TYPE_INBOX);
348             if (mailbox == null) {
349                 Log.w(Logging.LOG_TAG, "Could not load INBOX for account id: " + accountId);
350                 return;
351             }
352             if (Email.DEBUG) {
353                 Log.i(Logging.LOG_TAG, "Registering for notifications for account " + accountId);
354             }
355             ContentObserver observer = new MessageContentObserver(
356                     sNotificationHandler, mContext, mailbox.mId, accountId);
357             resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer);
358             mNotificationMap.put(accountId, observer);
359             // Now, ping the observer for any initial notifications
360             observer.onChange(true);
361         }
362     }
363 
364     /**
365      * Unregisters the observer for the given account. If the specified account does not have
366      * a registered observer, no action is performed. This will not clear any existing notification
367      * for the specified account. Use {@link NotificationManager#cancel(int)}.
368      * NOTE: This must be called on the notification handler thread.
369      * @param accountId The ID of the account to unregister from. To unregister all accounts that
370      *                  have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}.
371      */
unregisterMessageNotification(long accountId)372     private void unregisterMessageNotification(long accountId) {
373         ContentResolver resolver = mContext.getContentResolver();
374         if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
375             if (Email.DEBUG) {
376                 Log.i(Logging.LOG_TAG, "Unregistering notifications for all accounts");
377             }
378             // cancel all existing message observers
379             for (ContentObserver observer : mNotificationMap.values()) {
380                 resolver.unregisterContentObserver(observer);
381             }
382             mNotificationMap.clear();
383         } else {
384             if (Email.DEBUG) {
385                 Log.i(Logging.LOG_TAG, "Unregistering notifications for account " + accountId);
386             }
387             ContentObserver observer = mNotificationMap.remove(accountId);
388             if (observer != null) {
389                 resolver.unregisterContentObserver(observer);
390             }
391         }
392     }
393 
394     /**
395      * Returns a picture of the sender of the given message. If no picture is available, returns
396      * {@code null}.
397      *
398      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
399      */
getSenderPhoto(Message message)400     private Bitmap getSenderPhoto(Message message) {
401         Address sender = Address.unpackFirst(message.mFrom);
402         if (sender == null) {
403             return null;
404         }
405         String email = sender.getAddress();
406         if (TextUtils.isEmpty(email)) {
407             return null;
408         }
409         Bitmap photo = ContactStatusLoader.getContactInfo(mContext, email).mPhoto;
410 
411         if (photo != null) {
412             final Resources res = mContext.getResources();
413             final int idealIconHeight =
414                     res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
415             final int idealIconWidth =
416                     res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
417 
418             if (photo.getHeight() < idealIconHeight) {
419                 // We should scale this image to fit the intended size
420                 photo = Bitmap.createScaledBitmap(
421                         photo, idealIconWidth, idealIconHeight, true);
422             }
423         }
424         return photo;
425     }
426 
427     /**
428      * Returns a "new message" notification for the given account.
429      *
430      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
431      */
432     @VisibleForTesting
createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor, long newestMessageId, int unseenMessageCount, int unreadCount)433     Notification createNewMessageNotification(long accountId, long mailboxId, Cursor messageCursor,
434             long newestMessageId, int unseenMessageCount, int unreadCount) {
435         final Account account = Account.restoreAccountWithId(mContext, accountId);
436         if (account == null) {
437             return null;
438         }
439         // Get the latest message
440         final Message message = Message.restoreMessageWithId(mContext, newestMessageId);
441         if (message == null) {
442             return null; // no message found???
443         }
444 
445         String senderName = Address.toFriendly(Address.unpack(message.mFrom));
446         if (senderName == null) {
447             senderName = ""; // Happens when a message has no from.
448         }
449         final boolean multipleUnseen = unseenMessageCount > 1;
450         final Bitmap senderPhoto = multipleUnseen
451                 ? mGenericMultipleSenderIcon
452                 : getSenderPhoto(message);
453         final SpannableString title = getNewMessageTitle(senderName, unseenMessageCount);
454         // TODO: add in display name on the second line for the text, once framework supports
455         // multiline texts.
456         final String text = multipleUnseen
457                 ? account.mDisplayName
458                 : message.mSubject;
459         final Bitmap largeIcon = senderPhoto != null ? senderPhoto : mGenericSenderIcon;
460         final Integer number = unreadCount > 1 ? unreadCount : null;
461         final Intent intent;
462         if (unseenMessageCount > 1) {
463             intent = Welcome.createOpenAccountInboxIntent(mContext, accountId);
464         } else {
465             intent = Welcome.createOpenMessageIntent(
466                     mContext, accountId, mailboxId, newestMessageId);
467         }
468         intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
469                 Intent.FLAG_ACTIVITY_TASK_ON_HOME);
470         long now = mClock.getTime();
471         boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS;
472         final Notification.Builder builder = createBaseAccountNotificationBuilder(
473                 account, title.toString(), title, text,
474                 intent, largeIcon, number, enableAudio, false);
475         if (isRunningJellybeanOrLater()) {
476             // For a new-style notification
477             if (multipleUnseen) {
478                 if (messageCursor != null) {
479                     final int maxNumDigestItems = mContext.getResources().getInteger(
480                             R.integer.max_num_notification_digest_items);
481                     // The body of the notification is the account name, or the label name.
482                     builder.setSubText(text);
483 
484                     Notification.InboxStyle digest = new Notification.InboxStyle(builder);
485 
486                     digest.setBigContentTitle(title);
487 
488                     int numDigestItems = 0;
489                     // We can assume that the current position of the cursor is on the
490                     // newest message
491                     do {
492                         final long messageId =
493                                 messageCursor.getLong(EmailContent.ID_PROJECTION_COLUMN);
494 
495                         // Get the latest message
496                         final Message digestMessage =
497                                 Message.restoreMessageWithId(mContext, messageId);
498                         if (digestMessage != null) {
499                             final CharSequence digestLine =
500                                     getSingleMessageInboxLine(mContext, digestMessage);
501                             digest.addLine(digestLine);
502                             numDigestItems++;
503                         }
504                     } while (numDigestItems <= maxNumDigestItems && messageCursor.moveToNext());
505 
506                     // We want to clear the content text in this case. The content text would have
507                     // been set in createBaseAccountNotificationBuilder, but since the same string
508                     // was set in as the subtext, we don't want to show a duplicate string.
509                     builder.setContentText(null);
510                 }
511             } else {
512                 // The notification content will be the subject of the conversation.
513                 builder.setContentText(getSingleMessageLittleText(mContext, message.mSubject));
514 
515                 // The notification subtext will be the subject of the conversation for inbox
516                 // notifications, or will based on the the label name for user label notifications.
517                 builder.setSubText(account.mDisplayName);
518 
519                 final Notification.BigTextStyle bigText = new Notification.BigTextStyle(builder);
520                 bigText.bigText(getSingleMessageBigText(mContext, message));
521             }
522         }
523 
524         mLastMessageNotifyTime = now;
525         return builder.getNotification();
526     }
527 
528     /**
529      * Sets the bigtext for a notification for a single new conversation
530      * @param context
531      * @param message New message that triggered the notification.
532      * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle}
533      */
getSingleMessageInboxLine(Context context, Message message)534     private static CharSequence getSingleMessageInboxLine(Context context, Message message) {
535         final String subject = message.mSubject;
536         final String snippet = message.mSnippet;
537         final String senders = Address.toFriendly(Address.unpack(message.mFrom));
538 
539         final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
540 
541         final TextAppearanceSpan notificationPrimarySpan =
542                 new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
543 
544         if (TextUtils.isEmpty(senders)) {
545             // If the senders are empty, just use the subject/snippet.
546             return subjectSnippet;
547         }
548         else if (TextUtils.isEmpty(subjectSnippet)) {
549             // If the subject/snippet is empty, just use the senders.
550             final SpannableString spannableString = new SpannableString(senders);
551             spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
552 
553             return spannableString;
554         } else {
555             final String formatString = context.getResources().getString(
556                     R.string.multiple_new_message_notification_item);
557             final TextAppearanceSpan notificationSecondarySpan =
558                     new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
559 
560             final String instantiatedString = String.format(formatString, senders, subjectSnippet);
561 
562             final SpannableString spannableString = new SpannableString(instantiatedString);
563 
564             final boolean isOrderReversed = formatString.indexOf("%2$s") <
565                     formatString.indexOf("%1$s");
566             final int primaryOffset =
567                     (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
568                      instantiatedString.indexOf(senders));
569             final int secondaryOffset =
570                     (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
571                      instantiatedString.indexOf(subjectSnippet));
572             spannableString.setSpan(notificationPrimarySpan,
573                     primaryOffset, primaryOffset + senders.length(), 0);
574             spannableString.setSpan(notificationSecondarySpan,
575                     secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
576             return spannableString;
577         }
578     }
579 
580     /**
581      * Sets the bigtext for a notification for a single new conversation
582      * @param context
583      * @param subject Subject of the new message that triggered the notification
584      * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText}
585      */
586     private static CharSequence getSingleMessageLittleText(Context context, String subject) {
587         if (subject == null) {
588             return null;
589         }
590         final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
591                 context, R.style.NotificationPrimaryText);
592 
593         final SpannableString spannableString = new SpannableString(subject);
594         spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
595 
596         return spannableString;
597     }
598 
599 
600     /**
601      * Sets the bigtext for a notification for a single new conversation
602      * @param context
603      * @param message New message that triggered the notification
604      * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle}
605      */
606     private static CharSequence getSingleMessageBigText(Context context, Message message) {
607         final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
608                 context, R.style.NotificationPrimaryText);
609 
610         final String subject = message.mSubject;
611         final String snippet = message.mSnippet;
612 
613         if (TextUtils.isEmpty(subject)) {
614             // If the subject is empty, just use the snippet.
615             return snippet;
616         }
617         else if (TextUtils.isEmpty(snippet)) {
618             // If the snippet is empty, just use the subject.
619             final SpannableString spannableString = new SpannableString(subject);
620             spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
621 
622             return spannableString;
623         } else {
624             final String notificationBigTextFormat = context.getResources().getString(
625                     R.string.single_new_message_notification_big_text);
626 
627             // Localizers may change the order of the parameters, look at how the format
628             // string is structured.
629             final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
630                     notificationBigTextFormat.indexOf("%1$s");
631             final String bigText = String.format(notificationBigTextFormat, subject, snippet);
632             final SpannableString spannableString = new SpannableString(bigText);
633 
634             final int subjectOffset =
635                     (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
636             spannableString.setSpan(notificationSubjectSpan,
637                     subjectOffset, subjectOffset + subject.length(), 0);
638 
639             return spannableString;
640         }
641     }
642 
643     /**
644      * Creates a notification title for a new message. If there is only a single message,
645      * show the sender name. Otherwise, show "X new messages".
646      */
647     @VisibleForTesting
getNewMessageTitle(String sender, int unseenCount)648     SpannableString getNewMessageTitle(String sender, int unseenCount) {
649         String title;
650         if (unseenCount > 1) {
651             title = String.format(
652                     mContext.getString(R.string.notification_multiple_new_messages_fmt),
653                     unseenCount);
654         } else {
655             title = sender;
656         }
657         return new SpannableString(title);
658     }
659 
660     /** Returns the system's current ringer mode */
661     @VisibleForTesting
getRingerMode()662     int getRingerMode() {
663         return mAudioManager.getRingerMode();
664     }
665 
666     /** Sets up the notification's sound and vibration based upon account details. */
667     @VisibleForTesting
setupSoundAndVibration(Notification.Builder builder, Account account)668     void setupSoundAndVibration(Notification.Builder builder, Account account) {
669         final int flags = account.mFlags;
670         final String ringtoneUri = account.mRingtoneUri;
671         final boolean vibrate = (flags & Account.FLAGS_VIBRATE_ALWAYS) != 0;
672         final boolean vibrateWhenSilent = (flags & Account.FLAGS_VIBRATE_WHEN_SILENT) != 0;
673         final boolean isRingerSilent = getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
674 
675         int defaults = Notification.DEFAULT_LIGHTS;
676         if (vibrate || (vibrateWhenSilent && isRingerSilent)) {
677             defaults |= Notification.DEFAULT_VIBRATE;
678         }
679 
680         builder.setSound((ringtoneUri == null) ? null : Uri.parse(ringtoneUri))
681             .setDefaults(defaults);
682     }
683 
684     /**
685      * Show (or update) a notification that the given attachment could not be forwarded. This
686      * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
687      * it's helpful for debugging.
688      *
689      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
690      */
showDownloadForwardFailedNotification(Attachment attachment)691     public void showDownloadForwardFailedNotification(Attachment attachment) {
692         final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey);
693         if (account == null) return;
694         showAccountNotification(account,
695                 mContext.getString(R.string.forward_download_failed_ticker),
696                 mContext.getString(R.string.forward_download_failed_title),
697                 attachment.mFileName,
698                 null,
699                 NOTIFICATION_ID_ATTACHMENT_WARNING);
700     }
701 
702     /**
703      * Returns a notification ID for login failed notifications for the given account account.
704      */
getLoginFailedNotificationId(long accountId)705     private int getLoginFailedNotificationId(long accountId) {
706         return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
707     }
708 
709     /**
710      * Show (or update) a notification that there was a login failure for the given account.
711      *
712      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
713      */
showLoginFailedNotification(long accountId)714     public void showLoginFailedNotification(long accountId) {
715         final Account account = Account.restoreAccountWithId(mContext, accountId);
716         if (account == null) return;
717         showAccountNotification(account,
718                 mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
719                 mContext.getString(R.string.login_failed_title),
720                 account.getDisplayName(),
721                 AccountSettings.createAccountSettingsIntent(mContext, accountId,
722                         account.mDisplayName),
723                 getLoginFailedNotificationId(accountId));
724     }
725 
726     /**
727      * Cancels the login failed notification for the given account.
728      */
cancelLoginFailedNotification(long accountId)729     public void cancelLoginFailedNotification(long accountId) {
730         mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
731     }
732 
733     /**
734      * Show (or update) a notification that the user's password is expiring. The given account
735      * is used to update the display text, but, all accounts share the same notification ID.
736      *
737      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
738      */
showPasswordExpiringNotification(long accountId)739     public void showPasswordExpiringNotification(long accountId) {
740         Account account = Account.restoreAccountWithId(mContext, accountId);
741         if (account == null) return;
742 
743         Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
744                 accountId, false);
745         String accountName = account.getDisplayName();
746         String ticker =
747             mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
748         String title = mContext.getString(R.string.password_expire_warning_content_title);
749         showAccountNotification(account, ticker, title, accountName, intent,
750                 NOTIFICATION_ID_PASSWORD_EXPIRING);
751     }
752 
753     /**
754      * Show (or update) a notification that the user's password has expired. The given account
755      * is used to update the display text, but, all accounts share the same notification ID.
756      *
757      * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
758      */
showPasswordExpiredNotification(long accountId)759     public void showPasswordExpiredNotification(long accountId) {
760         Account account = Account.restoreAccountWithId(mContext, accountId);
761         if (account == null) return;
762 
763         Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
764                 accountId, true);
765         String accountName = account.getDisplayName();
766         String ticker = mContext.getString(R.string.password_expired_ticker);
767         String title = mContext.getString(R.string.password_expired_content_title);
768         showAccountNotification(account, ticker, title, accountName, intent,
769                 NOTIFICATION_ID_PASSWORD_EXPIRED);
770     }
771 
772     /**
773      * Cancels any password expire notifications [both expired & expiring].
774      */
cancelPasswordExpirationNotifications()775     public void cancelPasswordExpirationNotifications() {
776         mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
777         mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
778     }
779 
780     /**
781      * Show (or update) a security needed notification. The given account is used to update
782      * the display text, but, all accounts share the same notification ID.
783      */
showSecurityNeededNotification(Account account)784     public void showSecurityNeededNotification(Account account) {
785         Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
786         String accountName = account.getDisplayName();
787         String ticker =
788             mContext.getString(R.string.security_notification_ticker_fmt, accountName);
789         String title = mContext.getString(R.string.security_notification_content_title);
790         showAccountNotification(account, ticker, title, accountName, intent,
791                 NOTIFICATION_ID_SECURITY_NEEDED);
792     }
793 
794     /**
795      * Cancels the security needed notification.
796      */
cancelSecurityNeededNotification()797     public void cancelSecurityNeededNotification() {
798         mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED);
799     }
800 
801     /**
802      * Observer invoked whenever a message we're notifying the user about changes.
803      */
804     private static class MessageContentObserver extends ContentObserver {
805         /** A selection to get messages the user hasn't seen before */
806         private final static String MESSAGE_SELECTION =
807                 MessageColumns.MAILBOX_KEY + "=? AND "
808                 + MessageColumns.ID + ">? AND "
809                 + MessageColumns.FLAG_READ + "=0 AND "
810                 + Message.FLAG_LOADED_SELECTION;
811         private final Context mContext;
812         private final long mMailboxId;
813         private final long mAccountId;
814 
MessageContentObserver( Handler handler, Context context, long mailboxId, long accountId)815         public MessageContentObserver(
816                 Handler handler, Context context, long mailboxId, long accountId) {
817             super(handler);
818             mContext = context;
819             mMailboxId = mailboxId;
820             mAccountId = accountId;
821         }
822 
823         @Override
onChange(boolean selfChange)824         public void onChange(boolean selfChange) {
825             if (mAccountId == sInstance.mSuspendAccountId
826                     || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
827                 return;
828             }
829 
830             ContentObserver observer = sInstance.mNotificationMap.get(mAccountId);
831             if (observer == null) {
832                 // Notification for a mailbox that we aren't observing; account is probably
833                 // being deleted.
834                 Log.w(Logging.LOG_TAG, "Received notification when observer data was null");
835                 return;
836             }
837             Account account = Account.restoreAccountWithId(mContext, mAccountId);
838             if (account == null) {
839                 Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification");
840                 return;
841             }
842             long oldMessageId = account.mNotifiedMessageId;
843             int oldMessageCount = account.mNotifiedMessageCount;
844 
845             ContentResolver resolver = mContext.getContentResolver();
846             Long lastSeenMessageId = Utility.getFirstRowLong(
847                     mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
848                     new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
849                     null, null, null, 0);
850             if (lastSeenMessageId == null) {
851                 // Mailbox got nuked. Could be that the account is in the process of being deleted
852                 Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification");
853                 return;
854             }
855 
856             Cursor c = resolver.query(
857                     Message.CONTENT_URI, EmailContent.ID_PROJECTION,
858                     MESSAGE_SELECTION,
859                     new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) },
860                     MessageColumns.ID + " DESC");
861             if (c == null) {
862                 // Couldn't find message info - things may be getting deleted in bulk.
863                 Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query");
864                 return;
865             }
866             try {
867                 int newMessageCount = c.getCount();
868                 long newMessageId = 0L;
869                 if (c.moveToNext()) {
870                     newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
871                 }
872 
873                 if (newMessageCount == 0) {
874                     // No messages to notify for; clear the notification
875                     int notificationId = sInstance.getNewMessageNotificationId(mAccountId);
876                     sInstance.mNotificationManager.cancel(notificationId);
877                 } else if (newMessageCount != oldMessageCount
878                         || (newMessageId != 0 && newMessageId != oldMessageId)) {
879                     // Either the count or last message has changed; update the notification
880                     Integer unreadCount = Utility.getFirstRowInt(
881                             mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
882                             new String[] { MailboxColumns.UNREAD_COUNT },
883                             null, null, null, 0);
884                     if (unreadCount == null) {
885                         Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox");
886                         return;
887                     }
888 
889                     Notification n = sInstance.createNewMessageNotification(
890                             mAccountId, mMailboxId, c, newMessageId,
891                             newMessageCount, unreadCount);
892                     if (n != null) {
893                         // Make the notification visible
894                         sInstance.mNotificationManager.notify(
895                                 sInstance.getNewMessageNotificationId(mAccountId), n);
896                     }
897                 }
898                 // Save away the new values
899                 ContentValues cv = new ContentValues();
900                 cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId);
901                 cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount);
902                 resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv,
903                         null, null);
904             } finally {
905                 c.close();
906             }
907         }
908     }
909 
910     /**
911      * Observer invoked whenever an account is modified. This could mean the user changed the
912      * notification settings.
913      */
914     private static class AccountContentObserver extends ContentObserver {
915         private final Context mContext;
AccountContentObserver(Handler handler, Context context)916         public AccountContentObserver(Handler handler, Context context) {
917             super(handler);
918             mContext = context;
919         }
920 
921         @Override
onChange(boolean selfChange)922         public void onChange(boolean selfChange) {
923             final ContentResolver resolver = mContext.getContentResolver();
924             final Cursor c = resolver.query(
925                 Account.CONTENT_URI, EmailContent.ID_PROJECTION,
926                 NOTIFIED_ACCOUNT_SELECTION, null, null);
927             final HashSet<Long> newAccountList = new HashSet<Long>();
928             final HashSet<Long> removedAccountList = new HashSet<Long>();
929             if (c == null) {
930                 // Suspender time ... theoretically, this will never happen
931                 Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query");
932                 return;
933             }
934             try {
935                 while (c.moveToNext()) {
936                     long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
937                     newAccountList.add(accountId);
938                 }
939             } finally {
940                 if (c != null) {
941                     c.close();
942                 }
943             }
944             // NOTE: Looping over three lists is not necessarily the most efficient. However, the
945             // account lists are going to be very small, so, this will not be necessarily bad.
946             // Cycle through existing notification list and adjust as necessary
947             for (long accountId : sInstance.mNotificationMap.keySet()) {
948                 if (!newAccountList.remove(accountId)) {
949                     // account id not in the current set of notifiable accounts
950                     removedAccountList.add(accountId);
951                 }
952             }
953             // A new account was added to the notification list
954             for (long accountId : newAccountList) {
955                 sInstance.registerMessageNotification(accountId);
956             }
957             // An account was removed from the notification list
958             for (long accountId : removedAccountList) {
959                 sInstance.unregisterMessageNotification(accountId);
960                 int notificationId = sInstance.getNewMessageNotificationId(accountId);
961                 sInstance.mNotificationManager.cancel(notificationId);
962             }
963         }
964     }
965 
966     /**
967      * Thread to handle all notification actions through its own {@link Looper}.
968      */
969     private static class NotificationThread implements Runnable {
970         /** Lock to ensure proper initialization */
971         private final Object mLock = new Object();
972         /** The {@link Looper} that handles messages for this thread */
973         private Looper mLooper;
974 
NotificationThread()975         NotificationThread() {
976             new Thread(null, this, "EmailNotification").start();
977             synchronized (mLock) {
978                 while (mLooper == null) {
979                     try {
980                         mLock.wait();
981                     } catch (InterruptedException ex) {
982                     }
983                 }
984             }
985         }
986 
987         @Override
run()988         public void run() {
989             synchronized (mLock) {
990                 Looper.prepare();
991                 mLooper = Looper.myLooper();
992                 mLock.notifyAll();
993             }
994             Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
995             Looper.loop();
996         }
quit()997         void quit() {
998             mLooper.quit();
999         }
getLooper()1000         Looper getLooper() {
1001             return mLooper;
1002         }
1003     }
1004 }
1005