NotificationUtils.java revision 05a18446a96fa8ea2ae077b482499abf67366e32
1/*
2 * Copyright (C) 2013 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 */
16package com.android.mail.utils;
17
18import android.app.Notification;
19import android.app.NotificationManager;
20import android.app.PendingIntent;
21import android.content.ContentResolver;
22import android.content.ContentUris;
23import android.content.ContentValues;
24import android.content.Context;
25import android.content.Intent;
26import android.content.res.Resources;
27import android.database.Cursor;
28import android.graphics.Bitmap;
29import android.graphics.BitmapFactory;
30import android.net.Uri;
31import android.preview.support.v4.app.NotificationManagerCompat;
32import android.preview.support.wearable.notifications.WearableNotifications;
33import android.provider.ContactsContract;
34import android.provider.ContactsContract.CommonDataKinds.Email;
35import android.provider.ContactsContract.Contacts.Photo;
36import android.support.v4.app.NotificationCompat;
37import android.support.v4.text.BidiFormatter;
38import android.support.v4.util.ArrayMap;
39import android.text.SpannableString;
40import android.text.SpannableStringBuilder;
41import android.text.TextUtils;
42import android.text.style.CharacterStyle;
43import android.text.style.TextAppearanceSpan;
44import android.util.Pair;
45import android.util.SparseArray;
46
47import com.android.emailcommon.mail.Address;
48import com.android.mail.EmailAddress;
49import com.android.mail.MailIntentService;
50import com.android.mail.R;
51import com.android.mail.analytics.Analytics;
52import com.android.mail.browse.MessageCursor;
53import com.android.mail.browse.SendersView;
54import com.android.mail.photomanager.LetterTileProvider;
55import com.android.mail.preferences.AccountPreferences;
56import com.android.mail.preferences.FolderPreferences;
57import com.android.mail.preferences.MailPrefs;
58import com.android.mail.providers.Account;
59import com.android.mail.providers.Conversation;
60import com.android.mail.providers.Folder;
61import com.android.mail.providers.Message;
62import com.android.mail.providers.UIProvider;
63import com.android.mail.ui.ImageCanvas.Dimensions;
64import com.android.mail.utils.NotificationActionUtils.NotificationAction;
65import com.google.android.mail.common.html.parser.HTML;
66import com.google.android.mail.common.html.parser.HTML4;
67import com.google.android.mail.common.html.parser.HtmlDocument;
68import com.google.android.mail.common.html.parser.HtmlTree;
69import com.google.common.base.Objects;
70import com.google.common.collect.ImmutableList;
71import com.google.common.collect.Lists;
72import com.google.common.collect.Sets;
73
74import java.io.ByteArrayInputStream;
75import java.util.ArrayList;
76import java.util.Arrays;
77import java.util.Collection;
78import java.util.HashMap;
79import java.util.HashSet;
80import java.util.List;
81import java.util.Map;
82import java.util.Set;
83import java.util.concurrent.ConcurrentHashMap;
84
85public class NotificationUtils {
86    public static final String LOG_TAG = "NotifUtils";
87
88    /** Contains a list of <(account, label), unread conversations> */
89    private static NotificationMap sActiveNotificationMap = null;
90
91    private static final SparseArray<Bitmap> sNotificationIcons = new SparseArray<Bitmap>();
92
93    private static TextAppearanceSpan sNotificationUnreadStyleSpan;
94    private static CharacterStyle sNotificationReadStyleSpan;
95
96    /** A factory that produces a plain text converter that removes elided text. */
97    private static final HtmlTree.PlainTextConverterFactory MESSAGE_CONVERTER_FACTORY =
98            new HtmlTree.PlainTextConverterFactory() {
99                @Override
100                public HtmlTree.PlainTextConverter createInstance() {
101                    return new MailMessagePlainTextConverter();
102                }
103            };
104
105    private static BidiFormatter sBidiFormatter = BidiFormatter.getInstance();
106
107    private static Map<NotificationKey, Set<Integer>> sChildNotificationsMap =
108            new HashMap<NotificationKey, Set<Integer>>();
109
110    /**
111     * Clears all notifications in response to the user tapping "Clear" in the status bar.
112     */
113    public static void clearAllNotfications(Context context) {
114        LogUtils.v(LOG_TAG, "Clearing all notifications.");
115        final NotificationMap notificationMap = getNotificationMap(context);
116        notificationMap.clear();
117        notificationMap.saveNotificationMap(context);
118    }
119
120    /**
121     * Returns the notification map, creating it if necessary.
122     */
123    private static synchronized NotificationMap getNotificationMap(Context context) {
124        if (sActiveNotificationMap == null) {
125            sActiveNotificationMap = new NotificationMap();
126
127            // populate the map from the cached data
128            sActiveNotificationMap.loadNotificationMap(context);
129        }
130        return sActiveNotificationMap;
131    }
132
133    /**
134     * Class representing the existing notifications, and the number of unread and
135     * unseen conversations that triggered each.
136     */
137    private static final class NotificationMap {
138
139        private static final String NOTIFICATION_PART_SEPARATOR = " ";
140        private static final int NUM_NOTIFICATION_PARTS= 4;
141        private final ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>> mMap =
142            new ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>>();
143
144        /**
145         * Returns the number of key values pairs in the inner map.
146         */
147        public int size() {
148            return mMap.size();
149        }
150
151        /**
152         * Returns a set of key values.
153         */
154        public Set<NotificationKey> keySet() {
155            return mMap.keySet();
156        }
157
158        /**
159         * Remove the key from the inner map and return its value.
160         *
161         * @param key The key {@link NotificationKey} to be removed.
162         * @return The value associated with this key.
163         */
164        public Pair<Integer, Integer> remove(NotificationKey key) {
165            return mMap.remove(key);
166        }
167
168        /**
169         * Clear all key-value pairs in the map.
170         */
171        public void clear() {
172            mMap.clear();
173        }
174
175        /**
176         * Discover if a key-value pair with this key exists.
177         *
178         * @param key The key {@link NotificationKey} to be checked.
179         * @return If a key-value pair with this key exists in the map.
180         */
181        public boolean containsKey(NotificationKey key) {
182            return mMap.containsKey(key);
183        }
184
185        /**
186         * Returns the unread count for the given NotificationKey.
187         */
188        public Integer getUnread(NotificationKey key) {
189            final Pair<Integer, Integer> value = mMap.get(key);
190            return value != null ? value.first : null;
191        }
192
193        /**
194         * Returns the unread unseen count for the given NotificationKey.
195         */
196        public Integer getUnseen(NotificationKey key) {
197            final Pair<Integer, Integer> value = mMap.get(key);
198            return value != null ? value.second : null;
199        }
200
201        /**
202         * Store the unread and unseen value for the given NotificationKey
203         */
204        public void put(NotificationKey key, int unread, int unseen) {
205            final Pair<Integer, Integer> value =
206                    new Pair<Integer, Integer>(Integer.valueOf(unread), Integer.valueOf(unseen));
207            mMap.put(key, value);
208        }
209
210        /**
211         * Populates the notification map with previously cached data.
212         */
213        public synchronized void loadNotificationMap(final Context context) {
214            final MailPrefs mailPrefs = MailPrefs.get(context);
215            final Set<String> notificationSet = mailPrefs.getActiveNotificationSet();
216            if (notificationSet != null) {
217                for (String notificationEntry : notificationSet) {
218                    // Get the parts of the string that make the notification entry
219                    final String[] notificationParts =
220                            TextUtils.split(notificationEntry, NOTIFICATION_PART_SEPARATOR);
221                    if (notificationParts.length == NUM_NOTIFICATION_PARTS) {
222                        final Uri accountUri = Uri.parse(notificationParts[0]);
223                        final Cursor accountCursor = context.getContentResolver().query(
224                                accountUri, UIProvider.ACCOUNTS_PROJECTION, null, null, null);
225                        final Account account;
226                        try {
227                            if (accountCursor.moveToFirst()) {
228                                account = new Account(accountCursor);
229                            } else {
230                                continue;
231                            }
232                        } finally {
233                            accountCursor.close();
234                        }
235
236                        final Uri folderUri = Uri.parse(notificationParts[1]);
237                        final Cursor folderCursor = context.getContentResolver().query(
238                                folderUri, UIProvider.FOLDERS_PROJECTION, null, null, null);
239                        final Folder folder;
240                        try {
241                            if (folderCursor.moveToFirst()) {
242                                folder = new Folder(folderCursor);
243                            } else {
244                                continue;
245                            }
246                        } finally {
247                            folderCursor.close();
248                        }
249
250                        final NotificationKey key = new NotificationKey(account, folder);
251                        final Integer unreadValue = Integer.valueOf(notificationParts[2]);
252                        final Integer unseenValue = Integer.valueOf(notificationParts[3]);
253                        put(key, unreadValue, unseenValue);
254                    }
255                }
256            }
257        }
258
259        /**
260         * Cache the notification map.
261         */
262        public synchronized void saveNotificationMap(Context context) {
263            final Set<String> notificationSet = Sets.newHashSet();
264            final Set<NotificationKey> keys = mMap.keySet();
265            for (NotificationKey key : keys) {
266                final Pair<Integer, Integer> value = mMap.get(key);
267                final Integer unreadCount = value.first;
268                final Integer unseenCount = value.second;
269                if (unreadCount != null && unseenCount != null) {
270                    final String[] partValues = new String[] {
271                            key.account.uri.toString(), key.folder.folderUri.fullUri.toString(),
272                            unreadCount.toString(), unseenCount.toString()};
273                    notificationSet.add(TextUtils.join(NOTIFICATION_PART_SEPARATOR, partValues));
274                }
275            }
276            final MailPrefs mailPrefs = MailPrefs.get(context);
277            mailPrefs.cacheActiveNotificationSet(notificationSet);
278        }
279    }
280
281    /**
282     * @return the title of this notification with each account and the number of unread and unseen
283     * conversations for it. Also remove any account in the map that has 0 unread.
284     */
285    private static String createNotificationString(NotificationMap notifications) {
286        StringBuilder result = new StringBuilder();
287        int i = 0;
288        Set<NotificationKey> keysToRemove = Sets.newHashSet();
289        for (NotificationKey key : notifications.keySet()) {
290            Integer unread = notifications.getUnread(key);
291            Integer unseen = notifications.getUnseen(key);
292            if (unread == null || unread.intValue() == 0) {
293                keysToRemove.add(key);
294            } else {
295                if (i > 0) result.append(", ");
296                result.append(key.toString() + " (" + unread + ", " + unseen + ")");
297                i++;
298            }
299        }
300
301        for (NotificationKey key : keysToRemove) {
302            notifications.remove(key);
303        }
304
305        return result.toString();
306    }
307
308    /**
309     * Get all notifications for all accounts and cancel them.
310     **/
311    public static void cancelAllNotifications(Context context) {
312        LogUtils.d(LOG_TAG, "cancelAllNotifications - cancelling all");
313        NotificationManagerCompat nm = NotificationManagerCompat.from(context);
314        nm.cancelAll();
315        clearAllNotfications(context);
316    }
317
318    /**
319     * Get all notifications for all accounts, cancel them, and repost.
320     * This happens when locale changes.
321     **/
322    public static void cancelAndResendNotificationsOnLocaleChange(Context context) {
323        LogUtils.d(LOG_TAG, "cancelAndResendNotificationsOnLocaleChange");
324        sBidiFormatter = BidiFormatter.getInstance();
325        resendNotifications(context, true, null, null);
326    }
327
328    /**
329     * Get all notifications for all accounts, optionally cancel them, and repost.
330     * This happens when locale changes. If you only want to resend messages from one
331     * account-folder pair, pass in the account and folder that should be resent.
332     * All other account-folder pairs will not have their notifications resent.
333     * All notifications will be resent if account or folder is null.
334     *
335     * @param context Current context.
336     * @param cancelExisting True, if all notifications should be canceled before resending.
337     *                       False, otherwise.
338     * @param accountUri The {@link Uri} of the {@link Account} of the notification
339     *                   upon which an action occurred.
340     * @param folderUri The {@link Uri} of the {@link Folder} of the notification
341     *                  upon which an action occurred.
342     */
343    public static void resendNotifications(Context context, final boolean cancelExisting,
344            final Uri accountUri, final FolderUri folderUri) {
345        LogUtils.d(LOG_TAG, "resendNotifications ");
346
347        if (cancelExisting) {
348            LogUtils.d(LOG_TAG, "resendNotifications - cancelling all");
349            NotificationManagerCompat nm = NotificationManagerCompat.from(context);
350            nm.cancelAll();
351        }
352        // Re-validate the notifications.
353        final NotificationMap notificationMap = getNotificationMap(context);
354        final Set<NotificationKey> keys = notificationMap.keySet();
355        for (NotificationKey notification : keys) {
356            final Folder folder = notification.folder;
357            final int notificationId =
358                    getNotificationId(notification.account.getAccountManagerAccount(), folder);
359
360            // Only resend notifications if the notifications are from the same folder
361            // and same account as the undo notification that was previously displayed.
362            if (accountUri != null && !Objects.equal(accountUri, notification.account.uri) &&
363                    folderUri != null && !Objects.equal(folderUri, folder.folderUri)) {
364                LogUtils.d(LOG_TAG, "resendNotifications - not resending %s / %s"
365                        + " because it doesn't match %s / %s",
366                        notification.account.uri, folder.folderUri, accountUri, folderUri);
367                continue;
368            }
369
370            LogUtils.d(LOG_TAG, "resendNotifications - resending %s / %s",
371                    notification.account.uri, folder.folderUri);
372
373            final NotificationAction undoableAction =
374                    NotificationActionUtils.sUndoNotifications.get(notificationId);
375            if (undoableAction == null) {
376                validateNotifications(context, folder, notification.account, true,
377                        false, notification);
378            } else {
379                // Create an undo notification
380                NotificationActionUtils.createUndoNotification(context, undoableAction);
381            }
382        }
383    }
384
385    /**
386     * Validate the notifications for the specified account.
387     */
388    public static void validateAccountNotifications(Context context, String account) {
389        LogUtils.d(LOG_TAG, "validateAccountNotifications - %s", account);
390
391        List<NotificationKey> notificationsToCancel = Lists.newArrayList();
392        // Iterate through the notification map to see if there are any entries that correspond to
393        // labels that are not in the sync set.
394        final NotificationMap notificationMap = getNotificationMap(context);
395        Set<NotificationKey> keys = notificationMap.keySet();
396        final AccountPreferences accountPreferences = new AccountPreferences(context, account);
397        final boolean enabled = accountPreferences.areNotificationsEnabled();
398        if (!enabled) {
399            // Cancel all notifications for this account
400            for (NotificationKey notification : keys) {
401                if (notification.account.getAccountManagerAccount().name.equals(account)) {
402                    notificationsToCancel.add(notification);
403                }
404            }
405        } else {
406            // Iterate through the notification map to see if there are any entries that
407            // correspond to labels that are not in the notification set.
408            for (NotificationKey notification : keys) {
409                if (notification.account.getAccountManagerAccount().name.equals(account)) {
410                    // If notification is not enabled for this label, remember this NotificationKey
411                    // to later cancel the notification, and remove the entry from the map
412                    final Folder folder = notification.folder;
413                    final boolean isInbox = folder.folderUri.equals(
414                            notification.account.settings.defaultInbox);
415                    final FolderPreferences folderPreferences = new FolderPreferences(
416                            context, notification.account.getEmailAddress(), folder, isInbox);
417
418                    if (!folderPreferences.areNotificationsEnabled()) {
419                        notificationsToCancel.add(notification);
420                    }
421                }
422            }
423        }
424
425        // Cancel & remove the invalid notifications.
426        if (notificationsToCancel.size() > 0) {
427            NotificationManager nm = (NotificationManager) context.getSystemService(
428                    Context.NOTIFICATION_SERVICE);
429            for (NotificationKey notification : notificationsToCancel) {
430                final Folder folder = notification.folder;
431                final int notificationId =
432                        getNotificationId(notification.account.getAccountManagerAccount(), folder);
433                LogUtils.d(LOG_TAG, "validateAccountNotifications - cancelling %s / %s",
434                        notification.account.getEmailAddress(), folder.persistentId);
435                nm.cancel(notificationId);
436                notificationMap.remove(notification);
437                NotificationActionUtils.sUndoNotifications.remove(notificationId);
438                NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
439            }
440            notificationMap.saveNotificationMap(context);
441        }
442    }
443
444    /**
445     * Display only one notification.
446     */
447    public static void setNewEmailIndicator(Context context, final int unreadCount,
448            final int unseenCount, final Account account, final Folder folder,
449            final boolean getAttention) {
450        LogUtils.d(LOG_TAG, "setNewEmailIndicator unreadCount = %d, unseenCount = %d, account = %s,"
451                + " folder = %s, getAttention = %b", unreadCount, unseenCount,
452                account.getEmailAddress(), folder.folderUri, getAttention);
453
454        boolean ignoreUnobtrusiveSetting = false;
455
456        final int notificationId = getNotificationId(account.getAccountManagerAccount(), folder);
457
458        // Update the notification map
459        final NotificationMap notificationMap = getNotificationMap(context);
460        final NotificationKey key = new NotificationKey(account, folder);
461        if (unreadCount == 0) {
462            LogUtils.d(LOG_TAG, "setNewEmailIndicator - cancelling %s / %s",
463                    account.getEmailAddress(), folder.persistentId);
464            notificationMap.remove(key);
465            ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE))
466                    .cancel(notificationId);
467        } else {
468            LogUtils.d(LOG_TAG, "setNewEmailIndicator - update count for: %s / %s " +
469                    "to: unread: %d unseen %d", account.getEmailAddress(), folder.persistentId,
470                    unreadCount, unseenCount);
471            if (!notificationMap.containsKey(key)) {
472                // This account previously didn't have any unread mail; ignore the "unobtrusive
473                // notifications" setting and play sound and/or vibrate the device even if a
474                // notification already exists (bug 2412348).
475                LogUtils.d(LOG_TAG, "setNewEmailIndicator - ignoringUnobtrusiveSetting");
476                ignoreUnobtrusiveSetting = true;
477            }
478            notificationMap.put(key, unreadCount, unseenCount);
479        }
480        notificationMap.saveNotificationMap(context);
481
482        if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
483            LogUtils.v(LOG_TAG, "New email: %s mapSize: %d getAttention: %b",
484                    createNotificationString(notificationMap), notificationMap.size(),
485                    getAttention);
486        }
487
488        if (NotificationActionUtils.sUndoNotifications.get(notificationId) == null) {
489            validateNotifications(context, folder, account, getAttention, ignoreUnobtrusiveSetting,
490                    key);
491        }
492    }
493
494    /**
495     * Validate the notifications notification.
496     */
497    private static void validateNotifications(Context context, final Folder folder,
498            final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting,
499            NotificationKey key) {
500
501        NotificationManagerCompat nm = NotificationManagerCompat.from(context);
502
503        final NotificationMap notificationMap = getNotificationMap(context);
504        if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
505            LogUtils.i(LOG_TAG, "Validating Notification: %s mapSize: %d "
506                    + "folder: %s getAttention: %b ignoreUnobtrusive: %b",
507                    createNotificationString(notificationMap),
508                    notificationMap.size(), folder.name, getAttention, ignoreUnobtrusiveSetting);
509        } else {
510            LogUtils.i(LOG_TAG, "Validating Notification, mapSize: %d "
511                    + "getAttention: %b ignoreUnobtrusive: %b", notificationMap.size(),
512                    getAttention, ignoreUnobtrusiveSetting);
513        }
514        // The number of unread messages for this account and label.
515        final Integer unread = notificationMap.getUnread(key);
516        final int unreadCount = unread != null ? unread.intValue() : 0;
517        final Integer unseen = notificationMap.getUnseen(key);
518        int unseenCount = unseen != null ? unseen.intValue() : 0;
519
520        Cursor cursor = null;
521
522        try {
523            final Uri.Builder uriBuilder = folder.conversationListUri.buildUpon();
524            uriBuilder.appendQueryParameter(
525                    UIProvider.SEEN_QUERY_PARAMETER, Boolean.FALSE.toString());
526            // Do not allow this quick check to disrupt any active network-enabled conversation
527            // cursor.
528            uriBuilder.appendQueryParameter(
529                    UIProvider.ConversationListQueryParameters.USE_NETWORK,
530                    Boolean.FALSE.toString());
531            cursor = context.getContentResolver().query(uriBuilder.build(),
532                    UIProvider.CONVERSATION_PROJECTION, null, null, null);
533            if (cursor == null) {
534                // This folder doesn't exist.
535                LogUtils.i(LOG_TAG,
536                        "The cursor is null, so the specified folder probably does not exist");
537                clearFolderNotification(context, account, folder, false);
538                return;
539            }
540            final int cursorUnseenCount = cursor.getCount();
541
542            // Make sure the unseen count matches the number of items in the cursor.  But, we don't
543            // want to overwrite a 0 unseen count that was specified in the intent
544            if (unseenCount != 0 && unseenCount != cursorUnseenCount) {
545                LogUtils.i(LOG_TAG,
546                        "Unseen count doesn't match cursor count.  unseen: %d cursor count: %d",
547                        unseenCount, cursorUnseenCount);
548                unseenCount = cursorUnseenCount;
549            }
550
551            // For the purpose of the notifications, the unseen count should be capped at the num of
552            // unread conversations.
553            if (unseenCount > unreadCount) {
554                unseenCount = unreadCount;
555            }
556
557            final int notificationId =
558                    getNotificationId(account.getAccountManagerAccount(), folder);
559
560            NotificationKey notificationKey = new NotificationKey(account, folder);
561
562            if (unseenCount == 0) {
563                LogUtils.i(LOG_TAG, "validateNotifications - cancelling account %s / folder %s",
564                        LogUtils.sanitizeName(LOG_TAG, account.getEmailAddress()),
565                        LogUtils.sanitizeName(LOG_TAG, folder.persistentId));
566                nm.cancel(notificationId);
567                cancelChildNotifications(notificationKey, nm);
568
569                return;
570            }
571
572            // We now have all we need to create the notification and the pending intent
573            PendingIntent clickIntent;
574
575            NotificationCompat.Builder notification = new NotificationCompat.Builder(context);
576            WearableNotifications.Builder wearableNotification =
577                    new WearableNotifications.Builder(notification);
578            Map<Integer, WearableNotifications.Builder> msgNotifications =
579                    new ArrayMap<Integer, WearableNotifications.Builder>();
580            notification.setSmallIcon(R.drawable.stat_notify_email);
581            notification.setTicker(account.getDisplayName());
582
583            final long when;
584
585            final long oldWhen =
586                    NotificationActionUtils.sNotificationTimestamps.get(notificationId);
587            if (oldWhen != 0) {
588                when = oldWhen;
589            } else {
590                when = System.currentTimeMillis();
591            }
592
593            notification.setWhen(when);
594
595            // The timestamp is now stored in the notification, so we can remove it from here
596            NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
597
598            // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a
599            // notification.  Also this intent gets fired when the user taps on a notification as
600            // the AutoCancel flag has been set
601            final Intent cancelNotificationIntent =
602                    new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
603            cancelNotificationIntent.setPackage(context.getPackageName());
604            cancelNotificationIntent.setData(Utils.appendVersionQueryParameter(context,
605                    folder.folderUri.fullUri));
606            cancelNotificationIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
607            cancelNotificationIntent.putExtra(Utils.EXTRA_FOLDER, folder);
608
609            notification.setDeleteIntent(PendingIntent.getService(
610                    context, notificationId, cancelNotificationIntent, 0));
611
612            // Ensure that the notification is cleared when the user selects it
613            notification.setAutoCancel(true);
614
615            boolean eventInfoConfigured = false;
616
617            final boolean isInbox = folder.folderUri.equals(account.settings.defaultInbox);
618            final FolderPreferences folderPreferences =
619                    new FolderPreferences(context, account.getEmailAddress(), folder, isInbox);
620
621            if (isInbox) {
622                final AccountPreferences accountPreferences =
623                        new AccountPreferences(context, account.getEmailAddress());
624                moveNotificationSetting(accountPreferences, folderPreferences);
625            }
626
627            if (!folderPreferences.areNotificationsEnabled()) {
628                LogUtils.i(LOG_TAG, "Notifications are disabled for this folder; not notifying");
629                // Don't notify
630                return;
631            }
632
633            if (unreadCount > 0) {
634                // How can I order this properly?
635                if (cursor.moveToNext()) {
636                    final Intent notificationIntent;
637
638                    // Launch directly to the conversation, if there is only 1 unseen conversation
639                    final boolean launchConversationMode = (unseenCount == 1);
640                    if (launchConversationMode) {
641                        notificationIntent = createViewConversationIntent(context, account, folder,
642                                cursor);
643                    } else {
644                        notificationIntent = createViewConversationIntent(context, account, folder,
645                                null);
646                    }
647
648                    Analytics.getInstance().sendEvent("notification_create",
649                            launchConversationMode ? "conversation" : "conversation_list",
650                            folder.getTypeDescription(), unseenCount);
651
652                    if (notificationIntent == null) {
653                        LogUtils.e(LOG_TAG, "Null intent when building notification");
654                        return;
655                    }
656
657                    clickIntent = createClickPendingIntent(context, notificationIntent);
658
659                    configureLatestEventInfoFromConversation(context, account, folderPreferences,
660                            notification, wearableNotification, msgNotifications, notificationId,
661                            cursor, clickIntent, notificationIntent, unreadCount, unseenCount,
662                            folder, when);
663                    eventInfoConfigured = true;
664                }
665            }
666
667            final boolean vibrate = folderPreferences.isNotificationVibrateEnabled();
668            final String ringtoneUri = folderPreferences.getNotificationRingtoneUri();
669            final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled();
670
671            if (!ignoreUnobtrusiveSetting && notifyOnce) {
672                // If the user has "unobtrusive notifications" enabled, only alert the first time
673                // new mail is received in this account.  This is the default behavior.  See
674                // bugs 2412348 and 2413490.
675                LogUtils.d(LOG_TAG, "Setting Alert Once");
676                notification.setOnlyAlertOnce(true);
677            }
678
679            LogUtils.i(LOG_TAG, "Account: %s vibrate: %s",
680                    LogUtils.sanitizeName(LOG_TAG, account.getEmailAddress()),
681                    Boolean.toString(folderPreferences.isNotificationVibrateEnabled()));
682
683            int defaults = 0;
684
685            // Check if any current child notifications exist previously.  Only notify if one of
686            // them is new.
687            boolean hasNewChildNotification;
688            Set<Integer> prevChildNotifications = sChildNotificationsMap.get(notificationKey);
689            if (prevChildNotifications != null) {
690                hasNewChildNotification = false;
691                for (Integer currentNotificationId : msgNotifications.keySet()) {
692                    if (!prevChildNotifications.contains(currentNotificationId)) {
693                        hasNewChildNotification = true;
694                        break;
695                    }
696                }
697            } else {
698                hasNewChildNotification = true;
699            }
700
701            LogUtils.d(LOG_TAG, "getAttention=%s,oldWhen=%s,hasNewChildNotification=%s",
702                    getAttention, oldWhen, hasNewChildNotification);
703
704            /*
705             * We do not want to notify if this is coming back from an Undo notification, hence the
706             * oldWhen check.
707             */
708            if (getAttention && oldWhen == 0 && hasNewChildNotification) {
709                final AccountPreferences accountPreferences =
710                        new AccountPreferences(context, account.getEmailAddress());
711                if (accountPreferences.areNotificationsEnabled()) {
712                    if (vibrate) {
713                        defaults |= Notification.DEFAULT_VIBRATE;
714                    }
715
716                    notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null
717                            : Uri.parse(ringtoneUri));
718                    LogUtils.i(LOG_TAG, "New email in %s vibrateWhen: %s, playing notification: %s",
719                            LogUtils.sanitizeName(LOG_TAG, account.getEmailAddress()), vibrate,
720                            ringtoneUri);
721                }
722            }
723
724            // TODO(skennedy) Why do we do any of the above if we're just going to bail here?
725            if (eventInfoConfigured) {
726                defaults |= Notification.DEFAULT_LIGHTS;
727                notification.setDefaults(defaults);
728
729                if (oldWhen != 0) {
730                    // We do not want to display the ticker again if we are re-displaying this
731                    // notification (like from an Undo notification)
732                    notification.setTicker(null);
733                }
734
735                nm.notify(notificationId, wearableNotification.build());
736
737                if (prevChildNotifications != null) {
738                    Set<Integer> currentNotificationIds = msgNotifications.keySet();
739                    for (Integer prevChildNotificationId : prevChildNotifications) {
740                        if (!currentNotificationIds.contains(prevChildNotificationId)) {
741                            nm.cancel(prevChildNotificationId);
742                            LogUtils.d(LOG_TAG, "canceling child notification %s",
743                                    prevChildNotificationId);
744                        }
745                    }
746                }
747
748                for (Map.Entry<Integer, WearableNotifications.Builder> entry
749                        : msgNotifications.entrySet()) {
750                    nm.notify(entry.getKey(), entry.getValue().build());
751                    LogUtils.d(LOG_TAG, "notifying child notification %s", entry.getKey());
752                }
753
754                Set<Integer> childNotificationIds = new HashSet<Integer>();
755                childNotificationIds.addAll(msgNotifications.keySet());
756                sChildNotificationsMap.put(notificationKey, childNotificationIds);
757            } else {
758                LogUtils.i(LOG_TAG, "event info not configured - not notifying");
759            }
760        } finally {
761            if (cursor != null) {
762                cursor.close();
763            }
764        }
765    }
766
767    private static PendingIntent createClickPendingIntent(Context context,
768            Intent notificationIntent) {
769        // Amend the click intent with a hint that its source was a notification,
770        // but remove the hint before it's used to generate notification action
771        // intents. This prevents the following sequence:
772        // 1. generate single notification
773        // 2. user clicks reply, then completes Compose activity
774        // 3. main activity launches, gets FROM_NOTIFICATION hint in intent
775        notificationIntent.putExtra(Utils.EXTRA_FROM_NOTIFICATION, true);
776        PendingIntent clickIntent = PendingIntent.getActivity(context, -1, notificationIntent,
777                PendingIntent.FLAG_UPDATE_CURRENT);
778        notificationIntent.removeExtra(Utils.EXTRA_FROM_NOTIFICATION);
779        return clickIntent;
780    }
781
782    /**
783     * @return an {@link Intent} which, if launched, will display the corresponding conversation
784     */
785    private static Intent createViewConversationIntent(final Context context, final Account account,
786            final Folder folder, final Cursor cursor) {
787        if (folder == null || account == null) {
788            LogUtils.e(LOG_TAG, "createViewConversationIntent(): "
789                    + "Null account or folder.  account: %s folder: %s", account, folder);
790            return null;
791        }
792
793        final Intent intent;
794
795        if (cursor == null) {
796            intent = Utils.createViewFolderIntent(context, folder.folderUri.fullUri, account);
797        } else {
798            // A conversation cursor has been specified, so this intent is intended to be go
799            // directly to the one new conversation
800
801            // Get the Conversation object
802            final Conversation conversation = new Conversation(cursor);
803            intent = Utils.createViewConversationIntent(context, conversation,
804                    folder.folderUri.fullUri, account);
805        }
806
807        return intent;
808    }
809
810    private static Bitmap getDefaultNotificationIcon(
811            final Context context, final Folder folder, final boolean multipleNew) {
812        final int resId;
813        if (folder.notificationIconResId != 0) {
814            resId = folder.notificationIconResId;
815        } else if (multipleNew) {
816            resId = R.drawable.ic_notification_multiple_mail_holo_dark;
817        } else {
818            resId = R.drawable.ic_contact_picture;
819        }
820
821        final Bitmap icon = getIcon(context, resId);
822
823        if (icon == null) {
824            LogUtils.e(LOG_TAG, "Couldn't decode notif icon res id %d", resId);
825        }
826
827        return icon;
828    }
829
830    private static Bitmap getIcon(final Context context, final int resId) {
831        final Bitmap cachedIcon = sNotificationIcons.get(resId);
832        if (cachedIcon != null) {
833            return cachedIcon;
834        }
835
836        final Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resId);
837        sNotificationIcons.put(resId, icon);
838
839        return icon;
840    }
841
842    private static void configureLatestEventInfoFromConversation(final Context context,
843            final Account account, final FolderPreferences folderPreferences,
844            final NotificationCompat.Builder notification,
845            final WearableNotifications.Builder summaryWearNotif,
846            final Map<Integer, WearableNotifications.Builder> msgNotifications,
847            final int summaryNotificationId, final Cursor conversationCursor,
848            final PendingIntent clickIntent, final Intent notificationIntent,
849            final int unreadCount, final int unseenCount,
850            final Folder folder, final long when) {
851        final Resources res = context.getResources();
852        final String notificationAccountDisplayName = account.getDisplayName();
853        final String notificationAccountEmail = account.getEmailAddress();
854
855        LogUtils.i(LOG_TAG, "Showing notification with unreadCount of %d and unseenCount of %d",
856                unreadCount, unseenCount);
857
858        String notificationTicker = null;
859
860        // Boolean indicating that this notification is for a non-inbox label.
861        final boolean isInbox = folder.folderUri.fullUri.equals(account.settings.defaultInbox);
862
863        // Notification label name for user label notifications.
864        final String notificationLabelName = isInbox ? null : folder.name;
865
866        if (unseenCount > 1) {
867            // Build the string that describes the number of new messages
868            final String newMessagesString = res.getString(R.string.new_messages, unseenCount);
869
870            // Use the default notification icon
871            notification.setLargeIcon(
872                    getDefaultNotificationIcon(context, folder, true /* multiple new messages */));
873
874            // The ticker initially start as the new messages string.
875            notificationTicker = newMessagesString;
876
877            // The title of the notification is the new messages string
878            notification.setContentTitle(newMessagesString);
879
880            // TODO(skennedy) Can we remove this check?
881            if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) {
882                // For a new-style notification
883                final int maxNumDigestItems = context.getResources().getInteger(
884                        R.integer.max_num_notification_digest_items);
885
886                // The body of the notification is the account name, or the label name.
887                notification.setSubText(
888                        isInbox ? notificationAccountDisplayName : notificationLabelName);
889
890                final NotificationCompat.InboxStyle digest =
891                        new NotificationCompat.InboxStyle(notification);
892
893                // Group by account.
894                String notificationGroupKey =
895                        account.uri.toString() + "/" + folder.folderUri.fullUri;
896                summaryWearNotif.setGroup(notificationGroupKey,
897                        WearableNotifications.GROUP_ORDER_SUMMARY);
898
899                int numDigestItems = 0;
900                do {
901                    final Conversation conversation = new Conversation(conversationCursor);
902
903                    if (!conversation.read) {
904                        boolean multipleUnreadThread = false;
905                        // TODO(cwren) extract this pattern into a helper
906
907                        Cursor cursor = null;
908                        MessageCursor messageCursor = null;
909                        try {
910                            final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon();
911                            uriBuilder.appendQueryParameter(
912                                    UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName);
913                            cursor = context.getContentResolver().query(uriBuilder.build(),
914                                    UIProvider.MESSAGE_PROJECTION, null, null, null);
915                            messageCursor = new MessageCursor(cursor);
916
917                            String from = "";
918                            String fromAddress = "";
919                            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
920                                final Message message = messageCursor.getMessage();
921                                fromAddress = message.getFrom();
922                                if (fromAddress == null) {
923                                    fromAddress = "";
924                                }
925                                from = getDisplayableSender(fromAddress);
926                            }
927                            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
928                                final Message message = messageCursor.getMessage();
929                                if (!message.read &&
930                                        !fromAddress.contentEquals(message.getFrom())) {
931                                    multipleUnreadThread = true;
932                                    break;
933                                }
934                            }
935                            final SpannableStringBuilder sendersBuilder;
936                            if (multipleUnreadThread) {
937                                final int sendersLength =
938                                        res.getInteger(R.integer.swipe_senders_length);
939
940                                sendersBuilder = getStyledSenders(context, conversationCursor,
941                                        sendersLength, notificationAccountEmail);
942                            } else {
943                                sendersBuilder =
944                                        new SpannableStringBuilder(getWrappedFromString(from));
945                            }
946                            final CharSequence digestLine = getSingleMessageInboxLine(context,
947                                    sendersBuilder.toString(),
948                                    conversation.subject,
949                                    conversation.getSnippet());
950                            digest.addLine(digestLine);
951                            numDigestItems++;
952
953                            // Adding child notification for Wear.
954                            NotificationCompat.Builder childNotif =
955                                    new NotificationCompat.Builder(context);
956                            childNotif.setSmallIcon(R.drawable.stat_notify_email);
957                            childNotif.setContentText(digestLine);
958                            Intent childNotificationIntent = createViewConversationIntent(context,
959                                    account, folder, conversationCursor);
960                            PendingIntent childClickIntent = createClickPendingIntent(context,
961                                    childNotificationIntent);
962                            childNotif.setContentIntent(childClickIntent);
963
964                            WearableNotifications.Builder childWearNotif =
965                                    new WearableNotifications.Builder(childNotif).setGroup(
966                                            notificationGroupKey, numDigestItems);
967                            int childNotificationId = getNotificationId(summaryNotificationId,
968                                    conversation.hashCode());
969
970                            configureNotifForOneConversation(context, account, folderPreferences,
971                                    childNotif, childWearNotif, conversationCursor,
972                                    notificationIntent, folder, when, res,
973                                    notificationAccountDisplayName, notificationAccountEmail,
974                                    isInbox, notificationLabelName, childNotificationId);
975                            msgNotifications.put(childNotificationId, childWearNotif);
976                        } finally {
977                            if (messageCursor != null) {
978                                messageCursor.close();
979                            }
980                            if (cursor != null) {
981                                cursor.close();
982                            }
983                        }
984                    }
985                } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
986            } else {
987                // The body of the notification is the account name, or the label name.
988                notification.setContentText(
989                        isInbox ? notificationAccountDisplayName : notificationLabelName);
990            }
991        } else {
992            // For notifications for a single new conversation, we want to get the information
993            // from the conversation
994
995            // Move the cursor to the most recent unread conversation
996            seekToLatestUnreadConversation(conversationCursor);
997
998            notificationTicker = configureNotifForOneConversation(context, account,
999                    folderPreferences, notification, summaryWearNotif, conversationCursor,
1000                    notificationIntent, folder, when, res, notificationAccountDisplayName,
1001                    notificationAccountEmail, isInbox, notificationLabelName,
1002                    summaryNotificationId);
1003        }
1004
1005        // Build the notification ticker
1006        if (notificationLabelName != null && notificationTicker != null) {
1007            // This is a per label notification, format the ticker with that information
1008            notificationTicker = res.getString(R.string.label_notification_ticker,
1009                    notificationLabelName, notificationTicker);
1010        }
1011
1012        if (notificationTicker != null) {
1013            // If we didn't generate a notification ticker, it will default to account name
1014            notification.setTicker(notificationTicker);
1015        }
1016
1017        // Set the number in the notification
1018        if (unreadCount > 1) {
1019            notification.setNumber(unreadCount);
1020        }
1021
1022        notification.setContentIntent(clickIntent);
1023    }
1024
1025    /**
1026     * Configure the notification for one conversation.  When there are multiple conversations,
1027     * this method is used to configure bundled notification for Android Wear.
1028     */
1029    private static String configureNotifForOneConversation(Context context, Account account,
1030            FolderPreferences folderPreferences, NotificationCompat.Builder notification,
1031            WearableNotifications.Builder summaryWearNotif, Cursor conversationCursor,
1032            Intent notificationIntent, Folder folder, long when, Resources res,
1033            String notificationAccountDisplayName, String notificationAccountEmail, boolean isInbox,
1034            String notificationLabelName, int notificationId) {
1035
1036        String notificationTicker;
1037
1038        final Conversation conversation = new Conversation(conversationCursor);
1039
1040        Cursor cursor = null;
1041        MessageCursor messageCursor = null;
1042        boolean multipleUnseenThread = false;
1043        String from = null;
1044        try {
1045            final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
1046                    UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
1047            cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
1048                    null, null, null);
1049            messageCursor = new MessageCursor(cursor);
1050            // Use the information from the last sender in the conversation that triggered
1051            // this notification.
1052
1053            String fromAddress = "";
1054            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
1055                final Message message = messageCursor.getMessage();
1056                fromAddress = message.getFrom();
1057                from = getDisplayableSender(fromAddress);
1058                notification.setLargeIcon(
1059                        getContactIcon(context, from, getSenderAddress(fromAddress), folder));
1060            }
1061
1062            // Assume that the last message in this conversation is unread
1063            int firstUnseenMessagePos = messageCursor.getPosition();
1064            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
1065                final Message message = messageCursor.getMessage();
1066                final boolean unseen = !message.seen;
1067                if (unseen) {
1068                    firstUnseenMessagePos = messageCursor.getPosition();
1069                    if (!multipleUnseenThread
1070                            && !fromAddress.contentEquals(message.getFrom())) {
1071                        multipleUnseenThread = true;
1072                    }
1073                }
1074            }
1075
1076            // TODO(skennedy) Can we remove this check?
1077            if (Utils.isRunningJellybeanOrLater()) {
1078                // For a new-style notification
1079
1080                if (multipleUnseenThread) {
1081                    // The title of a single conversation is the list of senders.
1082                    int sendersLength = res.getInteger(R.integer.swipe_senders_length);
1083
1084                    final SpannableStringBuilder sendersBuilder = getStyledSenders(
1085                            context, conversationCursor, sendersLength,
1086                            notificationAccountEmail);
1087
1088                    notification.setContentTitle(sendersBuilder);
1089                    // For a single new conversation, the ticker is based on the sender's name.
1090                    notificationTicker = sendersBuilder.toString();
1091                } else {
1092                    from = getWrappedFromString(from);
1093                    // The title of a single message the sender.
1094                    notification.setContentTitle(from);
1095                    // For a single new conversation, the ticker is based on the sender's name.
1096                    notificationTicker = from;
1097                }
1098
1099                // The notification content will be the subject of the conversation.
1100                notification.setContentText(
1101                        getSingleMessageLittleText(context, conversation.subject));
1102
1103                // The notification subtext will be the subject of the conversation for inbox
1104                // notifications, or will based on the the label name for user label
1105                // notifications.
1106                notification.setSubText(isInbox ?
1107                        notificationAccountDisplayName : notificationLabelName);
1108
1109                if (multipleUnseenThread) {
1110                    notification.setLargeIcon(
1111                            getDefaultNotificationIcon(context, folder, true));
1112                }
1113                final NotificationCompat.BigTextStyle bigText =
1114                        new NotificationCompat.BigTextStyle(notification);
1115
1116                // Seek the message cursor to the first unread message
1117                final Message message;
1118                if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
1119                    message = messageCursor.getMessage();
1120                    bigText.bigText(getSingleMessageBigText(context,
1121                            conversation.subject, message));
1122                } else {
1123                    LogUtils.e(LOG_TAG, "Failed to load message");
1124                    message = null;
1125                }
1126
1127                if (message != null) {
1128                    final Set<String> notificationActions =
1129                            folderPreferences.getNotificationActions(account);
1130
1131                    NotificationActionUtils.addNotificationActions(context, notificationIntent,
1132                            notification, summaryWearNotif, account, conversation, message,
1133                            folder, notificationId, when, notificationActions);
1134                }
1135            } else {
1136                // For an old-style notification
1137
1138                // The title of a single conversation notification is built from both the sender
1139                // and subject of the new message.
1140                notification.setContentTitle(getSingleMessageNotificationTitle(context,
1141                        from, conversation.subject));
1142
1143                // The notification content will be the subject of the conversation for inbox
1144                // notifications, or will based on the the label name for user label
1145                // notifications.
1146                notification.setContentText(
1147                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1148
1149                // For a single new conversation, the ticker is based on the sender's name.
1150                notificationTicker = from;
1151            }
1152        } finally {
1153            if (messageCursor != null) {
1154                messageCursor.close();
1155            }
1156            if (cursor != null) {
1157                cursor.close();
1158            }
1159        }
1160        return notificationTicker;
1161    }
1162
1163    private static String getWrappedFromString(String from) {
1164        if (from == null) {
1165            LogUtils.e(LOG_TAG, "null from string in getWrappedFromString");
1166            from = "";
1167        }
1168        from = sBidiFormatter.unicodeWrap(from);
1169        return from;
1170    }
1171
1172    private static SpannableStringBuilder getStyledSenders(final Context context,
1173            final Cursor conversationCursor, final int maxLength, final String account) {
1174        final Conversation conversation = new Conversation(conversationCursor);
1175        final com.android.mail.providers.ConversationInfo conversationInfo =
1176                conversation.conversationInfo;
1177        final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
1178        if (sNotificationUnreadStyleSpan == null) {
1179            sNotificationUnreadStyleSpan = new TextAppearanceSpan(
1180                    context, R.style.NotificationSendersUnreadTextAppearance);
1181            sNotificationReadStyleSpan =
1182                    new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
1183        }
1184        SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
1185                sNotificationUnreadStyleSpan, sNotificationReadStyleSpan,
1186                false /* showToHeader */, false /* resourceCachingRequired */);
1187
1188        return ellipsizeStyledSenders(context, senders);
1189    }
1190
1191    private static String sSendersSplitToken = null;
1192    private static String sElidedPaddingToken = null;
1193
1194    private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
1195            ArrayList<SpannableString> styledSenders) {
1196        if (sSendersSplitToken == null) {
1197            sSendersSplitToken = context.getString(R.string.senders_split_token);
1198            sElidedPaddingToken = context.getString(R.string.elided_padding_token);
1199        }
1200
1201        SpannableStringBuilder builder = new SpannableStringBuilder();
1202        SpannableString prevSender = null;
1203        for (SpannableString sender : styledSenders) {
1204            if (sender == null) {
1205                LogUtils.e(LOG_TAG, "null sender iterating over styledSenders");
1206                continue;
1207            }
1208            CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
1209            if (SendersView.sElidedString.equals(sender.toString())) {
1210                prevSender = sender;
1211                sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
1212            } else if (builder.length() > 0
1213                    && (prevSender == null || !SendersView.sElidedString.equals(prevSender
1214                            .toString()))) {
1215                prevSender = sender;
1216                sender = copyStyles(spans, sSendersSplitToken + sender);
1217            } else {
1218                prevSender = sender;
1219            }
1220            builder.append(sender);
1221        }
1222        return builder;
1223    }
1224
1225    private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
1226        SpannableString s = new SpannableString(newText);
1227        if (spans != null && spans.length > 0) {
1228            s.setSpan(spans[0], 0, s.length(), 0);
1229        }
1230        return s;
1231    }
1232
1233    /**
1234     * Seeks the cursor to the position of the most recent unread conversation. If no unread
1235     * conversation is found, the position of the cursor will be restored, and false will be
1236     * returned.
1237     */
1238    private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
1239        final int initialPosition = cursor.getPosition();
1240        do {
1241            final Conversation conversation = new Conversation(cursor);
1242            if (!conversation.read) {
1243                return true;
1244            }
1245        } while (cursor.moveToNext());
1246
1247        // Didn't find an unread conversation, reset the position.
1248        cursor.moveToPosition(initialPosition);
1249        return false;
1250    }
1251
1252    /**
1253     * Sets the bigtext for a notification for a single new conversation
1254     *
1255     * @param context
1256     * @param senders Sender of the new message that triggered the notification.
1257     * @param subject Subject of the new message that triggered the notification
1258     * @param snippet Snippet of the new message that triggered the notification
1259     * @return a {@link CharSequence} suitable for use in
1260     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1261     */
1262    private static CharSequence getSingleMessageInboxLine(Context context,
1263            String senders, String subject, String snippet) {
1264        // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1265
1266        final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1267
1268        final TextAppearanceSpan notificationPrimarySpan =
1269                new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1270
1271        if (TextUtils.isEmpty(senders)) {
1272            // If the senders are empty, just use the subject/snippet.
1273            return subjectSnippet;
1274        } else if (TextUtils.isEmpty(subjectSnippet)) {
1275            // If the subject/snippet is empty, just use the senders.
1276            final SpannableString spannableString = new SpannableString(senders);
1277            spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1278
1279            return spannableString;
1280        } else {
1281            final String formatString = context.getResources().getString(
1282                    R.string.multiple_new_message_notification_item);
1283            final TextAppearanceSpan notificationSecondarySpan =
1284                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1285
1286            // senders is already individually unicode wrapped so it does not need to be done here
1287            final String instantiatedString = String.format(formatString,
1288                    senders,
1289                    sBidiFormatter.unicodeWrap(subjectSnippet));
1290
1291            final SpannableString spannableString = new SpannableString(instantiatedString);
1292
1293            final boolean isOrderReversed = formatString.indexOf("%2$s") <
1294                    formatString.indexOf("%1$s");
1295            final int primaryOffset =
1296                    (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1297                     instantiatedString.indexOf(senders));
1298            final int secondaryOffset =
1299                    (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1300                     instantiatedString.indexOf(subjectSnippet));
1301            spannableString.setSpan(notificationPrimarySpan,
1302                    primaryOffset, primaryOffset + senders.length(), 0);
1303            spannableString.setSpan(notificationSecondarySpan,
1304                    secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1305            return spannableString;
1306        }
1307    }
1308
1309    /**
1310     * Sets the bigtext for a notification for a single new conversation
1311     * @param context
1312     * @param subject Subject of the new message that triggered the notification
1313     * @return a {@link CharSequence} suitable for use in
1314     * {@link NotificationCompat.Builder#setContentText}
1315     */
1316    private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1317        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1318                context, R.style.NotificationPrimaryText);
1319
1320        final SpannableString spannableString = new SpannableString(subject);
1321        spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1322
1323        return spannableString;
1324    }
1325
1326    /**
1327     * Sets the bigtext for a notification for a single new conversation
1328     *
1329     * @param context
1330     * @param subject Subject of the new message that triggered the notification
1331     * @param message the {@link Message} to be displayed.
1332     * @return a {@link CharSequence} suitable for use in
1333     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1334     */
1335    private static CharSequence getSingleMessageBigText(Context context, String subject,
1336            final Message message) {
1337
1338        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1339                context, R.style.NotificationPrimaryText);
1340
1341        final String snippet = getMessageBodyWithoutElidedText(message);
1342
1343        // Change multiple newlines (with potential white space between), into a single new line
1344        final String collapsedSnippet =
1345                !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1346
1347        if (TextUtils.isEmpty(subject)) {
1348            // If the subject is empty, just use the snippet.
1349            return snippet;
1350        } else if (TextUtils.isEmpty(collapsedSnippet)) {
1351            // If the snippet is empty, just use the subject.
1352            final SpannableString spannableString = new SpannableString(subject);
1353            spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1354
1355            return spannableString;
1356        } else {
1357            final String notificationBigTextFormat = context.getResources().getString(
1358                    R.string.single_new_message_notification_big_text);
1359
1360            // Localizers may change the order of the parameters, look at how the format
1361            // string is structured.
1362            final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1363                    notificationBigTextFormat.indexOf("%1$s");
1364            final String bigText =
1365                    String.format(notificationBigTextFormat, subject, collapsedSnippet);
1366            final SpannableString spannableString = new SpannableString(bigText);
1367
1368            final int subjectOffset =
1369                    (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1370            spannableString.setSpan(notificationSubjectSpan,
1371                    subjectOffset, subjectOffset + subject.length(), 0);
1372
1373            return spannableString;
1374        }
1375    }
1376
1377    /**
1378     * Gets the title for a notification for a single new conversation
1379     * @param context
1380     * @param sender Sender of the new message that triggered the notification.
1381     * @param subject Subject of the new message that triggered the notification
1382     * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1383     */
1384    private static CharSequence getSingleMessageNotificationTitle(Context context,
1385            String sender, String subject) {
1386
1387        if (TextUtils.isEmpty(subject)) {
1388            // If the subject is empty, just set the title to the sender's information.
1389            return sender;
1390        } else {
1391            final String notificationTitleFormat = context.getResources().getString(
1392                    R.string.single_new_message_notification_title);
1393
1394            // Localizers may change the order of the parameters, look at how the format
1395            // string is structured.
1396            final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1397                    notificationTitleFormat.indexOf("%1$s");
1398            final String titleString = String.format(notificationTitleFormat, sender, subject);
1399
1400            // Format the string so the subject is using the secondaryText style
1401            final SpannableString titleSpannable = new SpannableString(titleString);
1402
1403            // Find the offset of the subject.
1404            final int subjectOffset =
1405                    isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1406            final TextAppearanceSpan notificationSubjectSpan =
1407                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1408            titleSpannable.setSpan(notificationSubjectSpan,
1409                    subjectOffset, subjectOffset + subject.length(), 0);
1410            return titleSpannable;
1411        }
1412    }
1413
1414    /**
1415     * Clears the notifications for the specified account/folder.
1416     */
1417    public static void clearFolderNotification(Context context, Account account, Folder folder,
1418            final boolean markSeen) {
1419        LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.getEmailAddress(),
1420                folder.name);
1421        final NotificationMap notificationMap = getNotificationMap(context);
1422        final NotificationKey key = new NotificationKey(account, folder);
1423        notificationMap.remove(key);
1424        notificationMap.saveNotificationMap(context);
1425
1426        final NotificationManagerCompat notificationManager =
1427                NotificationManagerCompat.from(context);
1428        notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder));
1429
1430        cancelChildNotifications(key, notificationManager);
1431
1432        if (markSeen) {
1433            markSeen(context, folder);
1434        }
1435    }
1436
1437    /**
1438     * Clears all notifications for the specified account.
1439     */
1440    public static void clearAccountNotifications(final Context context,
1441            final android.accounts.Account account) {
1442        LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account);
1443        final NotificationMap notificationMap = getNotificationMap(context);
1444
1445        // Find all NotificationKeys for this account
1446        final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder();
1447
1448        for (final NotificationKey key : notificationMap.keySet()) {
1449            if (account.equals(key.account.getAccountManagerAccount())) {
1450                keyBuilder.add(key);
1451            }
1452        }
1453
1454        final List<NotificationKey> notificationKeys = keyBuilder.build();
1455
1456        final NotificationManagerCompat notificationManager =
1457                NotificationManagerCompat.from(context);
1458
1459        for (final NotificationKey notificationKey : notificationKeys) {
1460            final Folder folder = notificationKey.folder;
1461            notificationManager.cancel(getNotificationId(account, folder));
1462            notificationMap.remove(notificationKey);
1463
1464            cancelChildNotifications(notificationKey, notificationManager);
1465        }
1466
1467        notificationMap.saveNotificationMap(context);
1468    }
1469
1470    private static void cancelChildNotifications(NotificationKey key,
1471            NotificationManagerCompat nm) {
1472        Set<Integer> childNotifications = sChildNotificationsMap.get(key);
1473        if (childNotifications != null) {
1474            for (Integer childNotification : childNotifications) {
1475                nm.cancel(childNotification);
1476            }
1477            sChildNotificationsMap.remove(key);
1478        }
1479    }
1480
1481    private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1482        ArrayList<String> whereArgs = new ArrayList<String>();
1483        StringBuilder whereBuilder = new StringBuilder();
1484        String[] questionMarks = new String[addresses.size()];
1485
1486        whereArgs.addAll(addresses);
1487        Arrays.fill(questionMarks, "?");
1488        whereBuilder.append(Email.DATA1 + " IN (").
1489                append(TextUtils.join(",", questionMarks)).
1490                append(")");
1491
1492        ContentResolver resolver = context.getContentResolver();
1493        Cursor c = resolver.query(Email.CONTENT_URI,
1494                new String[]{Email.CONTACT_ID}, whereBuilder.toString(),
1495                whereArgs.toArray(new String[0]), null);
1496
1497        ArrayList<Long> contactIds = new ArrayList<Long>();
1498        if (c == null) {
1499            return contactIds;
1500        }
1501        try {
1502            while (c.moveToNext()) {
1503                contactIds.add(c.getLong(0));
1504            }
1505        } finally {
1506            c.close();
1507        }
1508        return contactIds;
1509    }
1510
1511    private static Bitmap getContactIcon(final Context context, final String displayName,
1512            final String senderAddress, final Folder folder) {
1513        if (senderAddress == null) {
1514            return null;
1515        }
1516
1517        Bitmap icon = null;
1518
1519        final List<Long> contactIds = findContacts( context, Arrays.asList(
1520                new String[] { senderAddress }));
1521
1522        // Get the ideal size for this icon.
1523        final Resources res = context.getResources();
1524        final int idealIconHeight =
1525                res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1526        final int idealIconWidth =
1527                res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1528
1529        if (contactIds != null) {
1530            for (final long id : contactIds) {
1531                final Uri contactUri =
1532                        ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id);
1533                final Uri photoUri = Uri.withAppendedPath(contactUri, Photo.CONTENT_DIRECTORY);
1534                final Cursor cursor = context.getContentResolver().query(
1535                        photoUri, new String[] { Photo.PHOTO }, null, null, null);
1536
1537                if (cursor != null) {
1538                    try {
1539                        if (cursor.moveToFirst()) {
1540                            final byte[] data = cursor.getBlob(0);
1541                            if (data != null) {
1542                                icon = BitmapFactory.decodeStream(new ByteArrayInputStream(data));
1543                                if (icon != null && icon.getHeight() < idealIconHeight) {
1544                                    // We should scale this image to fit the intended size
1545                                    icon = Bitmap.createScaledBitmap(
1546                                            icon, idealIconWidth, idealIconHeight, true);
1547                                }
1548                                if (icon != null) {
1549                                    break;
1550                                }
1551                            }
1552                        }
1553                    } finally {
1554                        cursor.close();
1555                    }
1556                }
1557            }
1558        }
1559
1560        if (icon == null) {
1561            // Make a colorful tile!
1562            final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight,
1563                    Dimensions.SCALE_ONE);
1564
1565            icon = new LetterTileProvider(context).getLetterTile(dimensions,
1566                    displayName, senderAddress);
1567        }
1568
1569        if (icon == null) {
1570            // Icon should be the default mail icon.
1571            icon = getDefaultNotificationIcon(context, folder, false /* single new message */);
1572        }
1573        return icon;
1574    }
1575
1576    private static String getMessageBodyWithoutElidedText(final Message message) {
1577        return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
1578    }
1579
1580    public static String getMessageBodyWithoutElidedText(String html) {
1581        if (TextUtils.isEmpty(html)) {
1582            return "";
1583        }
1584        // Get the html "tree" for this message body
1585        final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1586        htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY);
1587
1588        return htmlTree.getPlainText();
1589    }
1590
1591    public static void markSeen(final Context context, final Folder folder) {
1592        final Uri uri = folder.folderUri.fullUri;
1593
1594        final ContentValues values = new ContentValues(1);
1595        values.put(UIProvider.ConversationColumns.SEEN, 1);
1596
1597        context.getContentResolver().update(uri, values, null, null);
1598    }
1599
1600    /**
1601     * Returns a displayable string representing
1602     * the message sender. It has a preference toward showing the name,
1603     * but will fall back to the address if that is all that is available.
1604     */
1605    private static String getDisplayableSender(String sender) {
1606        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1607
1608        String displayableSender = address.getName();
1609
1610        if (!TextUtils.isEmpty(displayableSender)) {
1611            return Address.decodeAddressPersonal(displayableSender);
1612        }
1613
1614        // If that fails, default to the sender address.
1615        displayableSender = address.getAddress();
1616
1617        // If we were unable to tokenize a name or address,
1618        // just use whatever was in the sender.
1619        if (TextUtils.isEmpty(displayableSender)) {
1620            displayableSender = sender;
1621        }
1622        return displayableSender;
1623    }
1624
1625    /**
1626     * Returns only the address portion of a message sender.
1627     */
1628    private static String getSenderAddress(String sender) {
1629        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1630
1631        String tokenizedAddress = address.getAddress();
1632
1633        // If we were unable to tokenize a name or address,
1634        // just use whatever was in the sender.
1635        if (TextUtils.isEmpty(tokenizedAddress)) {
1636            tokenizedAddress = sender;
1637        }
1638        return tokenizedAddress;
1639    }
1640
1641    public static int getNotificationId(final android.accounts.Account account,
1642            final Folder folder) {
1643        return 1 ^ account.hashCode() ^ folder.hashCode();
1644    }
1645
1646    private static int getNotificationId(int summaryNotificationId, int childHashCode) {
1647        return summaryNotificationId ^ childHashCode;
1648    }
1649
1650    private static class NotificationKey {
1651        public final Account account;
1652        public final Folder folder;
1653
1654        public NotificationKey(Account account, Folder folder) {
1655            this.account = account;
1656            this.folder = folder;
1657        }
1658
1659        @Override
1660        public boolean equals(Object other) {
1661            if (!(other instanceof NotificationKey)) {
1662                return false;
1663            }
1664            NotificationKey key = (NotificationKey) other;
1665            return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount())
1666                    && folder.equals(key.folder);
1667        }
1668
1669        @Override
1670        public String toString() {
1671            return account.getDisplayName() + " " + folder.name;
1672        }
1673
1674        @Override
1675        public int hashCode() {
1676            final int accountHashCode = account.getAccountManagerAccount().hashCode();
1677            final int folderHashCode = folder.hashCode();
1678            return accountHashCode ^ folderHashCode;
1679        }
1680    }
1681
1682    /**
1683     * Contains the logic for converting the contents of one HtmlTree into
1684     * plaintext.
1685     */
1686    public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1687        // Strings for parsing html message bodies
1688        private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1689        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1690        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1691
1692        private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1693                new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1694
1695        private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
1696                HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
1697
1698        private int mEndNodeElidedTextBlock = -1;
1699
1700        @Override
1701        public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
1702            // If we are in the middle of an elided text block, don't add this node
1703            if (nodeNum < mEndNodeElidedTextBlock) {
1704                return;
1705            } else if (nodeNum == mEndNodeElidedTextBlock) {
1706                super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
1707                return;
1708            }
1709
1710            // If this tag starts another elided text block, we want to remember the end
1711            if (n instanceof HtmlDocument.Tag) {
1712                boolean foundElidedTextTag = false;
1713                final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
1714                final HTML.Element htmlElement = htmlTag.getElement();
1715                if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
1716                    // Make sure that the class is what is expected
1717                    final List<HtmlDocument.TagAttribute> attributes =
1718                            htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
1719                    for (HtmlDocument.TagAttribute attribute : attributes) {
1720                        if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
1721                                attribute.getValue())) {
1722                            // Found an "elided-text" div.  Remember information about this tag
1723                            mEndNodeElidedTextBlock = endNum;
1724                            foundElidedTextTag = true;
1725                            break;
1726                        }
1727                    }
1728                }
1729
1730                if (foundElidedTextTag) {
1731                    return;
1732                }
1733            }
1734
1735            super.addNode(n, nodeNum, endNum);
1736        }
1737    }
1738
1739    /**
1740     * During account setup in Email, we may not have an inbox yet, so the notification setting had
1741     * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
1742     * {@link FolderPreferences} now.
1743     */
1744    public static void moveNotificationSetting(final AccountPreferences accountPreferences,
1745            final FolderPreferences folderPreferences) {
1746        if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
1747            // If this setting has been changed some other way, don't overwrite it
1748            if (!folderPreferences.isNotificationsEnabledSet()) {
1749                final boolean notificationsEnabled =
1750                        accountPreferences.getDefaultInboxNotificationsEnabled();
1751
1752                folderPreferences.setNotificationsEnabled(notificationsEnabled);
1753            }
1754
1755            accountPreferences.clearDefaultInboxNotificationsEnabled();
1756        }
1757    }
1758}
1759