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