NotificationController.java revision f419287f22ae44f25e1ba1f757ec33c7941bbfa8
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.Handler;
36import android.os.Looper;
37import android.os.Process;
38import android.text.SpannableString;
39import android.text.TextUtils;
40import android.text.style.TextAppearanceSpan;
41import android.util.Log;
42
43import com.android.email.activity.ContactStatusLoader;
44import com.android.email.activity.setup.AccountSecurity;
45import com.android.email.activity.setup.AccountSettings;
46import com.android.email.provider.EmailProvider;
47import com.android.email.service.EmailBroadcastProcessorService;
48import com.android.email2.ui.MailActivityEmail;
49import com.android.emailcommon.Logging;
50import com.android.emailcommon.mail.Address;
51import com.android.emailcommon.provider.Account;
52import com.android.emailcommon.provider.EmailContent;
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.Mailbox;
57import com.android.emailcommon.utility.EmailAsyncTask;
58import com.android.emailcommon.utility.Utility;
59import com.android.mail.providers.Conversation;
60import com.android.mail.providers.Folder;
61import com.android.mail.providers.UIProvider;
62import com.android.mail.utils.Utils;
63import com.google.common.annotations.VisibleForTesting;
64
65import java.util.HashMap;
66import java.util.HashSet;
67
68/**
69 * Class that manages notifications.
70 */
71public class NotificationController {
72    private static final String TAG = "NotificationController";
73
74    /** Reserved for {@link com.android.exchange.CalendarSyncEnabler} */
75    @SuppressWarnings("unused")
76    private static final int NOTIFICATION_ID_EXCHANGE_CALENDAR_ADDED = 2;
77    private static final int NOTIFICATION_ID_ATTACHMENT_WARNING = 3;
78    private static final int NOTIFICATION_ID_PASSWORD_EXPIRING = 4;
79    private static final int NOTIFICATION_ID_PASSWORD_EXPIRED = 5;
80
81    private static final int NOTIFICATION_ID_BASE_MASK = 0xF0000000;
82    private static final int NOTIFICATION_ID_BASE_NEW_MESSAGES = 0x10000000;
83    private static final int NOTIFICATION_ID_BASE_LOGIN_WARNING = 0x20000000;
84    private static final int NOTIFICATION_ID_BASE_SECURITY_NEEDED = 0x30000000;
85    private static final int NOTIFICATION_ID_BASE_SECURITY_CHANGED = 0x40000000;
86
87    /** Selection to retrieve accounts that should we notify user for changes */
88    private final static String NOTIFIED_ACCOUNT_SELECTION =
89        Account.FLAGS + "&" + Account.FLAGS_NOTIFY_NEW_MAIL + " != 0";
90
91    private static final String NEW_MAIL_MAILBOX_ID = "com.android.email.new_mail.mailboxId";
92    private static final String NEW_MAIL_MESSAGE_ID = "com.android.email.new_mail.messageId";
93    private static final String NEW_MAIL_MESSAGE_COUNT = "com.android.email.new_mail.messageCount";
94    private static final String NEW_MAIL_UNREAD_COUNT = "com.android.email.new_mail.unreadCount";
95
96    private static NotificationThread sNotificationThread;
97    private static Handler sNotificationHandler;
98    private static NotificationController sInstance;
99    private final Context mContext;
100    private final NotificationManager mNotificationManager;
101    private final AudioManager mAudioManager;
102    private final Bitmap mGenericSenderIcon;
103    private final Bitmap mGenericMultipleSenderIcon;
104    private final Clock mClock;
105    /** Maps account id to its observer */
106    private final HashMap<Long, ContentObserver> mNotificationMap;
107    private ContentObserver mAccountObserver;
108
109    /**
110     * Timestamp indicating when the last message notification sound was played.
111     * Used for throttling.
112     */
113    private long mLastMessageNotifyTime;
114
115    /**
116     * Minimum interval between notification sounds.
117     * Since a long sync (either on account setup or after a long period of being offline) can cause
118     * several notifications consecutively, it can be pretty overwhelming to get a barrage of
119     * notification sounds. Throttle them using this value.
120     */
121    private static final long MIN_SOUND_INTERVAL_MS = 15 * 1000; // 15 seconds
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_BASE_MASK) == NOTIFICATION_ID_BASE_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 accountId The id of 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(long accountId, String ticker,
175            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            Account account = Account.restoreAccountWithId(mContext, accountId);
198            setupSoundAndVibration(builder, account);
199        }
200
201        return builder;
202    }
203
204    /**
205     * Generic notifier for any account.  Uses notification rules from account.
206     *
207     * @param accountId The account id this notification is being built for.
208     * @param ticker Text displayed when the notification is first shown. May be {@code null}.
209     * @param title The first line of text. May NOT be {@code null}.
210     * @param contentText The second line of text. May NOT be {@code null}.
211     * @param intent The intent to start if the user clicks on the notification.
212     * @param notificationId The ID of the notification to register with the service.
213     */
214    private void showNotification(long accountId, String ticker, String title,
215            String contentText, Intent intent, int notificationId) {
216        final Notification.Builder builder = createBaseAccountNotificationBuilder(accountId, ticker,
217                title, contentText, intent, null, null, true,
218                needsOngoingNotification(notificationId));
219        mNotificationManager.notify(notificationId, builder.getNotification());
220    }
221
222    /**
223     * Returns a notification ID for new message notifications for the given account.
224     */
225    private int getNewMessageNotificationId(long mailboxId) {
226        // We assume accountId will always be less than 0x0FFFFFFF; is there a better way?
227        return (int) (NOTIFICATION_ID_BASE_NEW_MESSAGES + mailboxId);
228    }
229
230    /**
231     * Tells the notification controller if it should be watching for changes to the message table.
232     * This is the main life cycle method for message notifications. When we stop observing
233     * database changes, we save the state [e.g. message ID and count] of the most recent
234     * notification shown to the user. And, when we start observing database changes, we restore
235     * the saved state.
236     * @param watch If {@code true}, we register observers for all accounts whose settings have
237     *              notifications enabled. Otherwise, all observers are unregistered.
238     */
239    public void watchForMessages(final boolean watch) {
240        if (MailActivityEmail.DEBUG) {
241            Log.d(Logging.LOG_TAG, "Notifications being toggled: " + watch);
242        }
243        // Don't create the thread if we're only going to stop watching
244        if (!watch && sNotificationThread == null) return;
245
246        ensureHandlerExists();
247        // Run this on the message notification handler
248        sNotificationHandler.post(new Runnable() {
249            @Override
250            public void run() {
251                ContentResolver resolver = mContext.getContentResolver();
252                if (!watch) {
253                    unregisterMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
254                    if (mAccountObserver != null) {
255                        resolver.unregisterContentObserver(mAccountObserver);
256                        mAccountObserver = null;
257                    }
258
259                    // tear down the event loop
260                    sNotificationThread.quit();
261                    sNotificationThread = null;
262                    return;
263                }
264
265                // otherwise, start new observers for all notified accounts
266                registerMessageNotification(Account.ACCOUNT_ID_COMBINED_VIEW);
267                // If we're already observing account changes, don't do anything else
268                if (mAccountObserver == null) {
269                    if (MailActivityEmail.DEBUG) {
270                        Log.i(Logging.LOG_TAG, "Observing account changes for notifications");
271                    }
272                    mAccountObserver = new AccountContentObserver(sNotificationHandler, mContext);
273                    resolver.registerContentObserver(Account.NOTIFIER_URI, true, mAccountObserver);
274                }
275            }
276        });
277    }
278
279    /**
280     * Ensures the notification handler exists and is ready to handle requests.
281     */
282    private static synchronized void ensureHandlerExists() {
283        if (sNotificationThread == null) {
284            sNotificationThread = new NotificationThread();
285            sNotificationHandler = new Handler(sNotificationThread.getLooper());
286        }
287    }
288
289    /**
290     * Registers an observer for changes to mailboxes in the given account.
291     * NOTE: This must be called on the notification handler thread.
292     * @param accountId The ID of the account to register the observer for. May be
293     *                  {@link Account#ACCOUNT_ID_COMBINED_VIEW} to register observers for all
294     *                  accounts that allow for user notification.
295     */
296    private void registerMessageNotification(long accountId) {
297        ContentResolver resolver = mContext.getContentResolver();
298        if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
299            Cursor c = resolver.query(
300                    Account.CONTENT_URI, EmailContent.ID_PROJECTION,
301                    NOTIFIED_ACCOUNT_SELECTION, null, null);
302            try {
303                while (c.moveToNext()) {
304                    long id = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
305                    registerMessageNotification(id);
306                }
307            } finally {
308                c.close();
309            }
310        } else {
311            ContentObserver obs = mNotificationMap.get(accountId);
312            if (obs != null) return;  // we're already observing; nothing to do
313            if (MailActivityEmail.DEBUG) {
314                Log.i(Logging.LOG_TAG, "Registering for notifications for account " + accountId);
315            }
316            ContentObserver observer = new MessageContentObserver(
317                    sNotificationHandler, mContext, accountId);
318            resolver.registerContentObserver(Message.NOTIFIER_URI, true, observer);
319            mNotificationMap.put(accountId, observer);
320            // Now, ping the observer for any initial notifications
321            observer.onChange(true);
322        }
323    }
324
325    /**
326     * Unregisters the observer for the given account. If the specified account does not have
327     * a registered observer, no action is performed. This will not clear any existing notification
328     * for the specified account. Use {@link NotificationManager#cancel(int)}.
329     * NOTE: This must be called on the notification handler thread.
330     * @param accountId The ID of the account to unregister from. To unregister all accounts that
331     *                  have observers, specify an ID of {@link Account#ACCOUNT_ID_COMBINED_VIEW}.
332     */
333    private void unregisterMessageNotification(long accountId) {
334        ContentResolver resolver = mContext.getContentResolver();
335        if (accountId == Account.ACCOUNT_ID_COMBINED_VIEW) {
336            if (MailActivityEmail.DEBUG) {
337                Log.i(Logging.LOG_TAG, "Unregistering notifications for all accounts");
338            }
339            // cancel all existing message observers
340            for (ContentObserver observer : mNotificationMap.values()) {
341                resolver.unregisterContentObserver(observer);
342            }
343            mNotificationMap.clear();
344        } else {
345            if (MailActivityEmail.DEBUG) {
346                Log.i(Logging.LOG_TAG, "Unregistering notifications for account " + accountId);
347            }
348            ContentObserver observer = mNotificationMap.remove(accountId);
349            if (observer != null) {
350                resolver.unregisterContentObserver(observer);
351            }
352        }
353    }
354
355    /**
356     * Returns a picture of the sender of the given message. If no picture is available, returns
357     * {@code null}.
358     *
359     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
360     */
361    private Bitmap getSenderPhoto(Message message) {
362        Address sender = Address.unpackFirst(message.mFrom);
363        if (sender == null) {
364            return null;
365        }
366        String email = sender.getAddress();
367        if (TextUtils.isEmpty(email)) {
368            return null;
369        }
370        Bitmap photo = ContactStatusLoader.getContactInfo(mContext, email).mPhoto;
371
372        if (photo != null) {
373            final Resources res = mContext.getResources();
374            final int idealIconHeight =
375                    res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
376            final int idealIconWidth =
377                    res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
378
379            if (photo.getHeight() < idealIconHeight) {
380                // We should scale this image to fit the intended size
381                photo = Bitmap.createScaledBitmap(
382                        photo, idealIconWidth, idealIconHeight, true);
383            }
384        }
385        return photo;
386    }
387
388    public static final String EXTRA_ACCOUNT = "account";
389    public static final String EXTRA_CONVERSATION = "conversationUri";
390    public static final String EXTRA_FOLDER = "folder";
391
392    private Intent createViewConversationIntent(Conversation conversation, Folder folder,
393            com.android.mail.providers.Account account) {
394        final Intent intent = new Intent(Intent.ACTION_VIEW);
395        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
396        intent.setDataAndType(conversation.uri, account.mimeType);
397        intent.putExtra(EXTRA_ACCOUNT, account);
398        intent.putExtra(EXTRA_FOLDER, folder);
399        intent.putExtra(EXTRA_CONVERSATION, conversation);
400        return intent;
401    }
402
403    private Cursor getUiCursor(Uri uri, String[] projection) {
404        Cursor c = mContext.getContentResolver().query(uri, projection, null, null, null);
405        if (c == null) return null;
406        if (c.moveToFirst()) {
407            return c;
408        } else {
409            c.close();
410            return null;
411        }
412    }
413
414    private Intent createViewConversationIntent(Message message) {
415        Cursor c = getUiCursor(EmailProvider.uiUri("uiaccount", message.mAccountKey),
416                UIProvider.ACCOUNTS_PROJECTION);
417        if (c == null) {
418            Log.w(TAG, "Can't find account for message " + message.mId);
419            return null;
420        }
421        com.android.mail.providers.Account acct = new com.android.mail.providers.Account(c);
422        c.close();
423        c = getUiCursor(EmailProvider.uiUri("uifolder", message.mMailboxKey),
424                UIProvider.FOLDERS_PROJECTION);
425        if (c == null) {
426            Log.w(TAG, "Can't find folder for message " + message.mId + ", folder " +
427                    message.mMailboxKey);
428            return null;
429        }
430        Folder folder = new Folder(c);
431        c.close();
432        c = getUiCursor(EmailProvider.uiUri("uiconversation", message.mId),
433                UIProvider.CONVERSATION_PROJECTION);
434        if (c == null) {
435            Log.w(TAG, "Can't find conversation for message " + message.mId);
436            return null;
437        }
438        Conversation conv = new Conversation(c);
439        c.close();
440        return createViewConversationIntent(conv, folder, acct);
441    }
442
443    /**
444     * Returns a "new message" notification for the given account.
445     *
446     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
447     */
448    @VisibleForTesting
449    Notification createNewMessageNotification(long mailboxId, long newMessageId,
450            int unseenMessageCount, int unreadCount) {
451        final Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, mailboxId);
452        if (mailbox == null) {
453            return null;
454        }
455        final Account account = Account.restoreAccountWithId(mContext, mailbox.mAccountKey);
456        if (account == null) {
457            return null;
458        }
459        // Get the latest message
460        final Message message = Message.restoreMessageWithId(mContext, newMessageId);
461        if (message == null) {
462            return null; // no message found???
463        }
464
465        String senderName = Address.toFriendly(Address.unpack(message.mFrom));
466        if (senderName == null) {
467            senderName = ""; // Happens when a message has no from.
468        }
469        final boolean multipleUnseen = unseenMessageCount > 1;
470        final Bitmap senderPhoto = multipleUnseen
471                ? mGenericMultipleSenderIcon
472                : getSenderPhoto(message);
473        final SpannableString title = getNewMessageTitle(senderName, unseenMessageCount);
474        // TODO: add in display name on the second line for the text, once framework supports
475        // multiline texts.
476        // Show account name if an inbox; otherwise mailbox name
477        final String text = multipleUnseen
478                ? ((mailbox.mType == Mailbox.TYPE_INBOX) ? account.mDisplayName :
479                    mailbox.mDisplayName)
480                : message.mSubject;
481        final Bitmap largeIcon = senderPhoto != null ? senderPhoto : mGenericSenderIcon;
482        final Integer number = unreadCount > 1 ? unreadCount : null;
483        Intent intent = createViewConversationIntent(message);
484        if (intent == null) {
485            return null;
486        }
487        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
488                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
489        long now = mClock.getTime();
490        boolean enableAudio = (now - mLastMessageNotifyTime) > MIN_SOUND_INTERVAL_MS;
491        final Notification.Builder builder = createBaseAccountNotificationBuilder(
492                mailbox.mAccountKey, title.toString(), title, text,
493                intent, largeIcon, number, enableAudio, false);
494        if (Utils.isRunningJellybeanOrLater()) {
495            // For a new-style notification
496            if (multipleUnseen) {
497                final Cursor messageCursor =
498                        mContext.getContentResolver().query(ContentUris.withAppendedId(
499                                EmailContent.MAILBOX_NOTIFICATION_URI, mailbox.mAccountKey),
500                                EmailContent.NOTIFICATION_PROJECTION, null, null, null);
501
502                try {
503                    if (messageCursor != null && messageCursor.getCount() > 0) {
504                        final int maxNumDigestItems = mContext.getResources().getInteger(
505                                R.integer.max_num_notification_digest_items);
506                        // The body of the notification is the account name, or the label name.
507                        builder.setSubText(text);
508
509                        Notification.InboxStyle digest = new Notification.InboxStyle(builder);
510
511                        digest.setBigContentTitle(title);
512
513                        int numDigestItems = 0;
514                        // We can assume that the current position of the cursor is on the
515                        // newest message
516                        messageCursor.moveToFirst();
517                        do {
518                            final long messageId =
519                                    messageCursor.getLong(EmailContent.ID_PROJECTION_COLUMN);
520
521                            // Get the latest message
522                            final Message digestMessage =
523                                    Message.restoreMessageWithId(mContext, messageId);
524                            if (digestMessage != null) {
525                                final CharSequence digestLine =
526                                        getSingleMessageInboxLine(mContext, digestMessage);
527                                digest.addLine(digestLine);
528                                numDigestItems++;
529                            }
530                        } while (numDigestItems <= maxNumDigestItems && messageCursor.moveToNext());
531
532                        // We want to clear the content text in this case. The content text would
533                        // have been set in createBaseAccountNotificationBuilder, but since the
534                        // same string was set in as the subtext, we don't want to show a
535                        // duplicate string.
536                        builder.setContentText(null);
537                    }
538                } finally {
539                    if (messageCursor != null) {
540                        messageCursor.close();
541                    }
542                }
543            } else {
544                // The notification content will be the subject of the conversation.
545                builder.setContentText(getSingleMessageLittleText(mContext, message.mSubject));
546
547                // The notification subtext will be the subject of the conversation for inbox
548                // notifications, or will based on the the label name for user label notifications.
549                builder.setSubText(account.mDisplayName);
550
551                final Notification.BigTextStyle bigText = new Notification.BigTextStyle(builder);
552                bigText.bigText(getSingleMessageBigText(mContext, message));
553            }
554        }
555
556        mLastMessageNotifyTime = now;
557        return builder.getNotification();
558    }
559
560    /**
561     * Sets the bigtext for a notification for a single new conversation
562     * @param context
563     * @param message New message that triggered the notification.
564     * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle}
565     */
566    private static CharSequence getSingleMessageInboxLine(Context context, Message message) {
567        final String subject = message.mSubject;
568        final String snippet = message.mSnippet;
569        final String senders = Address.toFriendly(Address.unpack(message.mFrom));
570
571        final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
572
573        final TextAppearanceSpan notificationPrimarySpan =
574                new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
575
576        if (TextUtils.isEmpty(senders)) {
577            // If the senders are empty, just use the subject/snippet.
578            return subjectSnippet;
579        }
580        else if (TextUtils.isEmpty(subjectSnippet)) {
581            // If the subject/snippet is empty, just use the senders.
582            final SpannableString spannableString = new SpannableString(senders);
583            spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
584
585            return spannableString;
586        } else {
587            final String formatString = context.getResources().getString(
588                    R.string.multiple_new_message_notification_item);
589            final TextAppearanceSpan notificationSecondarySpan =
590                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
591
592            final String instantiatedString = String.format(formatString, senders, subjectSnippet);
593
594            final SpannableString spannableString = new SpannableString(instantiatedString);
595
596            final boolean isOrderReversed = formatString.indexOf("%2$s") <
597                    formatString.indexOf("%1$s");
598            final int primaryOffset =
599                    (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
600                     instantiatedString.indexOf(senders));
601            final int secondaryOffset =
602                    (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
603                     instantiatedString.indexOf(subjectSnippet));
604            spannableString.setSpan(notificationPrimarySpan,
605                    primaryOffset, primaryOffset + senders.length(), 0);
606            spannableString.setSpan(notificationSecondarySpan,
607                    secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
608            return spannableString;
609        }
610    }
611
612    /**
613     * Sets the bigtext for a notification for a single new conversation
614     * @param context
615     * @param subject Subject of the new message that triggered the notification
616     * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText}
617     */
618    private static CharSequence getSingleMessageLittleText(Context context, String subject) {
619        if (subject == null) {
620            return null;
621        }
622        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
623                context, R.style.NotificationPrimaryText);
624
625        final SpannableString spannableString = new SpannableString(subject);
626        spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
627
628        return spannableString;
629    }
630
631
632    /**
633     * Sets the bigtext for a notification for a single new conversation
634     * @param context
635     * @param message New message that triggered the notification
636     * @return a {@link CharSequence} suitable for use in {@link Notification.BigTextStyle}
637     */
638    private static CharSequence getSingleMessageBigText(Context context, Message message) {
639        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
640                context, R.style.NotificationPrimaryText);
641
642        final String subject = message.mSubject;
643        final String snippet = message.mSnippet;
644
645        if (TextUtils.isEmpty(subject)) {
646            // If the subject is empty, just use the snippet.
647            return snippet;
648        }
649        else if (TextUtils.isEmpty(snippet)) {
650            // If the snippet is empty, just use the subject.
651            final SpannableString spannableString = new SpannableString(subject);
652            spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
653
654            return spannableString;
655        } else {
656            final String notificationBigTextFormat = context.getResources().getString(
657                    R.string.single_new_message_notification_big_text);
658
659            // Localizers may change the order of the parameters, look at how the format
660            // string is structured.
661            final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
662                    notificationBigTextFormat.indexOf("%1$s");
663            final String bigText = String.format(notificationBigTextFormat, subject, snippet);
664            final SpannableString spannableString = new SpannableString(bigText);
665
666            final int subjectOffset =
667                    (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
668            spannableString.setSpan(notificationSubjectSpan,
669                    subjectOffset, subjectOffset + subject.length(), 0);
670
671            return spannableString;
672        }
673    }
674
675    /**
676     * Creates a notification title for a new message. If there is only a single message,
677     * show the sender name. Otherwise, show "X new messages".
678     */
679    @VisibleForTesting
680    SpannableString getNewMessageTitle(String sender, int unseenCount) {
681        String title;
682        if (unseenCount > 1) {
683            title = String.format(
684                    mContext.getString(R.string.notification_multiple_new_messages_fmt),
685                    unseenCount);
686        } else {
687            title = sender;
688        }
689        return new SpannableString(title);
690    }
691
692    /** Returns the system's current ringer mode */
693    @VisibleForTesting
694    int getRingerMode() {
695        return mAudioManager.getRingerMode();
696    }
697
698    /** Sets up the notification's sound and vibration based upon account details. */
699    @VisibleForTesting
700    void setupSoundAndVibration(Notification.Builder builder, Account account) {
701        final int flags = account.mFlags;
702        final String ringtoneUri = account.mRingtoneUri;
703        final boolean vibrate = (flags & Account.FLAGS_VIBRATE_ALWAYS) != 0;
704        final boolean vibrateWhenSilent = (flags & Account.FLAGS_VIBRATE_WHEN_SILENT) != 0;
705        final boolean isRingerSilent = getRingerMode() != AudioManager.RINGER_MODE_NORMAL;
706
707        int defaults = Notification.DEFAULT_LIGHTS;
708        if (vibrate || (vibrateWhenSilent && isRingerSilent)) {
709            defaults |= Notification.DEFAULT_VIBRATE;
710        }
711
712        builder.setSound((ringtoneUri == null) ? null : Uri.parse(ringtoneUri))
713            .setDefaults(defaults);
714    }
715
716    /**
717     * Show (or update) a notification that the given attachment could not be forwarded. This
718     * is a very unusual case, and perhaps we shouldn't even send a notification. For now,
719     * it's helpful for debugging.
720     *
721     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
722     */
723    public void showDownloadForwardFailedNotification(Attachment attachment) {
724        Message message = Message.restoreMessageWithId(mContext, attachment.mMessageKey);
725        if (message == null) return;
726        Mailbox mailbox = Mailbox.restoreMailboxWithId(mContext, message.mMailboxKey);
727        showNotification(mailbox.mAccountKey,
728                mContext.getString(R.string.forward_download_failed_ticker),
729                mContext.getString(R.string.forward_download_failed_title),
730                attachment.mFileName,
731                null,
732                NOTIFICATION_ID_ATTACHMENT_WARNING);
733    }
734
735    /**
736     * Returns a notification ID for login failed notifications for the given account account.
737     */
738    private int getLoginFailedNotificationId(long accountId) {
739        return NOTIFICATION_ID_BASE_LOGIN_WARNING + (int)accountId;
740    }
741
742    /**
743     * Show (or update) a notification that there was a login failure for the given account.
744     *
745     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
746     */
747    public void showLoginFailedNotification(long accountId) {
748        final Account account = Account.restoreAccountWithId(mContext, accountId);
749        if (account == null) return;
750        final Mailbox mailbox = Mailbox.restoreMailboxOfType(mContext, account.mId,
751                Mailbox.TYPE_INBOX);
752        if (mailbox == null) return;
753        showNotification(mailbox.mAccountKey,
754                mContext.getString(R.string.login_failed_ticker, account.mDisplayName),
755                mContext.getString(R.string.login_failed_title),
756                account.getDisplayName(),
757                AccountSettings.createAccountSettingsIntent(mContext, accountId,
758                        account.mDisplayName),
759                getLoginFailedNotificationId(accountId));
760    }
761
762    /**
763     * Cancels the login failed notification for the given account.
764     */
765    public void cancelLoginFailedNotification(long accountId) {
766        mNotificationManager.cancel(getLoginFailedNotificationId(accountId));
767    }
768
769    /**
770     * Cancels the new message notification for a given mailbox
771     */
772    public void cancelNewMessageNotification(long mailboxId) {
773        mNotificationManager.cancel(getNewMessageNotificationId(mailboxId));
774    }
775
776    /**
777     * Show (or update) a notification that the user's password is expiring. The given account
778     * is used to update the display text, but, all accounts share the same notification ID.
779     *
780     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
781     */
782    public void showPasswordExpiringNotification(long accountId) {
783        Account account = Account.restoreAccountWithId(mContext, accountId);
784        if (account == null) return;
785
786        Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
787                accountId, false);
788        String accountName = account.getDisplayName();
789        String ticker =
790            mContext.getString(R.string.password_expire_warning_ticker_fmt, accountName);
791        String title = mContext.getString(R.string.password_expire_warning_content_title);
792        showNotification(accountId, ticker, title, accountName, intent,
793                NOTIFICATION_ID_PASSWORD_EXPIRING);
794    }
795
796    /**
797     * Show (or update) a notification that the user's password has expired. The given account
798     * is used to update the display text, but, all accounts share the same notification ID.
799     *
800     * NOTE: DO NOT CALL THIS METHOD FROM THE UI THREAD (DATABASE ACCESS)
801     */
802    public void showPasswordExpiredNotification(long accountId) {
803        Account account = Account.restoreAccountWithId(mContext, accountId);
804        if (account == null) return;
805
806        Intent intent = AccountSecurity.actionDevicePasswordExpirationIntent(mContext,
807                accountId, true);
808        String accountName = account.getDisplayName();
809        String ticker = mContext.getString(R.string.password_expired_ticker);
810        String title = mContext.getString(R.string.password_expired_content_title);
811        showNotification(accountId, ticker, title, accountName, intent,
812                NOTIFICATION_ID_PASSWORD_EXPIRED);
813    }
814
815    /**
816     * Cancels any password expire notifications [both expired & expiring].
817     */
818    public void cancelPasswordExpirationNotifications() {
819        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRING);
820        mNotificationManager.cancel(NOTIFICATION_ID_PASSWORD_EXPIRED);
821    }
822
823    /**
824     * Show (or update) a security needed notification. If tapped, the user is taken to a
825     * dialog asking whether he wants to update his settings.
826     */
827    public void showSecurityNeededNotification(Account account) {
828        Intent intent = AccountSecurity.actionUpdateSecurityIntent(mContext, account.mId, true);
829        String accountName = account.getDisplayName();
830        String ticker =
831            mContext.getString(R.string.security_needed_ticker_fmt, accountName);
832        String title = mContext.getString(R.string.security_notification_content_update_title);
833        showNotification(account.mId, ticker, title, accountName, intent,
834                (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId));
835    }
836
837    /**
838     * Show (or update) a security changed notification. If tapped, the user is taken to the
839     * account settings screen where he can view the list of enforced policies
840     */
841    public void showSecurityChangedNotification(Account account) {
842        Intent intent = AccountSettings.createAccountSettingsIntent(mContext, account.mId, null);
843        String accountName = account.getDisplayName();
844        String ticker =
845            mContext.getString(R.string.security_changed_ticker_fmt, accountName);
846        String title = mContext.getString(R.string.security_notification_content_change_title);
847        showNotification(account.mId, ticker, title, accountName, intent,
848                (int)(NOTIFICATION_ID_BASE_SECURITY_CHANGED + account.mId));
849    }
850
851    /**
852     * Show (or update) a security unsupported notification. If tapped, the user is taken to the
853     * account settings screen where he can view the list of unsupported policies
854     */
855    public void showSecurityUnsupportedNotification(Account account) {
856        Intent intent = AccountSettings.createAccountSettingsIntent(mContext, account.mId, null);
857        String accountName = account.getDisplayName();
858        String ticker =
859            mContext.getString(R.string.security_unsupported_ticker_fmt, accountName);
860        String title = mContext.getString(R.string.security_notification_content_unsupported_title);
861        showNotification(account.mId, ticker, title, accountName, intent,
862                (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + account.mId));
863   }
864
865    /**
866     * Cancels all security needed notifications.
867     */
868    public void cancelSecurityNeededNotification() {
869        EmailAsyncTask.runAsyncParallel(new Runnable() {
870            @Override
871            public void run() {
872                Cursor c = mContext.getContentResolver().query(Account.CONTENT_URI,
873                        Account.ID_PROJECTION, null, null, null);
874                try {
875                    while (c.moveToNext()) {
876                        long id = c.getLong(Account.ID_PROJECTION_COLUMN);
877                        mNotificationManager.cancel(
878                               (int)(NOTIFICATION_ID_BASE_SECURITY_NEEDED + id));
879                    }
880                }
881                finally {
882                    c.close();
883                }
884            }});
885    }
886
887    /**
888     * Observer invoked whenever a message we're notifying the user about changes.
889     */
890    private static class MessageContentObserver extends ContentObserver {
891        private final Context mContext;
892        private final long mAccountId;
893
894        public MessageContentObserver(
895                Handler handler, Context context, long accountId) {
896            super(handler);
897            mContext = context;
898            mAccountId = accountId;
899        }
900
901        @Override
902        public void onChange(boolean selfChange) {
903            ContentObserver observer = sInstance.mNotificationMap.get(mAccountId);
904            Account account = Account.restoreAccountWithId(mContext, mAccountId);
905            if (observer == null || account == null) {
906                Log.w(Logging.LOG_TAG, "Couldn't find account for changed message notification");
907                return;
908            }
909
910            ContentResolver resolver = mContext.getContentResolver();
911            Cursor c = resolver.query(ContentUris.withAppendedId(
912                    EmailContent.MAILBOX_NOTIFICATION_URI, mAccountId),
913                    EmailContent.NOTIFICATION_PROJECTION, null, null, null);
914            try {
915                while (c.moveToNext()) {
916                    long mailboxId = c.getLong(EmailContent.NOTIFICATION_MAILBOX_ID_COLUMN);
917                    if (mailboxId == 0) continue;
918                    int messageCount =
919                            c.getInt(EmailContent.NOTIFICATION_MAILBOX_MESSAGE_COUNT_COLUMN);
920                    int unreadCount =
921                            c.getInt(EmailContent.NOTIFICATION_MAILBOX_UNREAD_COUNT_COLUMN);
922
923                    Mailbox m = Mailbox.restoreMailboxWithId(mContext, mailboxId);
924                    long newMessageId = Utility.getFirstRowLong(mContext,
925                            ContentUris.withAppendedId(
926                                    EmailContent.MAILBOX_MOST_RECENT_MESSAGE_URI, mailboxId),
927                            Message.ID_COLUMN_PROJECTION, null, null, null,
928                            Message.ID_MAILBOX_COLUMN_ID, -1L);
929                    Log.d(Logging.LOG_TAG, "Changes to " + account.mDisplayName + "/" +
930                            m.mDisplayName + ", count: " + messageCount + ", lastNotified: " +
931                            m.mLastNotifiedMessageKey + ", mostRecent: " + newMessageId);
932                    // Broadcast intent here
933                    Intent i = new Intent(EmailBroadcastProcessorService.ACTION_NOTIFY_NEW_MAIL);
934                    // Required by UIProvider
935                    i.setType(EmailProvider.EMAIL_APP_MIME_TYPE);
936                    i.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_FOLDER,
937                            Uri.parse(EmailProvider.uiUriString("uifolder", mailboxId)));
938                    i.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_ACCOUNT,
939                            Uri.parse(EmailProvider.uiUriString("uiaccount", m.mAccountKey)));
940                    i.putExtra(UIProvider.UpdateNotificationExtras.EXTRA_UPDATED_UNREAD_COUNT,
941                            unreadCount);
942                    // Required by our notification controller
943                    i.putExtra(NEW_MAIL_MAILBOX_ID, mailboxId);
944                    i.putExtra(NEW_MAIL_MESSAGE_ID, newMessageId);
945                    i.putExtra(NEW_MAIL_MESSAGE_COUNT, messageCount);
946                    i.putExtra(NEW_MAIL_UNREAD_COUNT, unreadCount);
947                    mContext.sendOrderedBroadcast(i, null);
948                }
949            } finally {
950                c.close();
951            }
952        }
953    }
954
955    public static void notifyNewMail(Context context, Intent i) {
956        Log.d(Logging.LOG_TAG, "Sending notification to system...");
957        NotificationController nc = NotificationController.getInstance(context);
958        ContentResolver resolver = context.getContentResolver();
959        long mailboxId = i.getLongExtra(NEW_MAIL_MAILBOX_ID, -1);
960        long newMessageId = i.getLongExtra(NEW_MAIL_MESSAGE_ID, -1);
961        int messageCount = i.getIntExtra(NEW_MAIL_MESSAGE_COUNT, 0);
962        int unreadCount = i.getIntExtra(NEW_MAIL_UNREAD_COUNT, 0);
963        Notification n = nc.createNewMessageNotification(mailboxId, newMessageId,
964                messageCount, unreadCount);
965        if (n != null) {
966            // Make the notification visible
967            nc.mNotificationManager.notify(nc.getNewMessageNotificationId(mailboxId), n);
968        }
969        // Save away the new values
970        ContentValues cv = new ContentValues();
971        cv.put(MailboxColumns.LAST_NOTIFIED_MESSAGE_KEY, newMessageId);
972        cv.put(MailboxColumns.LAST_NOTIFIED_MESSAGE_COUNT, messageCount);
973        resolver.update(ContentUris.withAppendedId(Mailbox.CONTENT_URI, mailboxId), cv,
974                null, null);
975    }
976
977    /**
978     * Observer invoked whenever an account is modified. This could mean the user changed the
979     * notification settings.
980     */
981    private static class AccountContentObserver extends ContentObserver {
982        private final Context mContext;
983        public AccountContentObserver(Handler handler, Context context) {
984            super(handler);
985            mContext = context;
986        }
987
988        @Override
989        public void onChange(boolean selfChange) {
990            final ContentResolver resolver = mContext.getContentResolver();
991            final Cursor c = resolver.query(Account.CONTENT_URI, EmailContent.ID_PROJECTION,
992                NOTIFIED_ACCOUNT_SELECTION, null, null);
993            final HashSet<Long> newAccountList = new HashSet<Long>();
994            final HashSet<Long> removedAccountList = new HashSet<Long>();
995            if (c == null) {
996                // Suspender time ... theoretically, this will never happen
997                Log.wtf(Logging.LOG_TAG, "#onChange(); NULL response for account id query");
998                return;
999            }
1000            try {
1001                while (c.moveToNext()) {
1002                    long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN);
1003                    newAccountList.add(accountId);
1004                }
1005            } finally {
1006                if (c != null) {
1007                    c.close();
1008                }
1009            }
1010            // NOTE: Looping over three lists is not necessarily the most efficient. However, the
1011            // account lists are going to be very small, so, this will not be necessarily bad.
1012            // Cycle through existing notification list and adjust as necessary
1013            for (long accountId : sInstance.mNotificationMap.keySet()) {
1014                if (!newAccountList.remove(accountId)) {
1015                    // account id not in the current set of notifiable accounts
1016                    removedAccountList.add(accountId);
1017                }
1018            }
1019            // A new account was added to the notification list
1020            for (long accountId : newAccountList) {
1021                sInstance.registerMessageNotification(accountId);
1022            }
1023            // An account was removed from the notification list
1024            for (long accountId : removedAccountList) {
1025                sInstance.unregisterMessageNotification(accountId);
1026                int notificationId = sInstance.getNewMessageNotificationId(accountId);
1027                sInstance.mNotificationManager.cancel(notificationId);
1028            }
1029        }
1030    }
1031
1032    /**
1033     * Thread to handle all notification actions through its own {@link Looper}.
1034     */
1035    private static class NotificationThread implements Runnable {
1036        /** Lock to ensure proper initialization */
1037        private final Object mLock = new Object();
1038        /** The {@link Looper} that handles messages for this thread */
1039        private Looper mLooper;
1040
1041        NotificationThread() {
1042            new Thread(null, this, "EmailNotification").start();
1043            synchronized (mLock) {
1044                while (mLooper == null) {
1045                    try {
1046                        mLock.wait();
1047                    } catch (InterruptedException ex) {
1048                    }
1049                }
1050            }
1051        }
1052
1053        @Override
1054        public void run() {
1055            synchronized (mLock) {
1056                Looper.prepare();
1057                mLooper = Looper.myLooper();
1058                mLock.notifyAll();
1059            }
1060            Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
1061            Looper.loop();
1062        }
1063        void quit() {
1064            mLooper.quit();
1065        }
1066        Looper getLooper() {
1067            return mLooper;
1068        }
1069    }
1070}
1071