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