NotificationUtils.java revision 5a5b99b97c06fbe54497af0ea9adb8b14a62f91d
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                            // Conversations are sorted in descending order, but notification sort
1010                            // key is in ascending order.  Invert the order key to get the right
1011                            // order.  Left pad 19 zeros because it's a long.
1012                            String groupSortKey = String.format("%019d",
1013                                    (Long.MAX_VALUE - conversation.orderKey));
1014                            conversationNotif.setGroup(notificationGroupKey);
1015                            conversationNotif.setSortKey(groupSortKey);
1016
1017                            int conversationNotificationId = getNotificationId(
1018                                    summaryNotificationId, conversation.hashCode());
1019
1020                            final NotificationCompat.WearableExtender conversationWearExtender =
1021                                    new NotificationCompat.WearableExtender();
1022                            final ConfigResult result =
1023                                    configureNotifForOneConversation(context, account,
1024                                    folderPreferences, conversationNotif, conversationWearExtender,
1025                                    conversationCursor, notificationIntent, folder, when, res,
1026                                    notificationAccountDisplayName, notificationAccountEmail,
1027                                    isInbox, notificationLabelName, conversationNotificationId,
1028                                    photoFetcher);
1029                            msgNotifications.put(conversationNotificationId,
1030                                    NotificationBuilders.of(conversationNotif,
1031                                            conversationWearExtender));
1032
1033                            if (firstResult == null) {
1034                                firstResult = result;
1035                            }
1036                        } finally {
1037                            if (messageCursor != null) {
1038                                messageCursor.close();
1039                            }
1040                            if (cursor != null) {
1041                                cursor.close();
1042                            }
1043                        }
1044                    }
1045                } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
1046
1047                if (firstResult != null && firstResult.contactIconInfo != null) {
1048                    wearableExtender.setBackground(firstResult.contactIconInfo.wearableBg);
1049                } else {
1050                    LogUtils.w(LOG_TAG, "First contact icon is null!");
1051                    wearableExtender.setBackground(getDefaultWearableBg(context));
1052                }
1053            } else {
1054                // The body of the notification is the account name, or the label name.
1055                notification.setContentText(
1056                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1057            }
1058        } else {
1059            // For notifications for a single new conversation, we want to get the information
1060            // from the conversation
1061
1062            // Move the cursor to the most recent unread conversation
1063            seekToLatestUnreadConversation(conversationCursor);
1064
1065            final ConfigResult result = configureNotifForOneConversation(context, account,
1066                    folderPreferences, notification, wearableExtender, conversationCursor,
1067                    notificationIntent, folder, when, res, notificationAccountDisplayName,
1068                    notificationAccountEmail, isInbox, notificationLabelName,
1069                    summaryNotificationId, photoFetcher);
1070            notificationTicker = result.notificationTicker;
1071
1072            wearableExtender.setBackground(result.contactIconInfo.wearableBg);
1073        }
1074
1075        // Build the notification ticker
1076        if (notificationLabelName != null && notificationTicker != null) {
1077            // This is a per label notification, format the ticker with that information
1078            notificationTicker = res.getString(R.string.label_notification_ticker,
1079                    notificationLabelName, notificationTicker);
1080        }
1081
1082        if (notificationTicker != null) {
1083            // If we didn't generate a notification ticker, it will default to account name
1084            notification.setTicker(notificationTicker);
1085        }
1086
1087        // Set the number in the notification
1088        if (unreadCount > 1) {
1089            notification.setNumber(unreadCount);
1090        }
1091
1092        notification.setContentIntent(clickIntent);
1093    }
1094
1095    /**
1096     * Configure the notification for one conversation.  When there are multiple conversations,
1097     * this method is used to configure bundled notification for Android Wear.
1098     */
1099    private static ConfigResult configureNotifForOneConversation(Context context,
1100            Account account, FolderPreferences folderPreferences,
1101            NotificationCompat.Builder notification,
1102            NotificationCompat.WearableExtender wearExtender, Cursor conversationCursor,
1103            Intent notificationIntent, Folder folder, long when, Resources res,
1104            String notificationAccountDisplayName, String notificationAccountEmail,
1105            boolean isInbox, String notificationLabelName, int notificationId,
1106            final ContactPhotoFetcher photoFetcher) {
1107
1108        final ConfigResult result = new ConfigResult();
1109
1110        final Conversation conversation = new Conversation(conversationCursor);
1111
1112        Cursor cursor = null;
1113        MessageCursor messageCursor = null;
1114        boolean multipleUnseenThread = false;
1115        String from = null;
1116        try {
1117            final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
1118                    UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
1119            cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
1120                    null, null, null);
1121            messageCursor = new MessageCursor(cursor);
1122            // Use the information from the last sender in the conversation that triggered
1123            // this notification.
1124
1125            String fromAddress = "";
1126            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
1127                final Message message = messageCursor.getMessage();
1128                fromAddress = message.getFrom();
1129                if (fromAddress == null) {
1130                    // No sender. Go back to default value.
1131                    LogUtils.e(LOG_TAG, "No sender found for message: %d", message.getId());
1132                    fromAddress = "";
1133                }
1134                from = getDisplayableSender(fromAddress);
1135                result.contactIconInfo = getContactIcon(
1136                        context, account.getAccountManagerAccount().name, from,
1137                        getSenderAddress(fromAddress), folder, photoFetcher);
1138                notification.setLargeIcon(result.contactIconInfo.icon);
1139            }
1140
1141            // Assume that the last message in this conversation is unread
1142            int firstUnseenMessagePos = messageCursor.getPosition();
1143            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
1144                final Message message = messageCursor.getMessage();
1145                final boolean unseen = !message.seen;
1146                if (unseen) {
1147                    firstUnseenMessagePos = messageCursor.getPosition();
1148                    if (!multipleUnseenThread
1149                            && !fromAddress.contentEquals(message.getFrom())) {
1150                        multipleUnseenThread = true;
1151                    }
1152                }
1153            }
1154
1155            final String subject = ConversationItemView.filterTag(context, conversation.subject);
1156
1157            // TODO(skennedy) Can we remove this check?
1158            if (Utils.isRunningJellybeanOrLater()) {
1159                // For a new-style notification
1160
1161                if (multipleUnseenThread) {
1162                    // The title of a single conversation is the list of senders.
1163                    int sendersLength = res.getInteger(R.integer.swipe_senders_length);
1164
1165                    final SpannableStringBuilder sendersBuilder = getStyledSenders(
1166                            context, conversationCursor, sendersLength,
1167                            notificationAccountEmail);
1168
1169                    notification.setContentTitle(sendersBuilder);
1170                    // For a single new conversation, the ticker is based on the sender's name.
1171                    result.notificationTicker = sendersBuilder.toString();
1172                } else {
1173                    from = getWrappedFromString(from);
1174                    // The title of a single message the sender.
1175                    notification.setContentTitle(from);
1176                    // For a single new conversation, the ticker is based on the sender's name.
1177                    result.notificationTicker = from;
1178                }
1179
1180                // The notification content will be the subject of the conversation.
1181                notification.setContentText(getSingleMessageLittleText(context, subject));
1182
1183                // The notification subtext will be the subject of the conversation for inbox
1184                // notifications, or will based on the the label name for user label
1185                // notifications.
1186                notification.setSubText(isInbox ?
1187                        notificationAccountDisplayName : notificationLabelName);
1188
1189                if (multipleUnseenThread) {
1190                    notification.setLargeIcon(
1191                            getDefaultNotificationIcon(context, folder, true));
1192                }
1193                final NotificationCompat.BigTextStyle bigText =
1194                        new NotificationCompat.BigTextStyle(notification);
1195
1196                // Seek the message cursor to the first unread message
1197                final Message message;
1198                if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
1199                    message = messageCursor.getMessage();
1200                    bigText.bigText(getSingleMessageBigText(context, subject, message));
1201                } else {
1202                    LogUtils.e(LOG_TAG, "Failed to load message");
1203                    message = null;
1204                }
1205
1206                if (message != null) {
1207                    final Set<String> notificationActions =
1208                            folderPreferences.getNotificationActions(account);
1209
1210                    NotificationActionUtils.addNotificationActions(context, notificationIntent,
1211                            notification, wearExtender, account, conversation, message,
1212                            folder, notificationId, when, notificationActions);
1213                }
1214            } else {
1215                // For an old-style notification
1216
1217                // The title of a single conversation notification is built from both the sender
1218                // and subject of the new message.
1219                notification.setContentTitle(
1220                        getSingleMessageNotificationTitle(context, from, subject));
1221
1222                // The notification content will be the subject of the conversation for inbox
1223                // notifications, or will based on the the label name for user label
1224                // notifications.
1225                notification.setContentText(
1226                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1227
1228                // For a single new conversation, the ticker is based on the sender's name.
1229                result.notificationTicker = from;
1230            }
1231        } finally {
1232            if (messageCursor != null) {
1233                messageCursor.close();
1234            }
1235            if (cursor != null) {
1236                cursor.close();
1237            }
1238        }
1239        return result;
1240    }
1241
1242    private static String getWrappedFromString(String from) {
1243        if (from == null) {
1244            LogUtils.e(LOG_TAG, "null from string in getWrappedFromString");
1245            from = "";
1246        }
1247        from = sBidiFormatter.unicodeWrap(from);
1248        return from;
1249    }
1250
1251    private static SpannableStringBuilder getStyledSenders(final Context context,
1252            final Cursor conversationCursor, final int maxLength, final String account) {
1253        final Conversation conversation = new Conversation(conversationCursor);
1254        final com.android.mail.providers.ConversationInfo conversationInfo =
1255                conversation.conversationInfo;
1256        final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
1257        if (sNotificationUnreadStyleSpan == null) {
1258            sNotificationUnreadStyleSpan = new TextAppearanceSpan(
1259                    context, R.style.NotificationSendersUnreadTextAppearance);
1260            sNotificationReadStyleSpan =
1261                    new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
1262        }
1263        SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
1264                sNotificationUnreadStyleSpan, sNotificationReadStyleSpan,
1265                false /* showToHeader */, false /* resourceCachingRequired */);
1266
1267        return ellipsizeStyledSenders(context, senders);
1268    }
1269
1270    private static String sSendersSplitToken = null;
1271    private static String sElidedPaddingToken = null;
1272
1273    private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
1274            ArrayList<SpannableString> styledSenders) {
1275        if (sSendersSplitToken == null) {
1276            sSendersSplitToken = context.getString(R.string.senders_split_token);
1277            sElidedPaddingToken = context.getString(R.string.elided_padding_token);
1278        }
1279
1280        SpannableStringBuilder builder = new SpannableStringBuilder();
1281        SpannableString prevSender = null;
1282        for (SpannableString sender : styledSenders) {
1283            if (sender == null) {
1284                LogUtils.e(LOG_TAG, "null sender iterating over styledSenders");
1285                continue;
1286            }
1287            CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
1288            if (SendersView.sElidedString.equals(sender.toString())) {
1289                prevSender = sender;
1290                sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
1291            } else if (builder.length() > 0
1292                    && (prevSender == null || !SendersView.sElidedString.equals(prevSender
1293                            .toString()))) {
1294                prevSender = sender;
1295                sender = copyStyles(spans, sSendersSplitToken + sender);
1296            } else {
1297                prevSender = sender;
1298            }
1299            builder.append(sender);
1300        }
1301        return builder;
1302    }
1303
1304    private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
1305        SpannableString s = new SpannableString(newText);
1306        if (spans != null && spans.length > 0) {
1307            s.setSpan(spans[0], 0, s.length(), 0);
1308        }
1309        return s;
1310    }
1311
1312    /**
1313     * Seeks the cursor to the position of the most recent unread conversation. If no unread
1314     * conversation is found, the position of the cursor will be restored, and false will be
1315     * returned.
1316     */
1317    private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
1318        final int initialPosition = cursor.getPosition();
1319        do {
1320            final Conversation conversation = new Conversation(cursor);
1321            if (!conversation.read) {
1322                return true;
1323            }
1324        } while (cursor.moveToNext());
1325
1326        // Didn't find an unread conversation, reset the position.
1327        cursor.moveToPosition(initialPosition);
1328        return false;
1329    }
1330
1331    /**
1332     * Sets the bigtext for a notification for a single new conversation
1333     *
1334     * @param context
1335     * @param senders Sender of the new message that triggered the notification.
1336     * @param subject Subject of the new message that triggered the notification
1337     * @param snippet Snippet of the new message that triggered the notification
1338     * @return a {@link CharSequence} suitable for use in
1339     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1340     */
1341    private static CharSequence getSingleMessageInboxLine(Context context,
1342            String senders, String subject, String snippet) {
1343        // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1344
1345        final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1346
1347        final TextAppearanceSpan notificationPrimarySpan =
1348                new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1349
1350        if (TextUtils.isEmpty(senders)) {
1351            // If the senders are empty, just use the subject/snippet.
1352            return subjectSnippet;
1353        } else if (TextUtils.isEmpty(subjectSnippet)) {
1354            // If the subject/snippet is empty, just use the senders.
1355            final SpannableString spannableString = new SpannableString(senders);
1356            spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1357
1358            return spannableString;
1359        } else {
1360            final String formatString = context.getResources().getString(
1361                    R.string.multiple_new_message_notification_item);
1362            final TextAppearanceSpan notificationSecondarySpan =
1363                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1364
1365            // senders is already individually unicode wrapped so it does not need to be done here
1366            final String instantiatedString = String.format(formatString,
1367                    senders,
1368                    sBidiFormatter.unicodeWrap(subjectSnippet));
1369
1370            final SpannableString spannableString = new SpannableString(instantiatedString);
1371
1372            final boolean isOrderReversed = formatString.indexOf("%2$s") <
1373                    formatString.indexOf("%1$s");
1374            final int primaryOffset =
1375                    (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1376                     instantiatedString.indexOf(senders));
1377            final int secondaryOffset =
1378                    (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1379                     instantiatedString.indexOf(subjectSnippet));
1380            spannableString.setSpan(notificationPrimarySpan,
1381                    primaryOffset, primaryOffset + senders.length(), 0);
1382            spannableString.setSpan(notificationSecondarySpan,
1383                    secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1384            return spannableString;
1385        }
1386    }
1387
1388    /**
1389     * Sets the bigtext for a notification for a single new conversation
1390     * @param context
1391     * @param subject Subject of the new message that triggered the notification
1392     * @return a {@link CharSequence} suitable for use in
1393     * {@link NotificationCompat.Builder#setContentText}
1394     */
1395    private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1396        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1397                context, R.style.NotificationPrimaryText);
1398
1399        final SpannableString spannableString = new SpannableString(subject);
1400        spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1401
1402        return spannableString;
1403    }
1404
1405    /**
1406     * Sets the bigtext for a notification for a single new conversation
1407     *
1408     * @param context
1409     * @param subject Subject of the new message that triggered the notification
1410     * @param message the {@link Message} to be displayed.
1411     * @return a {@link CharSequence} suitable for use in
1412     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1413     */
1414    private static CharSequence getSingleMessageBigText(Context context, String subject,
1415            final Message message) {
1416
1417        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1418                context, R.style.NotificationPrimaryText);
1419
1420        final String snippet = getMessageBodyWithoutElidedText(message);
1421
1422        // Change multiple newlines (with potential white space between), into a single new line
1423        final String collapsedSnippet =
1424                !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1425
1426        if (TextUtils.isEmpty(subject)) {
1427            // If the subject is empty, just use the snippet.
1428            return snippet;
1429        } else if (TextUtils.isEmpty(collapsedSnippet)) {
1430            // If the snippet is empty, just use the subject.
1431            final SpannableString spannableString = new SpannableString(subject);
1432            spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1433
1434            return spannableString;
1435        } else {
1436            final String notificationBigTextFormat = context.getResources().getString(
1437                    R.string.single_new_message_notification_big_text);
1438
1439            // Localizers may change the order of the parameters, look at how the format
1440            // string is structured.
1441            final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1442                    notificationBigTextFormat.indexOf("%1$s");
1443            final String bigText =
1444                    String.format(notificationBigTextFormat, subject, collapsedSnippet);
1445            final SpannableString spannableString = new SpannableString(bigText);
1446
1447            final int subjectOffset =
1448                    (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1449            spannableString.setSpan(notificationSubjectSpan,
1450                    subjectOffset, subjectOffset + subject.length(), 0);
1451
1452            return spannableString;
1453        }
1454    }
1455
1456    /**
1457     * Gets the title for a notification for a single new conversation
1458     * @param context
1459     * @param sender Sender of the new message that triggered the notification.
1460     * @param subject Subject of the new message that triggered the notification
1461     * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1462     */
1463    private static CharSequence getSingleMessageNotificationTitle(Context context,
1464            String sender, String subject) {
1465
1466        if (TextUtils.isEmpty(subject)) {
1467            // If the subject is empty, just set the title to the sender's information.
1468            return sender;
1469        } else {
1470            final String notificationTitleFormat = context.getResources().getString(
1471                    R.string.single_new_message_notification_title);
1472
1473            // Localizers may change the order of the parameters, look at how the format
1474            // string is structured.
1475            final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1476                    notificationTitleFormat.indexOf("%1$s");
1477            final String titleString = String.format(notificationTitleFormat, sender, subject);
1478
1479            // Format the string so the subject is using the secondaryText style
1480            final SpannableString titleSpannable = new SpannableString(titleString);
1481
1482            // Find the offset of the subject.
1483            final int subjectOffset =
1484                    isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1485            final TextAppearanceSpan notificationSubjectSpan =
1486                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1487            titleSpannable.setSpan(notificationSubjectSpan,
1488                    subjectOffset, subjectOffset + subject.length(), 0);
1489            return titleSpannable;
1490        }
1491    }
1492
1493    /**
1494     * Clears the notifications for the specified account/folder.
1495     */
1496    public static void clearFolderNotification(Context context, Account account, Folder folder,
1497            final boolean markSeen) {
1498        LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.getEmailAddress(),
1499                folder.name);
1500        final NotificationMap notificationMap = getNotificationMap(context);
1501        final NotificationKey key = new NotificationKey(account, folder);
1502        notificationMap.remove(key);
1503        notificationMap.saveNotificationMap(context);
1504
1505        final NotificationManagerCompat notificationManager =
1506                NotificationManagerCompat.from(context);
1507        notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder));
1508
1509        cancelConversationNotifications(key, notificationManager);
1510
1511        if (markSeen) {
1512            markSeen(context, folder);
1513        }
1514    }
1515
1516    /**
1517     * Use content resolver to update a conversation.  Should not be called from a main thread.
1518     */
1519    public static void markConversationAsReadAndSeen(Context context, Uri conversationUri) {
1520        LogUtils.v(LOG_TAG, "markConversationAsReadAndSeen=%s", conversationUri);
1521
1522        final ContentValues values = new ContentValues(2);
1523        values.put(UIProvider.ConversationColumns.SEEN, Boolean.TRUE);
1524        values.put(UIProvider.ConversationColumns.READ, Boolean.TRUE);
1525        context.getContentResolver().update(conversationUri, values, null, null);
1526    }
1527
1528    /**
1529     * Clears all notifications for the specified account.
1530     */
1531    public static void clearAccountNotifications(final Context context,
1532            final android.accounts.Account account) {
1533        LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account);
1534        final NotificationMap notificationMap = getNotificationMap(context);
1535
1536        // Find all NotificationKeys for this account
1537        final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder();
1538
1539        for (final NotificationKey key : notificationMap.keySet()) {
1540            if (account.equals(key.account.getAccountManagerAccount())) {
1541                keyBuilder.add(key);
1542            }
1543        }
1544
1545        final List<NotificationKey> notificationKeys = keyBuilder.build();
1546
1547        final NotificationManagerCompat notificationManager =
1548                NotificationManagerCompat.from(context);
1549
1550        for (final NotificationKey notificationKey : notificationKeys) {
1551            final Folder folder = notificationKey.folder;
1552            notificationManager.cancel(getNotificationId(account, folder));
1553            notificationMap.remove(notificationKey);
1554
1555            cancelConversationNotifications(notificationKey, notificationManager);
1556        }
1557
1558        notificationMap.saveNotificationMap(context);
1559    }
1560
1561    private static void cancelConversationNotifications(NotificationKey key,
1562            NotificationManagerCompat nm) {
1563        final Set<Integer> conversationNotifications = sConversationNotificationMap.get(key);
1564        if (conversationNotifications != null) {
1565            for (Integer conversationNotification : conversationNotifications) {
1566                nm.cancel(conversationNotification);
1567            }
1568            sConversationNotificationMap.remove(key);
1569        }
1570    }
1571
1572    private static ContactIconInfo getContactIcon(final Context context, String accountName,
1573            final String displayName, final String senderAddress, final Folder folder,
1574            final ContactPhotoFetcher photoFetcher) {
1575
1576        if (senderAddress == null) {
1577            return null;
1578        }
1579
1580        if (Looper.myLooper() == Looper.getMainLooper()) {
1581            throw new IllegalStateException(
1582                    "getContactIcon should not be called on the main thread.");
1583        }
1584
1585        // Get the ideal size for this icon.
1586        final Resources res = context.getResources();
1587        final int idealIconHeight =
1588                res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1589        final int idealIconWidth =
1590                res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1591        final int idealWearableBgWidth =
1592                res.getDimensionPixelSize(R.dimen.wearable_background_width);
1593        final int idealWearableBgHeight =
1594                res.getDimensionPixelSize(R.dimen.wearable_background_height);
1595
1596        final ContactIconInfo contactIconInfo =
1597                photoFetcher != null ? photoFetcher.getContactPhoto(
1598                context, accountName, senderAddress,idealIconWidth, idealIconHeight,
1599                idealWearableBgWidth, idealWearableBgHeight) :
1600                getContactInfo(context, senderAddress, idealIconWidth, idealIconHeight,
1601                idealWearableBgWidth, idealWearableBgHeight);
1602
1603        if (contactIconInfo.icon == null) {
1604            // Make a colorful tile!
1605            final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight,
1606                    Dimensions.SCALE_ONE);
1607
1608            contactIconInfo.icon = new LetterTileProvider(context).getLetterTile(dimensions,
1609                    displayName, senderAddress);
1610        }
1611
1612        if (contactIconInfo.icon == null) {
1613            // Icon should be the default mail icon.
1614            contactIconInfo.icon = getDefaultNotificationIcon(context, folder,
1615                    false /* single new message */);
1616        }
1617
1618        if (contactIconInfo.wearableBg == null) {
1619            contactIconInfo.wearableBg = getDefaultWearableBg(context);
1620        }
1621
1622        return contactIconInfo;
1623    }
1624
1625    private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1626        ArrayList<String> whereArgs = new ArrayList<String>();
1627        StringBuilder whereBuilder = new StringBuilder();
1628        String[] questionMarks = new String[addresses.size()];
1629
1630        whereArgs.addAll(addresses);
1631        Arrays.fill(questionMarks, "?");
1632        whereBuilder.append(Email.DATA1 + " IN (").
1633                append(TextUtils.join(",", questionMarks)).
1634                append(")");
1635
1636        ContentResolver resolver = context.getContentResolver();
1637        Cursor c = resolver.query(Email.CONTENT_URI,
1638                new String[] {Email.CONTACT_ID}, whereBuilder.toString(),
1639                whereArgs.toArray(new String[0]), null);
1640
1641        ArrayList<Long> contactIds = new ArrayList<Long>();
1642        if (c == null) {
1643            return contactIds;
1644        }
1645        try {
1646            while (c.moveToNext()) {
1647                contactIds.add(c.getLong(0));
1648            }
1649        } finally {
1650            c.close();
1651        }
1652        return contactIds;
1653    }
1654
1655    public static ContactIconInfo getContactInfo(
1656            final Context context, final String senderAddress,
1657            final int idealIconWidth, final int idealIconHeight,
1658            final int idealWearableBgWidth, final int idealWearableBgHeight) {
1659        final ContactIconInfo contactIconInfo = new ContactIconInfo();
1660        final List<Long> contactIds = findContacts( context, Arrays.asList(
1661                new String[] { senderAddress }));
1662
1663        if (contactIds != null) {
1664            for (final long id : contactIds) {
1665                final Uri contactUri = ContentUris.withAppendedId(
1666                        ContactsContract.Contacts.CONTENT_URI, id);
1667                final InputStream inputStream =
1668                        ContactsContract.Contacts.openContactPhotoInputStream(
1669                                context.getContentResolver(), contactUri, true /*preferHighres*/);
1670
1671                if (inputStream != null) {
1672                    try {
1673                        final Bitmap source = BitmapFactory.decodeStream(inputStream);
1674                        if (source != null) {
1675                            // We should scale this image to fit the intended size
1676                            contactIconInfo.icon = Bitmap.createScaledBitmap(source, idealIconWidth,
1677                                    idealIconHeight, true);
1678
1679                            contactIconInfo.wearableBg = Bitmap.createScaledBitmap(source,
1680                                    idealWearableBgWidth, idealWearableBgHeight, true);
1681                        }
1682
1683                        if (contactIconInfo.icon != null) {
1684                            break;
1685                        }
1686                    } finally {
1687                        Closeables.closeQuietly(inputStream);
1688                    }
1689                }
1690            }
1691        }
1692
1693        return contactIconInfo;
1694    }
1695
1696    private static String getMessageBodyWithoutElidedText(final Message message) {
1697        return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
1698    }
1699
1700    public static String getMessageBodyWithoutElidedText(String html) {
1701        if (TextUtils.isEmpty(html)) {
1702            return "";
1703        }
1704        // Get the html "tree" for this message body
1705        final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1706        htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY);
1707
1708        return htmlTree.getPlainText();
1709    }
1710
1711    public static void markSeen(final Context context, final Folder folder) {
1712        final Uri uri = folder.folderUri.fullUri;
1713
1714        final ContentValues values = new ContentValues(1);
1715        values.put(UIProvider.ConversationColumns.SEEN, 1);
1716
1717        context.getContentResolver().update(uri, values, null, null);
1718    }
1719
1720    /**
1721     * Returns a displayable string representing
1722     * the message sender. It has a preference toward showing the name,
1723     * but will fall back to the address if that is all that is available.
1724     */
1725    private static String getDisplayableSender(String sender) {
1726        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1727
1728        String displayableSender = address.getName();
1729
1730        if (!TextUtils.isEmpty(displayableSender)) {
1731            return Address.decodeAddressPersonal(displayableSender);
1732        }
1733
1734        // If that fails, default to the sender address.
1735        displayableSender = address.getAddress();
1736
1737        // If we were unable to tokenize a name or address,
1738        // just use whatever was in the sender.
1739        if (TextUtils.isEmpty(displayableSender)) {
1740            displayableSender = sender;
1741        }
1742        return displayableSender;
1743    }
1744
1745    /**
1746     * Returns only the address portion of a message sender.
1747     */
1748    private static String getSenderAddress(String sender) {
1749        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1750
1751        String tokenizedAddress = address.getAddress();
1752
1753        // If we were unable to tokenize a name or address,
1754        // just use whatever was in the sender.
1755        if (TextUtils.isEmpty(tokenizedAddress)) {
1756            tokenizedAddress = sender;
1757        }
1758        return tokenizedAddress;
1759    }
1760
1761    public static int getNotificationId(final android.accounts.Account account,
1762            final Folder folder) {
1763        return 1 ^ account.hashCode() ^ folder.hashCode();
1764    }
1765
1766    private static int getNotificationId(int summaryNotificationId, int conversationHashCode) {
1767        return summaryNotificationId ^ conversationHashCode;
1768    }
1769
1770    private static class NotificationKey {
1771        public final Account account;
1772        public final Folder folder;
1773
1774        public NotificationKey(Account account, Folder folder) {
1775            this.account = account;
1776            this.folder = folder;
1777        }
1778
1779        @Override
1780        public boolean equals(Object other) {
1781            if (!(other instanceof NotificationKey)) {
1782                return false;
1783            }
1784            NotificationKey key = (NotificationKey) other;
1785            return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount())
1786                    && folder.equals(key.folder);
1787        }
1788
1789        @Override
1790        public String toString() {
1791            return account.getDisplayName() + " " + folder.name;
1792        }
1793
1794        @Override
1795        public int hashCode() {
1796            final int accountHashCode = account.getAccountManagerAccount().hashCode();
1797            final int folderHashCode = folder.hashCode();
1798            return accountHashCode ^ folderHashCode;
1799        }
1800    }
1801
1802    /**
1803     * Contains the logic for converting the contents of one HtmlTree into
1804     * plaintext.
1805     */
1806    public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1807        // Strings for parsing html message bodies
1808        private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1809        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1810        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1811
1812        private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1813                new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1814
1815        private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
1816                HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
1817
1818        private int mEndNodeElidedTextBlock = -1;
1819
1820        @Override
1821        public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
1822            // If we are in the middle of an elided text block, don't add this node
1823            if (nodeNum < mEndNodeElidedTextBlock) {
1824                return;
1825            } else if (nodeNum == mEndNodeElidedTextBlock) {
1826                super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
1827                return;
1828            }
1829
1830            // If this tag starts another elided text block, we want to remember the end
1831            if (n instanceof HtmlDocument.Tag) {
1832                boolean foundElidedTextTag = false;
1833                final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
1834                final HTML.Element htmlElement = htmlTag.getElement();
1835                if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
1836                    // Make sure that the class is what is expected
1837                    final List<HtmlDocument.TagAttribute> attributes =
1838                            htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
1839                    for (HtmlDocument.TagAttribute attribute : attributes) {
1840                        if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
1841                                attribute.getValue())) {
1842                            // Found an "elided-text" div.  Remember information about this tag
1843                            mEndNodeElidedTextBlock = endNum;
1844                            foundElidedTextTag = true;
1845                            break;
1846                        }
1847                    }
1848                }
1849
1850                if (foundElidedTextTag) {
1851                    return;
1852                }
1853            }
1854
1855            super.addNode(n, nodeNum, endNum);
1856        }
1857    }
1858
1859    /**
1860     * During account setup in Email, we may not have an inbox yet, so the notification setting had
1861     * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
1862     * {@link FolderPreferences} now.
1863     */
1864    public static void moveNotificationSetting(final AccountPreferences accountPreferences,
1865            final FolderPreferences folderPreferences) {
1866        if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
1867            // If this setting has been changed some other way, don't overwrite it
1868            if (!folderPreferences.isNotificationsEnabledSet()) {
1869                final boolean notificationsEnabled =
1870                        accountPreferences.getDefaultInboxNotificationsEnabled();
1871
1872                folderPreferences.setNotificationsEnabled(notificationsEnabled);
1873            }
1874
1875            accountPreferences.clearDefaultInboxNotificationsEnabled();
1876        }
1877    }
1878
1879    private static class NotificationBuilders {
1880        public final NotificationCompat.Builder notifBuilder;
1881        public final NotificationCompat.WearableExtender wearableNotifBuilder;
1882
1883        private NotificationBuilders(NotificationCompat.Builder notifBuilder,
1884                NotificationCompat.WearableExtender wearableNotifBuilder) {
1885            this.notifBuilder = notifBuilder;
1886            this.wearableNotifBuilder = wearableNotifBuilder;
1887        }
1888
1889        public static NotificationBuilders of(NotificationCompat.Builder notifBuilder,
1890                NotificationCompat.WearableExtender wearableNotifBuilder) {
1891            return new NotificationBuilders(notifBuilder, wearableNotifBuilder);
1892        }
1893    }
1894
1895    private static class ConfigResult {
1896        public String notificationTicker;
1897        public ContactIconInfo contactIconInfo;
1898    }
1899
1900    public static class ContactIconInfo {
1901        public Bitmap icon;
1902        public Bitmap wearableBg;
1903    }
1904}
1905