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