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