1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email;
18
19import android.app.Notification;
20import android.app.Notification.Builder;
21import android.app.NotificationManager;
22import android.app.PendingIntent;
23import android.content.ContentResolver;
24import android.content.ContentUris;
25import android.content.ContentValues;
26import android.content.Context;
27import android.content.Intent;
28import android.content.res.Resources;
29import android.database.ContentObserver;
30import android.database.Cursor;
31import android.graphics.Bitmap;
32import android.graphics.BitmapFactory;
33import android.media.AudioManager;
34import android.net.Uri;
35import android.os.Build;
36import android.os.Handler;
37import android.os.Looper;
38import android.os.Process;
39import android.text.SpannableString;
40import android.text.TextUtils;
41import android.text.style.TextAppearanceSpan;
42import android.util.Log;
43
44import com.android.email.activity.ContactStatusLoader;
45import com.android.email.activity.Welcome;
46import com.android.email.activity.setup.AccountSecurity;
47import com.android.email.activity.setup.AccountSettings;
48import com.android.emailcommon.Logging;
49import com.android.emailcommon.mail.Address;
50import com.android.emailcommon.provider.Account;
51import com.android.emailcommon.provider.EmailContent;
52import com.android.emailcommon.provider.EmailContent.AccountColumns;
53import com.android.emailcommon.provider.EmailContent.Attachment;
54import com.android.emailcommon.provider.EmailContent.MailboxColumns;
55import com.android.emailcommon.provider.EmailContent.Message;
56import com.android.emailcommon.provider.EmailContent.MessageColumns;
57import com.android.emailcommon.provider.Mailbox;
58import com.android.emailcommon.utility.Utility;
59import com.google.common.annotations.VisibleForTesting;
60
61import java.util.HashMap;
62import java.util.HashSet;
63
64/**
65 * Class that manages notifications.
66 */
67public 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
119    private static boolean isRunningJellybeanOrLater() {
120        return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
121    }
122
123    /** Constructor */
124    @VisibleForTesting
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 */
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     */
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     */
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     */
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     */
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     */
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     */
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     */
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     */
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     */
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     */
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
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     */
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
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
662    int getRingerMode() {
663        return mAudioManager.getRingerMode();
664    }
665
666    /** Sets up the notification's sound and vibration based upon account details. */
667    @VisibleForTesting
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) != 0;
672
673        int defaults = Notification.DEFAULT_LIGHTS;
674        if (vibrate) {
675            defaults |= Notification.DEFAULT_VIBRATE;
676        }
677
678        builder.setSound(TextUtils.isEmpty(ringtoneUri) ? null : Uri.parse(ringtoneUri))
679            .setDefaults(defaults);
680    }
681
682    /**
683     * Show (or update) a notification that the given attachment could not be forwarded. This
684     * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
685     * it's helpful for debugging.
686     *
687     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
688     */
689    public void showDownloadForwardFailedNotification(Attachment attachment) {
690        final Account account = Account.restoreAccountWithId(mContext, attachment.mAccountKey);
691        if (account == null) return;
692        showAccountNotification(account,
693                mContext.getString(R.string.forward_download_failed_ticker),
694                mContext.getString(R.string.forward_download_failed_title),
695                attachment.mFileName,
696                null,
697                NOTIFICATION_ID_ATTACHMENT_WARNING);
698    }
699
700    /**
701     * Returns a notification ID for login failed notifications for the given account account.
702     */
703    private int getLoginFailedNotificationId(long accountId) {
704        return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
705    }
706
707    /**
708     * Show (or update) a notification that there was a login failure for the given account.
709     *
710     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
711     */
712    public void showLoginFailedNotification(long accountId) {
713        final Account account = Account.restoreAccountWithId(mContext, accountId);
714        if (account == null) return;
715        showAccountNotification(account,
716                mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
717                mContext.getString(R.string.login_failed_title),
718                account.getDisplayName(),
719                AccountSettings.createAccountSettingsIntent(mContext, accountId,
720                        account.mDisplayName),
721                getLoginFailedNotificationId(accountId));
722    }
723
724    /**
725     * Cancels the login failed notification for the given account.
726     */
727    public void cancelLoginFailedNotification(long accountId) {
728        mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
729    }
730
731    /**
732     * Show (or update) a notification that the user's password is expiring. The given account
733     * is used to update the display text, but, all accounts share the same notification ID.
734     *
735     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
736     */
737    public void showPasswordExpiringNotification(long accountId) {
738        Account account = Account.restoreAccountWithId(mContext, accountId);
739        if (account == null) return;
740
741        Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
742                accountId, false);
743        String accountName = account.getDisplayName();
744        String ticker =
745            mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
746        String title = mContext.getString(R.string.password_expire_warning_content_title);
747        showAccountNotification(account, ticker, title, accountName, intent,
748                NOTIFICATION_ID_PASSWORD_EXPIRING);
749    }
750
751    /**
752     * Show (or update) a notification that the user's password has expired. The given account
753     * is used to update the display text, but, all accounts share the same notification ID.
754     *
755     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
756     */
757    public void showPasswordExpiredNotification(long accountId) {
758        Account account = Account.restoreAccountWithId(mContext, accountId);
759        if (account == null) return;
760
761        Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
762                accountId, true);
763        String accountName = account.getDisplayName();
764        String ticker = mContext.getString(R.string.password_expired_ticker);
765        String title = mContext.getString(R.string.password_expired_content_title);
766        showAccountNotification(account, ticker, title, accountName, intent,
767                NOTIFICATION_ID_PASSWORD_EXPIRED);
768    }
769
770    /**
771     * Cancels any password expire notifications [both expired & expiring].
772     */
773    public void cancelPasswordExpirationNotifications() {
774        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
775        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
776    }
777
778    /**
779     * Show (or update) a security needed notification. The given account is used to update
780     * the display text, but, all accounts share the same notification ID.
781     */
782    public void showSecurityNeededNotification(Account account) {
783        Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
784        String accountName = account.getDisplayName();
785        String ticker =
786            mContext.getString(R.string.security_notification_ticker_fmt, accountName);
787        String title = mContext.getString(R.string.security_notification_content_title);
788        showAccountNotification(account, ticker, title, accountName, intent,
789                NOTIFICATION_ID_SECURITY_NEEDED);
790    }
791
792    /**
793     * Cancels the security needed notification.
794     */
795    public void cancelSecurityNeededNotification() {
796        mNotificationManager.cancel(NOTIFICATION_ID_SECURITY_NEEDED);
797    }
798
799    /**
800     * Observer invoked whenever a message we're notifying the user about changes.
801     */
802    private static class MessageContentObserver extends ContentObserver {
803        /** A selection to get messages the user hasn't seen before */
804        private final static String MESSAGE_SELECTION =
805                MessageColumns.MAILBOX_KEY + "=? AND "
806                + MessageColumns.ID + ">? AND "
807                + MessageColumns.FLAG_READ + "=0 AND "
808                + Message.FLAG_LOADED_SELECTION;
809        private final Context mContext;
810        private final long mMailboxId;
811        private final long mAccountId;
812
813        public MessageContentObserver(
814                Handler handler, Context context, long mailboxId, long accountId) {
815            super(handler);
816            mContext = context;
817            mMailboxId = mailboxId;
818            mAccountId = accountId;
819        }
820
821        @Override
822        public void onChange(boolean selfChange) {
823            if (mAccountId == sInstance.mSuspendAccountId
824                    || sInstance.mSuspendAccountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
825                return;
826            }
827
828            ContentObserver observer = sInstance.mNotificationMap.get(mAccountId);
829            if (observer == null) {
830                // Notification for a mailbox that we aren't observing; account is probably
831                // being deleted.
832                Log.w(Logging.LOG_TAG, "Received notification when observer data was null");
833                return;
834            }
835            Account account = Account.restoreAccountWithId(mContext, mAccountId);
836            if (account == null) {
837                Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification");
838                return;
839            }
840            long oldMessageId = account.mNotifiedMessageId;
841            int oldMessageCount = account.mNotifiedMessageCount;
842
843            ContentResolver resolver = mContext.getContentResolver();
844            Long lastSeenMessageId = Utility.getFirstRowLong(
845                    mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
846                    new String[] { MailboxColumns.LAST_SEEN_MESSAGE_KEY },
847                    null, null, null, 0);
848            if (lastSeenMessageId == null) {
849                // Mailbox got nuked. Could be that the account is in the process of being deleted
850                Log.w(Logging.LOG_TAG, "Couldn't find mailbox for changed message notification");
851                return;
852            }
853
854            Cursor c = resolver.query(
855                    Message.CONTENT_URI, EmailContent.ID_PROJECTION,
856                    MESSAGE_SELECTION,
857                    new String[] { Long.toString(mMailboxId), Long.toString(lastSeenMessageId) },
858                    MessageColumns.ID + " DESC");
859            if (c == null) {
860                // Couldn't find message info - things may be getting deleted in bulk.
861                Log.w(Logging.LOG_TAG, "#onChange(); NULL response for message id query");
862                return;
863            }
864            try {
865                int newMessageCount = c.getCount();
866                long newMessageId = 0L;
867                if (c.moveToNext()) {
868                    newMessageId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
869                }
870
871                if (newMessageCount == 0) {
872                    // No messages to notify for; clear the notification
873                    int notificationId = sInstance.getNewMessageNotificationId(mAccountId);
874                    sInstance.mNotificationManager.cancel(notificationId);
875                } else if (newMessageCount != oldMessageCount
876                        || (newMessageId != 0 && newMessageId != oldMessageId)) {
877                    // Either the count or last message has changed; update the notification
878                    Integer unreadCount = Utility.getFirstRowInt(
879                            mContext, ContentUris.withAppendedId(Mailbox.CONTENT_URI, mMailboxId),
880                            new String[] { MailboxColumns.UNREAD_COUNT },
881                            null, null, null, 0);
882                    if (unreadCount == null) {
883                        Log.w(Logging.LOG_TAG, "Couldn't find unread count for mailbox");
884                        return;
885                    }
886
887                    Notification n = sInstance.createNewMessageNotification(
888                            mAccountId, mMailboxId, c, newMessageId,
889                            newMessageCount, unreadCount);
890                    if (n != null) {
891                        // Make the notification visible
892                        sInstance.mNotificationManager.notify(
893                                sInstance.getNewMessageNotificationId(mAccountId), n);
894                    }
895                }
896                // Save away the new values
897                ContentValues cv = new ContentValues();
898                cv.put(AccountColumns.NOTIFIED_MESSAGE_ID, newMessageId);
899                cv.put(AccountColumns.NOTIFIED_MESSAGE_COUNT, newMessageCount);
900                resolver.update(ContentUris.withAppendedId(Account.CONTENT_URI, mAccountId), cv,
901                        null, null);
902            } finally {
903                c.close();
904            }
905        }
906    }
907
908    /**
909     * Observer invoked whenever an account is modified. This could mean the user changed the
910     * notification settings.
911     */
912    private static class AccountContentObserver extends ContentObserver {
913        private final Context mContext;
914        public AccountContentObserver(Handler handler, Context context) {
915            super(handler);
916            mContext = context;
917        }
918
919        @Override
920        public void onChange(boolean selfChange) {
921            final ContentResolver resolver = mContext.getContentResolver();
922            final Cursor c = resolver.query(
923                Account.CONTENT_URI, EmailContent.ID_PROJECTION,
924                NOTIFIED_ACCOUNT_SELECTION, null, null);
925            final HashSet<Long> newAccountList = new HashSet<Long>();
926            final HashSet<Long> removedAccountList = new HashSet<Long>();
927            if (c == null) {
928                // Suspender time ... theoretically, this will never happen
929                Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query");
930                return;
931            }
932            try {
933                while (c.moveToNext()) {
934                    long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
935                    newAccountList.add(accountId);
936                }
937            } finally {
938                if (c != null) {
939                    c.close();
940                }
941            }
942            // NOTE: Looping over three lists is not necessarily the most efficient. However, the
943            // account lists are going to be very small, so, this will not be necessarily bad.
944            // Cycle through existing notification list and adjust as necessary
945            for (long accountId : sInstance.mNotificationMap.keySet()) {
946                if (!newAccountList.remove(accountId)) {
947                    // account id not in the current set of notifiable accounts
948                    removedAccountList.add(accountId);
949                }
950            }
951            // A new account was added to the notification list
952            for (long accountId : newAccountList) {
953                sInstance.registerMessageNotification(accountId);
954            }
955            // An account was removed from the notification list
956            for (long accountId : removedAccountList) {
957                sInstance.unregisterMessageNotification(accountId);
958                int notificationId = sInstance.getNewMessageNotificationId(accountId);
959                sInstance.mNotificationManager.cancel(notificationId);
960            }
961        }
962    }
963
964    /**
965     * Thread to handle all notification actions through its own {@link Looper}.
966     */
967    private static class NotificationThread implements Runnable {
968        /** Lock to ensure proper initialization */
969        private final Object mLock = new Object();
970        /** The {@link Looper} that handles messages for this thread */
971        private Looper mLooper;
972
973        NotificationThread() {
974            new Thread(null, this, "EmailNotification").start();
975            synchronized (mLock) {
976                while (mLooper == null) {
977                    try {
978                        mLock.wait();
979                    } catch (InterruptedException ex) {
980                    }
981                }
982            }
983        }
984
985        @Override
986        public void run() {
987            synchronized (mLock) {
988                Looper.prepare();
989                mLooper = Looper.myLooper();
990                mLock.notifyAll();
991            }
992            Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
993            Looper.loop();
994        }
995        void quit() {
996            mLooper.quit();
997        }
998        Looper getLooper() {
999            return mLooper;
1000        }
1001    }
1002}
1003