NotificationUtils.java revision de2f3f40ab2403b89bebd219dcab4d561fd07387
1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.mail.utils;
17
18import android.app.Notification;
19import android.app.PendingIntent;
20import android.content.ContentResolver;
21import android.content.ContentUris;
22import android.content.ContentValues;
23import android.content.Context;
24import android.content.Intent;
25import android.content.res.Resources;
26import android.database.Cursor;
27import android.graphics.Bitmap;
28import android.graphics.BitmapFactory;
29import android.net.Uri;
30import android.os.Looper;
31import android.provider.ContactsContract;
32import android.provider.ContactsContract.CommonDataKinds.Email;
33import android.support.v4.app.NotificationCompat;
34import android.support.v4.app.NotificationManagerCompat;
35import android.support.v4.text.BidiFormatter;
36import android.support.v4.util.ArrayMap;
37import android.text.SpannableString;
38import android.text.SpannableStringBuilder;
39import android.text.TextUtils;
40import android.text.style.CharacterStyle;
41import android.text.style.TextAppearanceSpan;
42import android.util.Pair;
43import android.util.SparseArray;
44
45import com.android.emailcommon.mail.Address;
46import com.android.mail.EmailAddress;
47import com.android.mail.MailIntentService;
48import com.android.mail.R;
49import com.android.mail.analytics.Analytics;
50import com.android.mail.browse.ConversationItemView;
51import com.android.mail.browse.MessageCursor;
52import com.android.mail.browse.SendersView;
53import com.android.mail.photo.ContactFetcher;
54import com.android.mail.photomanager.LetterTileProvider;
55import com.android.mail.preferences.AccountPreferences;
56import com.android.mail.preferences.FolderPreferences;
57import com.android.mail.preferences.MailPrefs;
58import com.android.mail.providers.Account;
59import com.android.mail.providers.Conversation;
60import com.android.mail.providers.Folder;
61import com.android.mail.providers.Message;
62import com.android.mail.providers.UIProvider;
63import com.android.mail.ui.ImageCanvas.Dimensions;
64import com.android.mail.utils.NotificationActionUtils.NotificationAction;
65import com.google.android.mail.common.html.parser.HTML;
66import com.google.android.mail.common.html.parser.HTML4;
67import com.google.android.mail.common.html.parser.HtmlDocument;
68import com.google.android.mail.common.html.parser.HtmlTree;
69import com.google.common.base.Objects;
70import com.google.common.collect.ImmutableList;
71import com.google.common.collect.Lists;
72import com.google.common.collect.Sets;
73import com.google.common.io.Closeables;
74
75import java.io.InputStream;
76import java.lang.ref.WeakReference;
77import java.util.ArrayList;
78import java.util.Arrays;
79import java.util.Collection;
80import java.util.HashMap;
81import java.util.HashSet;
82import java.util.List;
83import java.util.Map;
84import java.util.Set;
85import java.util.concurrent.ConcurrentHashMap;
86
87public class NotificationUtils {
88    public static final String LOG_TAG = "NotifUtils";
89
90    public static final String EXTRA_UNREAD_COUNT = "unread-count";
91    public static final String EXTRA_UNSEEN_COUNT = "unseen-count";
92    public static final String EXTRA_GET_ATTENTION = "get-attention";
93    private static final int PUBLIC_NOTIFICATIONS_VISIBLE_CHARS = 4;
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<Bitmap>(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 String notificationAccountDisplayName = account.getDisplayName();
973        final String notificationAccountEmail = account.getEmailAddress();
974        final boolean multipleUnseen = unseenCount > 1;
975
976        LogUtils.i(LOG_TAG, "Showing notification with unreadCount of %d and unseenCount of %d",
977                unreadCount, unseenCount);
978
979        String notificationTicker = null;
980
981        // Boolean indicating that this notification is for a non-inbox label.
982        final boolean isInbox = folder.folderUri.fullUri.equals(account.settings.defaultInbox);
983
984        // Notification label name for user label notifications.
985        final String notificationLabelName = isInbox ? null : folder.name;
986
987        if (multipleUnseen) {
988            // Build the string that describes the number of new messages
989            final String newMessagesString = createTitle(context, unseenCount);
990
991            // The ticker initially start as the new messages string.
992            notificationTicker = newMessagesString;
993
994            // The title of the notification is the new messages string
995            notificationBuilder.setContentTitle(newMessagesString);
996
997            // TODO(skennedy) Can we remove this check?
998            if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) {
999                // For a new-style notification
1000                final int maxNumDigestItems = context.getResources().getInteger(
1001                        R.integer.max_num_notification_digest_items);
1002
1003                // The body of the notification is the account name, or the label name.
1004                notificationBuilder.setSubText(
1005                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1006
1007                final NotificationCompat.InboxStyle digest =
1008                        new NotificationCompat.InboxStyle(notificationBuilder);
1009
1010                // Group by account and folder
1011                final String notificationGroupKey = createGroupKey(account, folder);
1012                // Track all senders to later tag them along with the digest notification
1013                final HashSet<String> sendersList = new HashSet<String>();
1014                notificationBuilder.setGroup(notificationGroupKey).setGroupSummary(true);
1015
1016                ConfigResult firstResult = null;
1017                int numDigestItems = 0;
1018                do {
1019                    final Conversation conversation = new Conversation(conversationCursor);
1020
1021                    if (!conversation.read) {
1022                        boolean multipleUnreadThread = false;
1023                        // TODO(cwren) extract this pattern into a helper
1024
1025                        Cursor cursor = null;
1026                        MessageCursor messageCursor = null;
1027                        try {
1028                            final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon();
1029                            uriBuilder.appendQueryParameter(
1030                                    UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName);
1031                            cursor = context.getContentResolver().query(uriBuilder.build(),
1032                                    UIProvider.MESSAGE_PROJECTION, null, null, null);
1033                            messageCursor = new MessageCursor(cursor);
1034
1035                            String from = "";
1036                            String fromAddress = "";
1037                            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
1038                                final Message message = messageCursor.getMessage();
1039                                fromAddress = message.getFrom();
1040                                if (fromAddress == null) {
1041                                    fromAddress = "";
1042                                }
1043                                from = getDisplayableSender(fromAddress);
1044                                sendersList.add(getSenderAddress(fromAddress));
1045                            }
1046                            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
1047                                final Message message = messageCursor.getMessage();
1048                                if (!message.read &&
1049                                        !fromAddress.contentEquals(message.getFrom())) {
1050                                    multipleUnreadThread = true;
1051                                    sendersList.add(getSenderAddress(message.getFrom()));
1052                                }
1053                            }
1054                            final SpannableStringBuilder sendersBuilder;
1055                            if (multipleUnreadThread) {
1056                                final int sendersLength =
1057                                        res.getInteger(R.integer.swipe_senders_length);
1058
1059                                sendersBuilder = getStyledSenders(context, conversationCursor,
1060                                        sendersLength, notificationAccountEmail);
1061                            } else {
1062                                sendersBuilder =
1063                                        new SpannableStringBuilder(getWrappedFromString(from));
1064                            }
1065                            final CharSequence digestLine = getSingleMessageInboxLine(context,
1066                                    sendersBuilder.toString(),
1067                                    ConversationItemView.filterTag(context, conversation.subject),
1068                                    conversation.getSnippet());
1069                            digest.addLine(digestLine);
1070                            numDigestItems++;
1071
1072                            // Adding conversation notification for Wear.
1073                            NotificationCompat.Builder conversationNotif =
1074                                    new NotificationCompat.Builder(context);
1075                            conversationNotif.setCategory(NotificationCompat.CATEGORY_EMAIL);
1076
1077                            conversationNotif.setSmallIcon(
1078                                    R.drawable.ic_notification_multiple_mail_24dp);
1079
1080                            if (com.android.mail.utils.Utils.isRunningLOrLater()) {
1081                                conversationNotif.setColor(
1082                                        context.getResources()
1083                                                .getColor(R.color.notification_icon_color));
1084                            }
1085                            conversationNotif.setContentText(digestLine);
1086                            Intent conversationNotificationIntent = createViewConversationIntent(
1087                                    context, account, folder, conversationCursor);
1088                            PendingIntent conversationClickIntent = createClickPendingIntent(
1089                                    context, conversationNotificationIntent);
1090                            conversationNotif.setContentIntent(conversationClickIntent);
1091                            conversationNotif.setAutoCancel(true);
1092
1093                            // Conversations are sorted in descending order, but notification sort
1094                            // key is in ascending order.  Invert the order key to get the right
1095                            // order.  Left pad 19 zeros because it's a long.
1096                            String groupSortKey = String.format("%019d",
1097                                    (Long.MAX_VALUE - conversation.orderKey));
1098                            conversationNotif.setGroup(notificationGroupKey);
1099                            conversationNotif.setSortKey(groupSortKey);
1100                            conversationNotif.setWhen(conversation.dateMs);
1101
1102                            int conversationNotificationId = getNotificationId(
1103                                    summaryNotificationId, conversation.hashCode());
1104
1105                            final NotificationCompat.WearableExtender conversationWearExtender =
1106                                    new NotificationCompat.WearableExtender();
1107                            final ConfigResult result =
1108                                    configureNotifForOneConversation(context, account,
1109                                    folderPreferences, conversationNotif, conversationWearExtender,
1110                                    conversationCursor, notificationIntent, folder, when, res,
1111                                    notificationAccountDisplayName, notificationAccountEmail,
1112                                    isInbox, notificationLabelName, conversationNotificationId,
1113                                    contactFetcher);
1114                            msgNotifications.put(conversationNotificationId,
1115                                    NotificationBuilders.of(conversationNotif,
1116                                            conversationWearExtender));
1117
1118                            if (firstResult == null) {
1119                                firstResult = result;
1120                            }
1121                        } finally {
1122                            if (messageCursor != null) {
1123                                messageCursor.close();
1124                            }
1125                            if (cursor != null) {
1126                                cursor.close();
1127                            }
1128                        }
1129                    }
1130                } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
1131
1132                // Tag main digest notification with the senders
1133                tagNotificationsWithPeople(context, notificationBuilder, sendersList,
1134                        account.getAccountManagerAccount().name, contactFetcher);
1135
1136                if (firstResult != null && firstResult.contactIconInfo != null) {
1137                    wearableExtender.setBackground(firstResult.contactIconInfo.wearableBg);
1138                } else {
1139                    LogUtils.w(LOG_TAG, "First contact icon is null!");
1140                    wearableExtender.setBackground(getDefaultWearableBg(context));
1141                }
1142            } else {
1143                // The body of the notification is the account name, or the label name.
1144                notificationBuilder.setContentText(
1145                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1146            }
1147        } else {
1148            // For notifications for a single new conversation, we want to get the information
1149            // from the conversation
1150
1151            // Move the cursor to the most recent unread conversation
1152            seekToLatestUnreadConversation(conversationCursor);
1153
1154            final ConfigResult result = configureNotifForOneConversation(context, account,
1155                    folderPreferences, notificationBuilder, wearableExtender, conversationCursor,
1156                    notificationIntent, folder, when, res, notificationAccountDisplayName,
1157                    notificationAccountEmail, isInbox, notificationLabelName,
1158                    summaryNotificationId, contactFetcher);
1159            notificationTicker = result.notificationTicker;
1160
1161            if (result.contactIconInfo != null) {
1162                wearableExtender.setBackground(result.contactIconInfo.wearableBg);
1163            } else {
1164                wearableExtender.setBackground(getDefaultWearableBg(context));
1165            }
1166        }
1167
1168        // Build the notification ticker
1169        if (notificationLabelName != null && notificationTicker != null) {
1170            // This is a per label notification, format the ticker with that information
1171            notificationTicker = res.getString(R.string.label_notification_ticker,
1172                    notificationLabelName, notificationTicker);
1173        }
1174
1175        if (notificationTicker != null) {
1176            // If we didn't generate a notification ticker, it will default to account name
1177            notificationBuilder.setTicker(notificationTicker);
1178        }
1179
1180        // Set the number in the notification
1181        if (unreadCount > 1) {
1182            notificationBuilder.setNumber(unreadCount);
1183        }
1184
1185        notificationBuilder.setContentIntent(clickIntent);
1186    }
1187
1188    /**
1189     * Configure the notification for one conversation.  When there are multiple conversations,
1190     * this method is used to configure bundled notification for Android Wear.
1191     */
1192    private static ConfigResult configureNotifForOneConversation(Context context,
1193            Account account, FolderPreferences folderPreferences,
1194            NotificationCompat.Builder notificationBuilder,
1195            NotificationCompat.WearableExtender wearExtender, Cursor conversationCursor,
1196            Intent notificationIntent, Folder folder, long when, Resources res,
1197            String notificationAccountDisplayName, String notificationAccountEmail,
1198            boolean isInbox, String notificationLabelName, int notificationId,
1199            final ContactFetcher contactFetcher) {
1200
1201        final ConfigResult result = new ConfigResult();
1202
1203        final Conversation conversation = new Conversation(conversationCursor);
1204
1205        // Set of all unique senders for unseen messages
1206        final HashSet<String> sendersList = new HashSet<String>();
1207        Cursor cursor = null;
1208        MessageCursor messageCursor = null;
1209        boolean multipleUnseenThread = false;
1210        String from = null;
1211        try {
1212            final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
1213                    UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
1214            cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
1215                    null, null, null);
1216            messageCursor = new MessageCursor(cursor);
1217            // Use the information from the last sender in the conversation that triggered
1218            // this notification.
1219
1220            String fromAddress = "";
1221            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
1222                final Message message = messageCursor.getMessage();
1223                fromAddress = message.getFrom();
1224                if (fromAddress == null) {
1225                    // No sender. Go back to default value.
1226                    LogUtils.e(LOG_TAG, "No sender found for message: %d", message.getId());
1227                    fromAddress = "";
1228                }
1229                from = getDisplayableSender(fromAddress);
1230                result.contactIconInfo = getContactIcon(
1231                        context, account.getAccountManagerAccount().name, from,
1232                        getSenderAddress(fromAddress), folder, contactFetcher);
1233                sendersList.add(getSenderAddress(fromAddress));
1234                notificationBuilder.setLargeIcon(result.contactIconInfo.icon);
1235            }
1236
1237            // Assume that the last message in this conversation is unread
1238            int firstUnseenMessagePos = messageCursor.getPosition();
1239            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
1240                final Message message = messageCursor.getMessage();
1241                final boolean unseen = !message.seen;
1242                if (unseen) {
1243                    firstUnseenMessagePos = messageCursor.getPosition();
1244                    sendersList.add(getSenderAddress(message.getFrom()));
1245                    if (!multipleUnseenThread
1246                            && !fromAddress.contentEquals(message.getFrom())) {
1247                        multipleUnseenThread = true;
1248                    }
1249                }
1250            }
1251
1252            final String subject = ConversationItemView.filterTag(context, conversation.subject);
1253
1254            // TODO(skennedy) Can we remove this check?
1255            if (Utils.isRunningJellybeanOrLater()) {
1256                // For a new-style notification
1257
1258                if (multipleUnseenThread) {
1259                    // The title of a single conversation is the list of senders.
1260                    int sendersLength = res.getInteger(R.integer.swipe_senders_length);
1261
1262                    final SpannableStringBuilder sendersBuilder = getStyledSenders(
1263                            context, conversationCursor, sendersLength,
1264                            notificationAccountEmail);
1265
1266                    notificationBuilder.setContentTitle(sendersBuilder);
1267                    // For a single new conversation, the ticker is based on the sender's name.
1268                    result.notificationTicker = sendersBuilder.toString();
1269                } else {
1270                    from = getWrappedFromString(from);
1271                    // The title of a single message the sender.
1272                    notificationBuilder.setContentTitle(from);
1273                    // For a single new conversation, the ticker is based on the sender's name.
1274                    result.notificationTicker = from;
1275                }
1276
1277                // The notification content will be the subject of the conversation.
1278                notificationBuilder.setContentText(getSingleMessageLittleText(context, subject));
1279
1280                // The notification subtext will be the subject of the conversation for inbox
1281                // notifications, or will based on the the label name for user label
1282                // notifications.
1283                notificationBuilder.setSubText(isInbox ?
1284                        notificationAccountDisplayName : notificationLabelName);
1285
1286                final NotificationCompat.BigTextStyle bigText =
1287                        new NotificationCompat.BigTextStyle(notificationBuilder);
1288
1289                // Seek the message cursor to the first unread message
1290                final Message message;
1291                if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
1292                    message = messageCursor.getMessage();
1293                    bigText.bigText(getSingleMessageBigText(context, subject, message));
1294                } else {
1295                    LogUtils.e(LOG_TAG, "Failed to load message");
1296                    message = null;
1297                }
1298
1299                if (message != null) {
1300                    final Set<String> notificationActions =
1301                            folderPreferences.getNotificationActions(account);
1302
1303                    NotificationActionUtils.addNotificationActions(context, notificationIntent,
1304                            notificationBuilder, wearExtender, account, conversation, message,
1305                            folder, notificationId, when, notificationActions);
1306                }
1307            } else {
1308                // For an old-style notification
1309
1310                // The title of a single conversation notification is built from both the sender
1311                // and subject of the new message.
1312                notificationBuilder.setContentTitle(
1313                        getSingleMessageNotificationTitle(context, from, subject));
1314
1315                // The notification content will be the subject of the conversation for inbox
1316                // notifications, or will based on the the label name for user label
1317                // notifications.
1318                notificationBuilder.setContentText(
1319                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1320
1321                // For a single new conversation, the ticker is based on the sender's name.
1322                result.notificationTicker = from;
1323            }
1324
1325            tagNotificationsWithPeople(context, notificationBuilder, sendersList,
1326                    account.getAccountManagerAccount().name, contactFetcher);
1327        } finally {
1328            if (messageCursor != null) {
1329                messageCursor.close();
1330            }
1331            if (cursor != null) {
1332                cursor.close();
1333            }
1334        }
1335        return result;
1336    }
1337
1338    /**
1339     * Iterates through all senders, retrieves contact lookup Uris for each sender, and tags the
1340     * given notification with these Uris
1341     * @param context
1342     * @param notificationBuilder
1343     * @param sendersList List of unique senders to be tagged with the conversation
1344     * @param accountName
1345     * @param contactFetcher Implementation of ContactLookupUriFetcher (null by default)
1346     */
1347    private static void tagNotificationsWithPeople(Context context,
1348            NotificationCompat.Builder notificationBuilder, HashSet<String> sendersList,
1349            String accountName, ContactFetcher contactFetcher) {
1350        // If there is a ContactLookupUriFetcher, go through all unique senders
1351        // in the combined notification and add each one as a person.
1352        if (contactFetcher != null) {
1353            for (final String sender : sendersList) {
1354                final Uri contactLookupUri =
1355                        contactFetcher.getContactLookupUri(context,
1356                                accountName, sender);
1357
1358                if (contactLookupUri != null) {
1359                    notificationBuilder.addPerson(contactLookupUri.toString());
1360                }
1361            }
1362
1363        // If implementation for the fetcher is not provided, rely on the ContentResolver
1364        // to query for contacts and tag the notification
1365        } else {
1366            findAndTagContacts(context, sendersList, notificationBuilder);
1367        }
1368    }
1369
1370    /**
1371     * Queries for contact id and lookup key to tag the notification with Person objects
1372     * based on the addresses given.
1373     * @param context
1374     * @param addresses set of addresses to tag the single notification with
1375     * @param notificationBuilder
1376     */
1377    private static void findAndTagContacts(Context context,
1378            HashSet<String> addresses, NotificationCompat.Builder notificationBuilder) {
1379        final ArrayList<String> whereArgs = new ArrayList<String>(addresses);
1380        final StringBuilder whereBuilder = new StringBuilder();
1381        final String[] questionMarks = new String[addresses.size()];
1382
1383        Arrays.fill(questionMarks, "?");
1384        whereBuilder.append(Email.DATA1 + " IN (").
1385                append(TextUtils.join(",", questionMarks)).
1386                append(")");
1387
1388        final ContentResolver resolver = context.getContentResolver();
1389        final Cursor c = resolver.query(Email.CONTENT_URI,
1390                new String[] {Email.CONTACT_ID, ContactsContract.Contacts.LOOKUP_KEY},
1391                whereBuilder.toString(),
1392                whereArgs.toArray(new String[0]), null);
1393
1394        if (c == null) {
1395            // No query results - no contacts to tag
1396            return;
1397        }
1398
1399        final int contactIdCol = c.getColumnIndex(Email.CONTACT_ID);
1400        final int lookupKeyCol = c.getColumnIndex(ContactsContract.Contacts.LOOKUP_KEY);
1401
1402        try {
1403            while (c.moveToNext()) {
1404                c.getString(lookupKeyCol);
1405
1406                // Get lookup uri based on id and lookup key
1407                final Uri contactLookupUri = ContactsContract.Contacts.getLookupUri(
1408                        c.getLong(contactIdCol), c.getString(lookupKeyCol));
1409
1410                // Add Person if uri is found
1411                if (contactLookupUri != null) {
1412                    notificationBuilder.addPerson(contactLookupUri.toString());
1413                }
1414            }
1415        } finally {
1416            c.close();
1417        }
1418    }
1419
1420    private static String getWrappedFromString(String from) {
1421        if (from == null) {
1422            LogUtils.e(LOG_TAG, "null from string in getWrappedFromString");
1423            from = "";
1424        }
1425        from = sBidiFormatter.unicodeWrap(from);
1426        return from;
1427    }
1428
1429    private static SpannableStringBuilder getStyledSenders(final Context context,
1430            final Cursor conversationCursor, final int maxLength, final String account) {
1431        final Conversation conversation = new Conversation(conversationCursor);
1432        final com.android.mail.providers.ConversationInfo conversationInfo =
1433                conversation.conversationInfo;
1434        final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
1435        if (sNotificationUnreadStyleSpan == null) {
1436            sNotificationUnreadStyleSpan = new TextAppearanceSpan(
1437                    context, R.style.NotificationSendersUnreadTextAppearance);
1438            sNotificationReadStyleSpan =
1439                    new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
1440        }
1441        SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
1442                sNotificationUnreadStyleSpan, sNotificationReadStyleSpan,
1443                false /* showToHeader */, false /* resourceCachingRequired */);
1444
1445        return ellipsizeStyledSenders(context, senders);
1446    }
1447
1448    private static String sSendersSplitToken = null;
1449    private static String sElidedPaddingToken = null;
1450
1451    private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
1452            ArrayList<SpannableString> styledSenders) {
1453        if (sSendersSplitToken == null) {
1454            sSendersSplitToken = context.getString(R.string.senders_split_token);
1455            sElidedPaddingToken = context.getString(R.string.elided_padding_token);
1456        }
1457
1458        SpannableStringBuilder builder = new SpannableStringBuilder();
1459        SpannableString prevSender = null;
1460        for (SpannableString sender : styledSenders) {
1461            if (sender == null) {
1462                LogUtils.e(LOG_TAG, "null sender iterating over styledSenders");
1463                continue;
1464            }
1465            CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
1466            if (SendersView.sElidedString.equals(sender.toString())) {
1467                prevSender = sender;
1468                sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
1469            } else if (builder.length() > 0
1470                    && (prevSender == null || !SendersView.sElidedString.equals(prevSender
1471                            .toString()))) {
1472                prevSender = sender;
1473                sender = copyStyles(spans, sSendersSplitToken + sender);
1474            } else {
1475                prevSender = sender;
1476            }
1477            builder.append(sender);
1478        }
1479        return builder;
1480    }
1481
1482    private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
1483        SpannableString s = new SpannableString(newText);
1484        if (spans != null && spans.length > 0) {
1485            s.setSpan(spans[0], 0, s.length(), 0);
1486        }
1487        return s;
1488    }
1489
1490    /**
1491     * Seeks the cursor to the position of the most recent unread conversation. If no unread
1492     * conversation is found, the position of the cursor will be restored, and false will be
1493     * returned.
1494     */
1495    private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
1496        final int initialPosition = cursor.getPosition();
1497        do {
1498            final Conversation conversation = new Conversation(cursor);
1499            if (!conversation.read) {
1500                return true;
1501            }
1502        } while (cursor.moveToNext());
1503
1504        // Didn't find an unread conversation, reset the position.
1505        cursor.moveToPosition(initialPosition);
1506        return false;
1507    }
1508
1509    /**
1510     * Sets the bigtext for a notification for a single new conversation
1511     *
1512     * @param context
1513     * @param senders Sender of the new message that triggered the notification.
1514     * @param subject Subject of the new message that triggered the notification
1515     * @param snippet Snippet of the new message that triggered the notification
1516     * @return a {@link CharSequence} suitable for use in
1517     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1518     */
1519    private static CharSequence getSingleMessageInboxLine(Context context,
1520            String senders, String subject, String snippet) {
1521        // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1522
1523        final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1524
1525        final TextAppearanceSpan notificationPrimarySpan =
1526                new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1527
1528        if (TextUtils.isEmpty(senders)) {
1529            // If the senders are empty, just use the subject/snippet.
1530            return subjectSnippet;
1531        } else if (TextUtils.isEmpty(subjectSnippet)) {
1532            // If the subject/snippet is empty, just use the senders.
1533            final SpannableString spannableString = new SpannableString(senders);
1534            spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1535
1536            return spannableString;
1537        } else {
1538            final String formatString = context.getResources().getString(
1539                    R.string.multiple_new_message_notification_item);
1540            final TextAppearanceSpan notificationSecondarySpan =
1541                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1542
1543            // senders is already individually unicode wrapped so it does not need to be done here
1544            final String instantiatedString = String.format(formatString,
1545                    senders,
1546                    sBidiFormatter.unicodeWrap(subjectSnippet));
1547
1548            final SpannableString spannableString = new SpannableString(instantiatedString);
1549
1550            final boolean isOrderReversed = formatString.indexOf("%2$s") <
1551                    formatString.indexOf("%1$s");
1552            final int primaryOffset =
1553                    (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1554                     instantiatedString.indexOf(senders));
1555            final int secondaryOffset =
1556                    (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1557                     instantiatedString.indexOf(subjectSnippet));
1558            spannableString.setSpan(notificationPrimarySpan,
1559                    primaryOffset, primaryOffset + senders.length(), 0);
1560            spannableString.setSpan(notificationSecondarySpan,
1561                    secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1562            return spannableString;
1563        }
1564    }
1565
1566    /**
1567     * Sets the bigtext for a notification for a single new conversation
1568     * @param context
1569     * @param subject Subject of the new message that triggered the notification
1570     * @return a {@link CharSequence} suitable for use in
1571     * {@link NotificationCompat.Builder#setContentText}
1572     */
1573    private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1574        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1575                context, R.style.NotificationPrimaryText);
1576
1577        final SpannableString spannableString = new SpannableString(subject);
1578        spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1579
1580        return spannableString;
1581    }
1582
1583    /**
1584     * Sets the bigtext for a notification for a single new conversation
1585     *
1586     * @param context
1587     * @param subject Subject of the new message that triggered the notification
1588     * @param message the {@link Message} to be displayed.
1589     * @return a {@link CharSequence} suitable for use in
1590     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1591     */
1592    private static CharSequence getSingleMessageBigText(Context context, String subject,
1593            final Message message) {
1594
1595        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1596                context, R.style.NotificationPrimaryText);
1597
1598        final String snippet = getMessageBodyWithoutElidedText(message);
1599
1600        // Change multiple newlines (with potential white space between), into a single new line
1601        final String collapsedSnippet =
1602                !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1603
1604        if (TextUtils.isEmpty(subject)) {
1605            // If the subject is empty, just use the snippet.
1606            return snippet;
1607        } else if (TextUtils.isEmpty(collapsedSnippet)) {
1608            // If the snippet is empty, just use the subject.
1609            final SpannableString spannableString = new SpannableString(subject);
1610            spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1611
1612            return spannableString;
1613        } else {
1614            final String notificationBigTextFormat = context.getResources().getString(
1615                    R.string.single_new_message_notification_big_text);
1616
1617            // Localizers may change the order of the parameters, look at how the format
1618            // string is structured.
1619            final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1620                    notificationBigTextFormat.indexOf("%1$s");
1621            final String bigText =
1622                    String.format(notificationBigTextFormat, subject, collapsedSnippet);
1623            final SpannableString spannableString = new SpannableString(bigText);
1624
1625            final int subjectOffset =
1626                    (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1627            spannableString.setSpan(notificationSubjectSpan,
1628                    subjectOffset, subjectOffset + subject.length(), 0);
1629
1630            return spannableString;
1631        }
1632    }
1633
1634    /**
1635     * Gets the title for a notification for a single new conversation
1636     * @param context
1637     * @param sender Sender of the new message that triggered the notification.
1638     * @param subject Subject of the new message that triggered the notification
1639     * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1640     */
1641    private static CharSequence getSingleMessageNotificationTitle(Context context,
1642            String sender, String subject) {
1643
1644        if (TextUtils.isEmpty(subject)) {
1645            // If the subject is empty, just set the title to the sender's information.
1646            return sender;
1647        } else {
1648            final String notificationTitleFormat = context.getResources().getString(
1649                    R.string.single_new_message_notification_title);
1650
1651            // Localizers may change the order of the parameters, look at how the format
1652            // string is structured.
1653            final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1654                    notificationTitleFormat.indexOf("%1$s");
1655            final String titleString = String.format(notificationTitleFormat, sender, subject);
1656
1657            // Format the string so the subject is using the secondaryText style
1658            final SpannableString titleSpannable = new SpannableString(titleString);
1659
1660            // Find the offset of the subject.
1661            final int subjectOffset =
1662                    isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1663            final TextAppearanceSpan notificationSubjectSpan =
1664                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1665            titleSpannable.setSpan(notificationSubjectSpan,
1666                    subjectOffset, subjectOffset + subject.length(), 0);
1667            return titleSpannable;
1668        }
1669    }
1670
1671    /**
1672     * Clears the notifications for the specified account/folder.
1673     */
1674    public static void clearFolderNotification(Context context, Account account, Folder folder,
1675            final boolean markSeen) {
1676        LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.getEmailAddress(),
1677                folder.name);
1678        final NotificationMap notificationMap = getNotificationMap(context);
1679        final NotificationKey key = new NotificationKey(account, folder);
1680        notificationMap.remove(key);
1681        notificationMap.saveNotificationMap(context);
1682
1683        final NotificationManagerCompat notificationManager =
1684                NotificationManagerCompat.from(context);
1685        notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder));
1686
1687        cancelConversationNotifications(key, notificationManager);
1688
1689        if (markSeen) {
1690            markSeen(context, folder);
1691        }
1692    }
1693
1694    /**
1695     * Use content resolver to update a conversation.  Should not be called from a main thread.
1696     */
1697    public static void markConversationAsReadAndSeen(Context context, Uri conversationUri) {
1698        LogUtils.v(LOG_TAG, "markConversationAsReadAndSeen=%s", conversationUri);
1699
1700        final ContentValues values = new ContentValues(2);
1701        values.put(UIProvider.ConversationColumns.SEEN, Boolean.TRUE);
1702        values.put(UIProvider.ConversationColumns.READ, Boolean.TRUE);
1703        context.getContentResolver().update(conversationUri, values, null, null);
1704    }
1705
1706    /**
1707     * Clears all notifications for the specified account.
1708     */
1709    public static void clearAccountNotifications(final Context context,
1710            final android.accounts.Account account) {
1711        LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account);
1712        final NotificationMap notificationMap = getNotificationMap(context);
1713
1714        // Find all NotificationKeys for this account
1715        final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder();
1716
1717        for (final NotificationKey key : notificationMap.keySet()) {
1718            if (account.equals(key.account.getAccountManagerAccount())) {
1719                keyBuilder.add(key);
1720            }
1721        }
1722
1723        final List<NotificationKey> notificationKeys = keyBuilder.build();
1724
1725        final NotificationManagerCompat notificationManager =
1726                NotificationManagerCompat.from(context);
1727
1728        for (final NotificationKey notificationKey : notificationKeys) {
1729            final Folder folder = notificationKey.folder;
1730            notificationManager.cancel(getNotificationId(account, folder));
1731            notificationMap.remove(notificationKey);
1732
1733            cancelConversationNotifications(notificationKey, notificationManager);
1734        }
1735
1736        notificationMap.saveNotificationMap(context);
1737    }
1738
1739    private static void cancelConversationNotifications(NotificationKey key,
1740            NotificationManagerCompat nm) {
1741        final Set<Integer> conversationNotifications = sConversationNotificationMap.get(key);
1742        if (conversationNotifications != null) {
1743            for (Integer conversationNotification : conversationNotifications) {
1744                nm.cancel(conversationNotification);
1745            }
1746            sConversationNotificationMap.remove(key);
1747        }
1748    }
1749
1750    private static ContactIconInfo getContactIcon(final Context context, String accountName,
1751            final String displayName, final String senderAddress, final Folder folder,
1752            final ContactFetcher contactFetcher) {
1753        if (Looper.myLooper() == Looper.getMainLooper()) {
1754            throw new IllegalStateException(
1755                    "getContactIcon should not be called on the main thread.");
1756        }
1757
1758        final ContactIconInfo contactIconInfo;
1759        if (TextUtils.isEmpty(senderAddress)) {
1760            contactIconInfo = new ContactIconInfo();
1761        } else {
1762            // Get the ideal size for this icon.
1763            final Resources res = context.getResources();
1764            final int idealIconHeight =
1765                    res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1766            final int idealIconWidth =
1767                    res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1768            final int idealWearableBgWidth =
1769                    res.getDimensionPixelSize(R.dimen.wearable_background_width);
1770            final int idealWearableBgHeight =
1771                    res.getDimensionPixelSize(R.dimen.wearable_background_height);
1772
1773            if (contactFetcher != null) {
1774                contactIconInfo = contactFetcher.getContactPhoto(context, accountName,
1775                        senderAddress, idealIconWidth, idealIconHeight, idealWearableBgWidth,
1776                        idealWearableBgHeight);
1777            } else {
1778                contactIconInfo = getContactInfo(context, senderAddress, idealIconWidth,
1779                        idealIconHeight, idealWearableBgWidth, idealWearableBgHeight);
1780            }
1781
1782            if (contactIconInfo.icon == null) {
1783                // Make a colorful tile!
1784                final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight,
1785                        Dimensions.SCALE_ONE);
1786
1787                contactIconInfo.icon = new LetterTileProvider(context.getResources())
1788                        .getLetterTile(dimensions, displayName, senderAddress);
1789            }
1790
1791            // Only turn the square photo/letter tile into a circle for L and later
1792            if (Utils.isRunningLOrLater()) {
1793                contactIconInfo.icon = BitmapUtil.frameBitmapInCircle(contactIconInfo.icon);
1794            }
1795        }
1796
1797        if (contactIconInfo.icon == null) {
1798            // Use anonymous icon due to lack of sender
1799            contactIconInfo.icon = getIcon(context,
1800                    R.drawable.ic_notification_anonymous_avatar_32dp);
1801        }
1802
1803        if (contactIconInfo.wearableBg == null) {
1804            contactIconInfo.wearableBg = getDefaultWearableBg(context);
1805        }
1806
1807        return contactIconInfo;
1808    }
1809
1810    private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1811        ArrayList<String> whereArgs = new ArrayList<String>();
1812        StringBuilder whereBuilder = new StringBuilder();
1813        String[] questionMarks = new String[addresses.size()];
1814
1815        whereArgs.addAll(addresses);
1816        Arrays.fill(questionMarks, "?");
1817        whereBuilder.append(Email.DATA1 + " IN (").
1818                append(TextUtils.join(",", questionMarks)).
1819                append(")");
1820
1821        ContentResolver resolver = context.getContentResolver();
1822        Cursor c = resolver.query(Email.CONTENT_URI,
1823                new String[] {Email.CONTACT_ID}, whereBuilder.toString(),
1824                whereArgs.toArray(new String[0]), null);
1825
1826        ArrayList<Long> contactIds = new ArrayList<Long>();
1827        if (c == null) {
1828            return contactIds;
1829        }
1830        try {
1831            while (c.moveToNext()) {
1832                contactIds.add(c.getLong(0));
1833            }
1834        } finally {
1835            c.close();
1836        }
1837        return contactIds;
1838    }
1839
1840    public static ContactIconInfo getContactInfo(
1841            final Context context, final String senderAddress,
1842            final int idealIconWidth, final int idealIconHeight,
1843            final int idealWearableBgWidth, final int idealWearableBgHeight) {
1844        final ContactIconInfo contactIconInfo = new ContactIconInfo();
1845        final List<Long> contactIds = findContacts(context, Arrays.asList(
1846                new String[]{senderAddress}));
1847
1848        if (contactIds != null) {
1849            for (final long id : contactIds) {
1850                final Uri contactUri = ContentUris.withAppendedId(
1851                        ContactsContract.Contacts.CONTENT_URI, id);
1852                final InputStream inputStream =
1853                        ContactsContract.Contacts.openContactPhotoInputStream(
1854                                context.getContentResolver(), contactUri, true /*preferHighres*/);
1855
1856                if (inputStream != null) {
1857                    try {
1858                        final Bitmap source = BitmapFactory.decodeStream(inputStream);
1859                        if (source != null) {
1860                            // We should scale this image to fit the intended size
1861                            contactIconInfo.icon = Bitmap.createScaledBitmap(source, idealIconWidth,
1862                                    idealIconHeight, true);
1863
1864                            contactIconInfo.wearableBg = Bitmap.createScaledBitmap(source,
1865                                    idealWearableBgWidth, idealWearableBgHeight, true);
1866                        }
1867
1868                        if (contactIconInfo.icon != null) {
1869                            break;
1870                        }
1871                    } finally {
1872                        Closeables.closeQuietly(inputStream);
1873                    }
1874                }
1875            }
1876        }
1877
1878        return contactIconInfo;
1879    }
1880
1881    private static String getMessageBodyWithoutElidedText(final Message message) {
1882        return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
1883    }
1884
1885    public static String getMessageBodyWithoutElidedText(String html) {
1886        if (TextUtils.isEmpty(html)) {
1887            return "";
1888        }
1889        // Get the html "tree" for this message body
1890        final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1891        htmlTree.setConverterFactory(MESSAGE_CONVERTER_FACTORY);
1892
1893        return htmlTree.getPlainText();
1894    }
1895
1896    public static void markSeen(final Context context, final Folder folder) {
1897        final Uri uri = folder.folderUri.fullUri;
1898
1899        final ContentValues values = new ContentValues(1);
1900        values.put(UIProvider.ConversationColumns.SEEN, 1);
1901
1902        context.getContentResolver().update(uri, values, null, null);
1903    }
1904
1905    /**
1906     * Returns a displayable string representing
1907     * the message sender. It has a preference toward showing the name,
1908     * but will fall back to the address if that is all that is available.
1909     */
1910    private static String getDisplayableSender(String sender) {
1911        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1912
1913        String displayableSender = address.getName();
1914
1915        if (!TextUtils.isEmpty(displayableSender)) {
1916            return Address.decodeAddressPersonal(displayableSender);
1917        }
1918
1919        // If that fails, default to the sender address.
1920        displayableSender = address.getAddress();
1921
1922        // If we were unable to tokenize a name or address,
1923        // just use whatever was in the sender.
1924        if (TextUtils.isEmpty(displayableSender)) {
1925            displayableSender = sender;
1926        }
1927        return displayableSender;
1928    }
1929
1930    /**
1931     * Returns only the address portion of a message sender.
1932     */
1933    private static String getSenderAddress(String sender) {
1934        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1935
1936        String tokenizedAddress = address.getAddress();
1937
1938        // If we were unable to tokenize a name or address,
1939        // just use whatever was in the sender.
1940        if (TextUtils.isEmpty(tokenizedAddress)) {
1941            tokenizedAddress = sender;
1942        }
1943        return tokenizedAddress;
1944    }
1945
1946    public static int getNotificationId(final android.accounts.Account account,
1947            final Folder folder) {
1948        return 1 ^ account.hashCode() ^ folder.hashCode();
1949    }
1950
1951    private static int getNotificationId(int summaryNotificationId, int conversationHashCode) {
1952        return summaryNotificationId ^ conversationHashCode;
1953    }
1954
1955    private static class NotificationKey {
1956        public final Account account;
1957        public final Folder folder;
1958
1959        public NotificationKey(Account account, Folder folder) {
1960            this.account = account;
1961            this.folder = folder;
1962        }
1963
1964        @Override
1965        public boolean equals(Object other) {
1966            if (!(other instanceof NotificationKey)) {
1967                return false;
1968            }
1969            NotificationKey key = (NotificationKey) other;
1970            return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount())
1971                    && folder.equals(key.folder);
1972        }
1973
1974        @Override
1975        public String toString() {
1976            return account.getDisplayName() + " " + folder.name;
1977        }
1978
1979        @Override
1980        public int hashCode() {
1981            final int accountHashCode = account.getAccountManagerAccount().hashCode();
1982            final int folderHashCode = folder.hashCode();
1983            return accountHashCode ^ folderHashCode;
1984        }
1985    }
1986
1987    /**
1988     * Contains the logic for converting the contents of one HtmlTree into
1989     * plaintext.
1990     */
1991    public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1992        // Strings for parsing html message bodies
1993        private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1994        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1995        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1996
1997        private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1998                new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1999
2000        private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
2001                HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
2002
2003        private int mEndNodeElidedTextBlock = -1;
2004
2005        @Override
2006        public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
2007            // If we are in the middle of an elided text block, don't add this node
2008            if (nodeNum < mEndNodeElidedTextBlock) {
2009                return;
2010            } else if (nodeNum == mEndNodeElidedTextBlock) {
2011                super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
2012                return;
2013            }
2014
2015            // If this tag starts another elided text block, we want to remember the end
2016            if (n instanceof HtmlDocument.Tag) {
2017                boolean foundElidedTextTag = false;
2018                final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
2019                final HTML.Element htmlElement = htmlTag.getElement();
2020                if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
2021                    // Make sure that the class is what is expected
2022                    final List<HtmlDocument.TagAttribute> attributes =
2023                            htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
2024                    for (HtmlDocument.TagAttribute attribute : attributes) {
2025                        if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
2026                                attribute.getValue())) {
2027                            // Found an "elided-text" div.  Remember information about this tag
2028                            mEndNodeElidedTextBlock = endNum;
2029                            foundElidedTextTag = true;
2030                            break;
2031                        }
2032                    }
2033                }
2034
2035                if (foundElidedTextTag) {
2036                    return;
2037                }
2038            }
2039
2040            super.addNode(n, nodeNum, endNum);
2041        }
2042    }
2043
2044    /**
2045     * During account setup in Email, we may not have an inbox yet, so the notification setting had
2046     * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
2047     * {@link FolderPreferences} now.
2048     */
2049    public static void moveNotificationSetting(final AccountPreferences accountPreferences,
2050            final FolderPreferences folderPreferences) {
2051        if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
2052            // If this setting has been changed some other way, don't overwrite it
2053            if (!folderPreferences.isNotificationsEnabledSet()) {
2054                final boolean notificationsEnabled =
2055                        accountPreferences.getDefaultInboxNotificationsEnabled();
2056
2057                folderPreferences.setNotificationsEnabled(notificationsEnabled);
2058            }
2059
2060            accountPreferences.clearDefaultInboxNotificationsEnabled();
2061        }
2062    }
2063
2064    private static class NotificationBuilders {
2065        public final NotificationCompat.Builder notifBuilder;
2066        public final NotificationCompat.WearableExtender wearableNotifBuilder;
2067
2068        private NotificationBuilders(NotificationCompat.Builder notifBuilder,
2069                NotificationCompat.WearableExtender wearableNotifBuilder) {
2070            this.notifBuilder = notifBuilder;
2071            this.wearableNotifBuilder = wearableNotifBuilder;
2072        }
2073
2074        public static NotificationBuilders of(NotificationCompat.Builder notifBuilder,
2075                NotificationCompat.WearableExtender wearableNotifBuilder) {
2076            return new NotificationBuilders(notifBuilder, wearableNotifBuilder);
2077        }
2078    }
2079
2080    private static class ConfigResult {
2081        public String notificationTicker;
2082        public ContactIconInfo contactIconInfo;
2083    }
2084
2085    public static class ContactIconInfo {
2086        public Bitmap icon;
2087        public Bitmap wearableBg;
2088    }
2089}
2090