NotificationUtils.java revision 24cfa0778a966620112a5b2ca90b70c8ab8e40d9
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
634            if (com.android.mail.utils.Utils.isRunningLOrLater()) {
635                notification.setColor(
636                        context.getResources().getColor(R.color.notification_icon_gmail_red));
637            }
638            // TODO(shahrk) - fix for multiple mail
639            // if(folder.notificationIconResId != 0 || unseenCount <=  2)
640            notification.setSmallIcon(R.drawable.ic_notification_mail_24dp);
641            notification.setTicker(account.getDisplayName());
642            notification.setVisibility(NotificationCompat.VISIBILITY_PRIVATE);
643
644            final long when;
645
646            final long oldWhen =
647                    NotificationActionUtils.sNotificationTimestamps.get(notificationId);
648            if (oldWhen != 0) {
649                when = oldWhen;
650            } else {
651                when = System.currentTimeMillis();
652            }
653
654            notification.setWhen(when);
655
656            // The timestamp is now stored in the notification, so we can remove it from here
657            NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
658
659            // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a
660            // notification.  Also this intent gets fired when the user taps on a notification as
661            // the AutoCancel flag has been set
662            final Intent cancelNotificationIntent =
663                    new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
664            cancelNotificationIntent.setPackage(context.getPackageName());
665            cancelNotificationIntent.setData(Utils.appendVersionQueryParameter(context,
666                    folder.folderUri.fullUri));
667            cancelNotificationIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
668            cancelNotificationIntent.putExtra(Utils.EXTRA_FOLDER, folder);
669
670            notification.setDeleteIntent(PendingIntent.getService(
671                    context, notificationId, cancelNotificationIntent, 0));
672
673            // Ensure that the notification is cleared when the user selects it
674            notification.setAutoCancel(true);
675
676            boolean eventInfoConfigured = false;
677
678            final boolean isInbox = folder.folderUri.equals(account.settings.defaultInbox);
679            final FolderPreferences folderPreferences =
680                    new FolderPreferences(context, account.getAccountId(), folder, isInbox);
681
682            if (isInbox) {
683                final AccountPreferences accountPreferences =
684                        new AccountPreferences(context, account.getAccountId());
685                moveNotificationSetting(accountPreferences, folderPreferences);
686            }
687
688            if (!folderPreferences.areNotificationsEnabled()) {
689                LogUtils.i(LOG_TAG, "Notifications are disabled for this folder; not notifying");
690                // Don't notify
691                return;
692            }
693
694            if (unreadCount > 0) {
695                // How can I order this properly?
696                if (cursor.moveToNext()) {
697                    final Intent notificationIntent;
698
699                    // Launch directly to the conversation, if there is only 1 unseen conversation
700                    final boolean launchConversationMode = (unseenCount == 1);
701                    if (launchConversationMode) {
702                        notificationIntent = createViewConversationIntent(context, account, folder,
703                                cursor);
704                    } else {
705                        notificationIntent = createViewConversationIntent(context, account, folder,
706                                null);
707                    }
708
709                    Analytics.getInstance().sendEvent("notification_create",
710                            launchConversationMode ? "conversation" : "conversation_list",
711                            folder.getTypeDescription(), unseenCount);
712
713                    if (notificationIntent == null) {
714                        LogUtils.e(LOG_TAG, "Null intent when building notification");
715                        return;
716                    }
717
718                    clickIntent = createClickPendingIntent(context, notificationIntent);
719
720                    configureLatestEventInfoFromConversation(context, account, folderPreferences,
721                            notification, wearableExtender, msgNotifications, notificationId,
722                            cursor, clickIntent, notificationIntent, unreadCount, unseenCount,
723                            folder, when, photoFetcher);
724                    eventInfoConfigured = true;
725                }
726            }
727
728            final boolean vibrate = folderPreferences.isNotificationVibrateEnabled();
729            final String ringtoneUri = folderPreferences.getNotificationRingtoneUri();
730            final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled();
731
732            if (!ignoreUnobtrusiveSetting && notifyOnce) {
733                // If the user has "unobtrusive notifications" enabled, only alert the first time
734                // new mail is received in this account.  This is the default behavior.  See
735                // bugs 2412348 and 2413490.
736                LogUtils.d(LOG_TAG, "Setting Alert Once");
737                notification.setOnlyAlertOnce(true);
738            }
739
740            LogUtils.i(LOG_TAG, "Account: %s vibrate: %s",
741                    LogUtils.sanitizeName(LOG_TAG, account.getEmailAddress()),
742                    Boolean.toString(folderPreferences.isNotificationVibrateEnabled()));
743
744            int defaults = 0;
745
746            // Check if any current conversation notifications exist previously.  Only notify if
747            // one of them is new.
748            boolean hasNewConversationNotification;
749            Set<Integer> prevConversationNotifications =
750                    sConversationNotificationMap.get(notificationKey);
751            if (prevConversationNotifications != null) {
752                hasNewConversationNotification = false;
753                for (Integer currentNotificationId : msgNotifications.keySet()) {
754                    if (!prevConversationNotifications.contains(currentNotificationId)) {
755                        hasNewConversationNotification = true;
756                        break;
757                    }
758                }
759            } else {
760                hasNewConversationNotification = true;
761            }
762
763            LogUtils.d(LOG_TAG, "getAttention=%s,oldWhen=%s,hasNewConversationNotification=%s",
764                    getAttention, oldWhen, hasNewConversationNotification);
765
766            /*
767             * We do not want to notify if this is coming back from an Undo notification, hence the
768             * oldWhen check.
769             */
770            if (getAttention && oldWhen == 0 && hasNewConversationNotification) {
771                final AccountPreferences accountPreferences =
772                        new AccountPreferences(context, account.getAccountId());
773                if (accountPreferences.areNotificationsEnabled()) {
774                    if (vibrate) {
775                        defaults |= Notification.DEFAULT_VIBRATE;
776                    }
777
778                    notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null
779                            : Uri.parse(ringtoneUri));
780                    LogUtils.i(LOG_TAG, "New email in %s vibrateWhen: %s, playing notification: %s",
781                            LogUtils.sanitizeName(LOG_TAG, account.getEmailAddress()), vibrate,
782                            ringtoneUri);
783                }
784            }
785
786            // TODO(skennedy) Why do we do any of the above if we're just going to bail here?
787            if (eventInfoConfigured) {
788                defaults |= Notification.DEFAULT_LIGHTS;
789                notification.setDefaults(defaults);
790
791                if (oldWhen != 0) {
792                    // We do not want to display the ticker again if we are re-displaying this
793                    // notification (like from an Undo notification)
794                    notification.setTicker(null);
795                }
796
797                notification.extend(wearableExtender);
798
799                // create the *public* form of the *private* notification we have been assembling
800                final Notification publicNotification = createPublicNotification(context, account,
801                        folder, when, unseenCount, unreadCount, clickIntent);
802
803                notification.setPublicVersion(publicNotification);
804
805                nm.notify(notificationId, notification.build());
806
807                if (prevConversationNotifications != null) {
808                    Set<Integer> currentNotificationIds = msgNotifications.keySet();
809                    for (Integer prevConversationNotificationId : prevConversationNotifications) {
810                        if (!currentNotificationIds.contains(prevConversationNotificationId)) {
811                            nm.cancel(prevConversationNotificationId);
812                            LogUtils.d(LOG_TAG, "canceling conversation notification %s",
813                                    prevConversationNotificationId);
814                        }
815                    }
816                }
817
818                for (Map.Entry<Integer, NotificationBuilders> entry : msgNotifications.entrySet()) {
819                    NotificationBuilders builders = entry.getValue();
820                    builders.notifBuilder.extend(builders.wearableNotifBuilder);
821                    nm.notify(entry.getKey(), builders.notifBuilder.build());
822                    LogUtils.d(LOG_TAG, "notifying conversation notification %s", entry.getKey());
823                }
824
825                Set<Integer> conversationNotificationIds = new HashSet<Integer>();
826                conversationNotificationIds.addAll(msgNotifications.keySet());
827                sConversationNotificationMap.put(notificationKey, conversationNotificationIds);
828            } else {
829                LogUtils.i(LOG_TAG, "event info not configured - not notifying");
830            }
831        } finally {
832            if (cursor != null) {
833                cursor.close();
834            }
835        }
836    }
837
838    /**
839     * Build and return a redacted form of a notification using the given information. This redacted
840     * form is shown above the lock screen and is devoid of sensitive information.
841     *
842     * @param context a context used to construct the notification
843     * @param account the account for which the notification is being generated
844     * @param folder the folder for which the notification is being generated
845     * @param when the timestamp of the notification
846     * @param unseenCount the number of unseen messages
847     * @param unreadCount the number of unread messages
848     * @param clickIntent the behavior to invoke if the notification is tapped (note that the user
849     *                    will be prompted to unlock the device before the behavior is executed)
850     * @return the redacted form of the notification to display above the lock screen
851     */
852    private static Notification createPublicNotification(Context context, Account account,
853            Folder folder, long when, int unseenCount, int unreadCount, PendingIntent clickIntent) {
854        final boolean multipleUnseen = unseenCount > 1;
855        final Bitmap largeIcon = getDefaultNotificationIcon(context, folder, multipleUnseen);
856
857        final NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
858                .setContentTitle(createTitle(context, unseenCount))
859                .setContentText(account.getDisplayName())
860                .setContentIntent(clickIntent)
861                .setLargeIcon(largeIcon)
862                .setNumber(unreadCount)
863                .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
864                .setWhen(when);
865
866        if (com.android.mail.utils.Utils.isRunningLOrLater()) {
867            builder.setColor(context.getResources().getColor(R.color.notification_icon_gmail_red));
868        }
869
870        // if this public notification summarizes multiple single notifications, mark it as the
871        // summary notification and generate the same group key as the single notifications
872        if (multipleUnseen) {
873            builder.setGroup(createGroupKey(account, folder));
874            builder.setGroupSummary(true);
875        }
876
877        // TODO(shahrk) - fix for multiple mail
878        // If the folder is a special label or only has 1 unseen, tack on the badge
879        // if (folder.notificationIconResId != 0 || !multipleUnseen) {
880        builder.setSmallIcon(R.drawable.ic_notification_mail_24dp);
881
882
883        return builder.build();
884    }
885
886    /**
887     * @param account the account in which the unread email resides
888     * @param folder the folder in which the unread email resides
889     * @return a key that groups notifications with common accounts and folders
890     */
891    private static String createGroupKey(Account account, Folder folder) {
892        return account.uri.toString() + "/" + folder.folderUri.fullUri;
893    }
894
895    /**
896     * @param context a context used to construct the title
897     * @param unseenCount the number of unseen messages
898     * @return e.g. "1 new message" or "2 new messages"
899     */
900    private static String createTitle(Context context, int unseenCount) {
901        final Resources resources = context.getResources();
902        return resources.getQuantityString(R.plurals.new_messages, unseenCount, unseenCount);
903    }
904
905    private static PendingIntent createClickPendingIntent(Context context,
906            Intent notificationIntent) {
907        // Amend the click intent with a hint that its source was a notification,
908        // but remove the hint before it's used to generate notification action
909        // intents. This prevents the following sequence:
910        // 1. generate single notification
911        // 2. user clicks reply, then completes Compose activity
912        // 3. main activity launches, gets FROM_NOTIFICATION hint in intent
913        notificationIntent.putExtra(Utils.EXTRA_FROM_NOTIFICATION, true);
914        PendingIntent clickIntent = PendingIntent.getActivity(context, -1, notificationIntent,
915                PendingIntent.FLAG_UPDATE_CURRENT);
916        notificationIntent.removeExtra(Utils.EXTRA_FROM_NOTIFICATION);
917        return clickIntent;
918    }
919
920    /**
921     * @return an {@link Intent} which, if launched, will display the corresponding conversation
922     */
923    private static Intent createViewConversationIntent(final Context context, final Account account,
924            final Folder folder, final Cursor cursor) {
925        if (folder == null || account == null) {
926            LogUtils.e(LOG_TAG, "createViewConversationIntent(): "
927                    + "Null account or folder.  account: %s folder: %s", account, folder);
928            return null;
929        }
930
931        final Intent intent;
932
933        if (cursor == null) {
934            intent = Utils.createViewFolderIntent(context, folder.folderUri.fullUri, account);
935        } else {
936            // A conversation cursor has been specified, so this intent is intended to be go
937            // directly to the one new conversation
938
939            // Get the Conversation object
940            final Conversation conversation = new Conversation(cursor);
941            intent = Utils.createViewConversationIntent(context, conversation,
942                    folder.folderUri.fullUri, account);
943        }
944
945        return intent;
946    }
947
948    private static Bitmap getDefaultNotificationIcon(
949            final Context context, final Folder folder, final boolean multipleNew) {
950        final int resId;
951        if (folder.notificationIconResId != 0) {
952            resId = folder.notificationIconResId;
953        } else if (multipleNew) {
954            resId = R.drawable.ic_notification_multiple_mail_24dp;
955        } else {
956            resId = R.drawable.ic_notification_anonymous_avatar_32dp;
957        }
958
959        final Bitmap icon = getIcon(context, resId);
960
961        if (icon == null) {
962            LogUtils.e(LOG_TAG, "Couldn't decode notif icon res id %d", resId);
963        }
964
965        return icon;
966    }
967
968    private static Bitmap getIcon(final Context context, final int resId) {
969        final Bitmap cachedIcon = sNotificationIcons.get(resId);
970        if (cachedIcon != null) {
971            return cachedIcon;
972        }
973
974        final Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resId);
975        sNotificationIcons.put(resId, icon);
976
977        return icon;
978    }
979
980    private static Bitmap getDefaultWearableBg(Context context) {
981        Bitmap bg = sDefaultWearableBg.get();
982        if (bg == null) {
983            bg = BitmapFactory.decodeResource(context.getResources(), R.drawable.bg_email);
984            sDefaultWearableBg = new WeakReference<Bitmap>(bg);
985        }
986        return bg;
987    }
988
989    private static void configureLatestEventInfoFromConversation(final Context context,
990            final Account account, final FolderPreferences folderPreferences,
991            final NotificationCompat.Builder notification,
992            final NotificationCompat.WearableExtender wearableExtender,
993            final Map<Integer, NotificationBuilders> msgNotifications,
994            final int summaryNotificationId, final Cursor conversationCursor,
995            final PendingIntent clickIntent, final Intent notificationIntent,
996            final int unreadCount, final int unseenCount,
997            final Folder folder, final long when, final ContactPhotoFetcher photoFetcher) {
998        final Resources res = context.getResources();
999        final String notificationAccountDisplayName = account.getDisplayName();
1000        final String notificationAccountEmail = account.getEmailAddress();
1001        final boolean multipleUnseen = unseenCount > 1;
1002
1003        LogUtils.i(LOG_TAG, "Showing notification with unreadCount of %d and unseenCount of %d",
1004                unreadCount, unseenCount);
1005
1006        String notificationTicker = null;
1007
1008        // Boolean indicating that this notification is for a non-inbox label.
1009        final boolean isInbox = folder.folderUri.fullUri.equals(account.settings.defaultInbox);
1010
1011        // Notification label name for user label notifications.
1012        final String notificationLabelName = isInbox ? null : folder.name;
1013
1014        if (multipleUnseen) {
1015            // Build the string that describes the number of new messages
1016            final String newMessagesString = createTitle(context, unseenCount);
1017
1018            // Use the default notification icon
1019            notification.setLargeIcon(
1020                    getDefaultNotificationIcon(context, folder, true /* multiple new messages */));
1021
1022            // The ticker initially start as the new messages string.
1023            notificationTicker = newMessagesString;
1024
1025            // The title of the notification is the new messages string
1026            notification.setContentTitle(newMessagesString);
1027
1028            // TODO(skennedy) Can we remove this check?
1029            if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) {
1030                // For a new-style notification
1031                final int maxNumDigestItems = context.getResources().getInteger(
1032                        R.integer.max_num_notification_digest_items);
1033
1034                // The body of the notification is the account name, or the label name.
1035                notification.setSubText(
1036                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1037
1038                final NotificationCompat.InboxStyle digest =
1039                        new NotificationCompat.InboxStyle(notification);
1040
1041                // Group by account and folder
1042                final String notificationGroupKey = createGroupKey(account, folder);
1043                notification.setGroup(notificationGroupKey).setGroupSummary(true);
1044
1045                ConfigResult firstResult = null;
1046                int numDigestItems = 0;
1047                do {
1048                    final Conversation conversation = new Conversation(conversationCursor);
1049
1050                    if (!conversation.read) {
1051                        boolean multipleUnreadThread = false;
1052                        // TODO(cwren) extract this pattern into a helper
1053
1054                        Cursor cursor = null;
1055                        MessageCursor messageCursor = null;
1056                        try {
1057                            final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon();
1058                            uriBuilder.appendQueryParameter(
1059                                    UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName);
1060                            cursor = context.getContentResolver().query(uriBuilder.build(),
1061                                    UIProvider.MESSAGE_PROJECTION, null, null, null);
1062                            messageCursor = new MessageCursor(cursor);
1063
1064                            String from = "";
1065                            String fromAddress = "";
1066                            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
1067                                final Message message = messageCursor.getMessage();
1068                                fromAddress = message.getFrom();
1069                                if (fromAddress == null) {
1070                                    fromAddress = "";
1071                                }
1072                                from = getDisplayableSender(fromAddress);
1073                            }
1074                            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
1075                                final Message message = messageCursor.getMessage();
1076                                if (!message.read &&
1077                                        !fromAddress.contentEquals(message.getFrom())) {
1078                                    multipleUnreadThread = true;
1079                                    break;
1080                                }
1081                            }
1082                            final SpannableStringBuilder sendersBuilder;
1083                            if (multipleUnreadThread) {
1084                                final int sendersLength =
1085                                        res.getInteger(R.integer.swipe_senders_length);
1086
1087                                sendersBuilder = getStyledSenders(context, conversationCursor,
1088                                        sendersLength, notificationAccountEmail);
1089                            } else {
1090                                sendersBuilder =
1091                                        new SpannableStringBuilder(getWrappedFromString(from));
1092                            }
1093                            final CharSequence digestLine = getSingleMessageInboxLine(context,
1094                                    sendersBuilder.toString(),
1095                                    ConversationItemView.filterTag(context, conversation.subject),
1096                                    conversation.getSnippet());
1097                            digest.addLine(digestLine);
1098                            numDigestItems++;
1099
1100                            // Adding conversation notification for Wear.
1101                            NotificationCompat.Builder conversationNotif =
1102                                    new NotificationCompat.Builder(context);
1103
1104                            // TODO(shahrk) - fix for multiple mail
1105                            // Check that the group's folder is assigned an icon res (one of the
1106                            // 4 sections). If it is, we can add the gmail badge. If not, it is
1107                            // accompanied by the multiple_mail_24dp icon and we don't want a badge
1108                            // if (folder.notificationIconResId != 0) {
1109                            conversationNotif.setSmallIcon(R.drawable.ic_notification_mail_24dp);
1110
1111                            if (com.android.mail.utils.Utils.isRunningLOrLater()) {
1112                                conversationNotif.setColor(
1113                                        context.getResources()
1114                                                .getColor(R.color.notification_icon_gmail_red));
1115                            }
1116                            conversationNotif.setContentText(digestLine);
1117                            Intent conversationNotificationIntent = createViewConversationIntent(
1118                                    context, account, folder, conversationCursor);
1119                            PendingIntent conversationClickIntent = createClickPendingIntent(
1120                                    context, conversationNotificationIntent);
1121                            conversationNotif.setContentIntent(conversationClickIntent);
1122                            conversationNotif.setAutoCancel(true);
1123
1124                            // Conversations are sorted in descending order, but notification sort
1125                            // key is in ascending order.  Invert the order key to get the right
1126                            // order.  Left pad 19 zeros because it's a long.
1127                            String groupSortKey = String.format("%019d",
1128                                    (Long.MAX_VALUE - conversation.orderKey));
1129                            conversationNotif.setGroup(notificationGroupKey);
1130                            conversationNotif.setSortKey(groupSortKey);
1131
1132                            int conversationNotificationId = getNotificationId(
1133                                    summaryNotificationId, conversation.hashCode());
1134
1135                            final NotificationCompat.WearableExtender conversationWearExtender =
1136                                    new NotificationCompat.WearableExtender();
1137                            final ConfigResult result =
1138                                    configureNotifForOneConversation(context, account,
1139                                    folderPreferences, conversationNotif, conversationWearExtender,
1140                                    conversationCursor, notificationIntent, folder, when, res,
1141                                    notificationAccountDisplayName, notificationAccountEmail,
1142                                    isInbox, notificationLabelName, conversationNotificationId,
1143                                    photoFetcher);
1144                            msgNotifications.put(conversationNotificationId,
1145                                    NotificationBuilders.of(conversationNotif,
1146                                            conversationWearExtender));
1147
1148                            if (firstResult == null) {
1149                                firstResult = result;
1150                            }
1151                        } finally {
1152                            if (messageCursor != null) {
1153                                messageCursor.close();
1154                            }
1155                            if (cursor != null) {
1156                                cursor.close();
1157                            }
1158                        }
1159                    }
1160                } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
1161
1162                if (firstResult != null && firstResult.contactIconInfo != null) {
1163                    wearableExtender.setBackground(firstResult.contactIconInfo.wearableBg);
1164                } else {
1165                    LogUtils.w(LOG_TAG, "First contact icon is null!");
1166                    wearableExtender.setBackground(getDefaultWearableBg(context));
1167                }
1168            } else {
1169                // The body of the notification is the account name, or the label name.
1170                notification.setContentText(
1171                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1172            }
1173        } else {
1174            // For notifications for a single new conversation, we want to get the information
1175            // from the conversation
1176
1177            // Move the cursor to the most recent unread conversation
1178            seekToLatestUnreadConversation(conversationCursor);
1179
1180            final ConfigResult result = configureNotifForOneConversation(context, account,
1181                    folderPreferences, notification, wearableExtender, conversationCursor,
1182                    notificationIntent, folder, when, res, notificationAccountDisplayName,
1183                    notificationAccountEmail, isInbox, notificationLabelName,
1184                    summaryNotificationId, photoFetcher);
1185            notificationTicker = result.notificationTicker;
1186
1187            if (result.contactIconInfo != null) {
1188                wearableExtender.setBackground(result.contactIconInfo.wearableBg);
1189            } else {
1190                wearableExtender.setBackground(getDefaultWearableBg(context));
1191            }
1192        }
1193
1194        // Build the notification ticker
1195        if (notificationLabelName != null && notificationTicker != null) {
1196            // This is a per label notification, format the ticker with that information
1197            notificationTicker = res.getString(R.string.label_notification_ticker,
1198                    notificationLabelName, notificationTicker);
1199        }
1200
1201        if (notificationTicker != null) {
1202            // If we didn't generate a notification ticker, it will default to account name
1203            notification.setTicker(notificationTicker);
1204        }
1205
1206        // Set the number in the notification
1207        if (unreadCount > 1) {
1208            notification.setNumber(unreadCount);
1209        }
1210
1211        notification.setContentIntent(clickIntent);
1212    }
1213
1214    /**
1215     * Configure the notification for one conversation.  When there are multiple conversations,
1216     * this method is used to configure bundled notification for Android Wear.
1217     */
1218    private static ConfigResult configureNotifForOneConversation(Context context,
1219            Account account, FolderPreferences folderPreferences,
1220            NotificationCompat.Builder notification,
1221            NotificationCompat.WearableExtender wearExtender, Cursor conversationCursor,
1222            Intent notificationIntent, Folder folder, long when, Resources res,
1223            String notificationAccountDisplayName, String notificationAccountEmail,
1224            boolean isInbox, String notificationLabelName, int notificationId,
1225            final ContactPhotoFetcher photoFetcher) {
1226
1227        final ConfigResult result = new ConfigResult();
1228
1229        final Conversation conversation = new Conversation(conversationCursor);
1230
1231        Cursor cursor = null;
1232        MessageCursor messageCursor = null;
1233        boolean multipleUnseenThread = false;
1234        String from = null;
1235        try {
1236            final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
1237                    UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
1238            cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
1239                    null, null, null);
1240            messageCursor = new MessageCursor(cursor);
1241            // Use the information from the last sender in the conversation that triggered
1242            // this notification.
1243
1244            String fromAddress = "";
1245            if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
1246                final Message message = messageCursor.getMessage();
1247                fromAddress = message.getFrom();
1248                if (fromAddress == null) {
1249                    // No sender. Go back to default value.
1250                    LogUtils.e(LOG_TAG, "No sender found for message: %d", message.getId());
1251                    fromAddress = "";
1252                }
1253                from = getDisplayableSender(fromAddress);
1254                result.contactIconInfo = getContactIcon(
1255                        context, account.getAccountManagerAccount().name, from,
1256                        getSenderAddress(fromAddress), folder, photoFetcher);
1257                notification.setLargeIcon(result.contactIconInfo.icon);
1258            }
1259
1260            // Assume that the last message in this conversation is unread
1261            int firstUnseenMessagePos = messageCursor.getPosition();
1262            while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
1263                final Message message = messageCursor.getMessage();
1264                final boolean unseen = !message.seen;
1265                if (unseen) {
1266                    firstUnseenMessagePos = messageCursor.getPosition();
1267                    if (!multipleUnseenThread
1268                            && !fromAddress.contentEquals(message.getFrom())) {
1269                        multipleUnseenThread = true;
1270                    }
1271                }
1272            }
1273
1274            final String subject = ConversationItemView.filterTag(context, conversation.subject);
1275
1276            // TODO(skennedy) Can we remove this check?
1277            if (Utils.isRunningJellybeanOrLater()) {
1278                // For a new-style notification
1279
1280                if (multipleUnseenThread) {
1281                    // The title of a single conversation is the list of senders.
1282                    int sendersLength = res.getInteger(R.integer.swipe_senders_length);
1283
1284                    final SpannableStringBuilder sendersBuilder = getStyledSenders(
1285                            context, conversationCursor, sendersLength,
1286                            notificationAccountEmail);
1287
1288                    notification.setContentTitle(sendersBuilder);
1289                    // For a single new conversation, the ticker is based on the sender's name.
1290                    result.notificationTicker = sendersBuilder.toString();
1291                } else {
1292                    from = getWrappedFromString(from);
1293                    // The title of a single message the sender.
1294                    notification.setContentTitle(from);
1295                    // For a single new conversation, the ticker is based on the sender's name.
1296                    result.notificationTicker = from;
1297                }
1298
1299                // The notification content will be the subject of the conversation.
1300                notification.setContentText(getSingleMessageLittleText(context, subject));
1301
1302                // The notification subtext will be the subject of the conversation for inbox
1303                // notifications, or will based on the the label name for user label
1304                // notifications.
1305                notification.setSubText(isInbox ?
1306                        notificationAccountDisplayName : notificationLabelName);
1307
1308                if (multipleUnseenThread) {
1309                    notification.setLargeIcon(
1310                            getDefaultNotificationIcon(context, folder, true));
1311                }
1312                final NotificationCompat.BigTextStyle bigText =
1313                        new NotificationCompat.BigTextStyle(notification);
1314
1315                // Seek the message cursor to the first unread message
1316                final Message message;
1317                if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
1318                    message = messageCursor.getMessage();
1319                    bigText.bigText(getSingleMessageBigText(context, subject, message));
1320                } else {
1321                    LogUtils.e(LOG_TAG, "Failed to load message");
1322                    message = null;
1323                }
1324
1325                if (message != null) {
1326                    final Set<String> notificationActions =
1327                            folderPreferences.getNotificationActions(account);
1328
1329                    NotificationActionUtils.addNotificationActions(context, notificationIntent,
1330                            notification, wearExtender, account, conversation, message,
1331                            folder, notificationId, when, notificationActions);
1332                }
1333            } else {
1334                // For an old-style notification
1335
1336                // The title of a single conversation notification is built from both the sender
1337                // and subject of the new message.
1338                notification.setContentTitle(
1339                        getSingleMessageNotificationTitle(context, from, subject));
1340
1341                // The notification content will be the subject of the conversation for inbox
1342                // notifications, or will based on the the label name for user label
1343                // notifications.
1344                notification.setContentText(
1345                        isInbox ? notificationAccountDisplayName : notificationLabelName);
1346
1347                // For a single new conversation, the ticker is based on the sender's name.
1348                result.notificationTicker = from;
1349            }
1350        } finally {
1351            if (messageCursor != null) {
1352                messageCursor.close();
1353            }
1354            if (cursor != null) {
1355                cursor.close();
1356            }
1357        }
1358        return result;
1359    }
1360
1361    private static String getWrappedFromString(String from) {
1362        if (from == null) {
1363            LogUtils.e(LOG_TAG, "null from string in getWrappedFromString");
1364            from = "";
1365        }
1366        from = sBidiFormatter.unicodeWrap(from);
1367        return from;
1368    }
1369
1370    private static SpannableStringBuilder getStyledSenders(final Context context,
1371            final Cursor conversationCursor, final int maxLength, final String account) {
1372        final Conversation conversation = new Conversation(conversationCursor);
1373        final com.android.mail.providers.ConversationInfo conversationInfo =
1374                conversation.conversationInfo;
1375        final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
1376        if (sNotificationUnreadStyleSpan == null) {
1377            sNotificationUnreadStyleSpan = new TextAppearanceSpan(
1378                    context, R.style.NotificationSendersUnreadTextAppearance);
1379            sNotificationReadStyleSpan =
1380                    new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
1381        }
1382        SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
1383                sNotificationUnreadStyleSpan, sNotificationReadStyleSpan,
1384                false /* showToHeader */, false /* resourceCachingRequired */);
1385
1386        return ellipsizeStyledSenders(context, senders);
1387    }
1388
1389    private static String sSendersSplitToken = null;
1390    private static String sElidedPaddingToken = null;
1391
1392    private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
1393            ArrayList<SpannableString> styledSenders) {
1394        if (sSendersSplitToken == null) {
1395            sSendersSplitToken = context.getString(R.string.senders_split_token);
1396            sElidedPaddingToken = context.getString(R.string.elided_padding_token);
1397        }
1398
1399        SpannableStringBuilder builder = new SpannableStringBuilder();
1400        SpannableString prevSender = null;
1401        for (SpannableString sender : styledSenders) {
1402            if (sender == null) {
1403                LogUtils.e(LOG_TAG, "null sender iterating over styledSenders");
1404                continue;
1405            }
1406            CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
1407            if (SendersView.sElidedString.equals(sender.toString())) {
1408                prevSender = sender;
1409                sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
1410            } else if (builder.length() > 0
1411                    && (prevSender == null || !SendersView.sElidedString.equals(prevSender
1412                            .toString()))) {
1413                prevSender = sender;
1414                sender = copyStyles(spans, sSendersSplitToken + sender);
1415            } else {
1416                prevSender = sender;
1417            }
1418            builder.append(sender);
1419        }
1420        return builder;
1421    }
1422
1423    private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
1424        SpannableString s = new SpannableString(newText);
1425        if (spans != null && spans.length > 0) {
1426            s.setSpan(spans[0], 0, s.length(), 0);
1427        }
1428        return s;
1429    }
1430
1431    /**
1432     * Seeks the cursor to the position of the most recent unread conversation. If no unread
1433     * conversation is found, the position of the cursor will be restored, and false will be
1434     * returned.
1435     */
1436    private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
1437        final int initialPosition = cursor.getPosition();
1438        do {
1439            final Conversation conversation = new Conversation(cursor);
1440            if (!conversation.read) {
1441                return true;
1442            }
1443        } while (cursor.moveToNext());
1444
1445        // Didn't find an unread conversation, reset the position.
1446        cursor.moveToPosition(initialPosition);
1447        return false;
1448    }
1449
1450    /**
1451     * Sets the bigtext for a notification for a single new conversation
1452     *
1453     * @param context
1454     * @param senders Sender of the new message that triggered the notification.
1455     * @param subject Subject of the new message that triggered the notification
1456     * @param snippet Snippet of the new message that triggered the notification
1457     * @return a {@link CharSequence} suitable for use in
1458     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1459     */
1460    private static CharSequence getSingleMessageInboxLine(Context context,
1461            String senders, String subject, String snippet) {
1462        // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1463
1464        final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1465
1466        final TextAppearanceSpan notificationPrimarySpan =
1467                new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1468
1469        if (TextUtils.isEmpty(senders)) {
1470            // If the senders are empty, just use the subject/snippet.
1471            return subjectSnippet;
1472        } else if (TextUtils.isEmpty(subjectSnippet)) {
1473            // If the subject/snippet is empty, just use the senders.
1474            final SpannableString spannableString = new SpannableString(senders);
1475            spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1476
1477            return spannableString;
1478        } else {
1479            final String formatString = context.getResources().getString(
1480                    R.string.multiple_new_message_notification_item);
1481            final TextAppearanceSpan notificationSecondarySpan =
1482                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1483
1484            // senders is already individually unicode wrapped so it does not need to be done here
1485            final String instantiatedString = String.format(formatString,
1486                    senders,
1487                    sBidiFormatter.unicodeWrap(subjectSnippet));
1488
1489            final SpannableString spannableString = new SpannableString(instantiatedString);
1490
1491            final boolean isOrderReversed = formatString.indexOf("%2$s") <
1492                    formatString.indexOf("%1$s");
1493            final int primaryOffset =
1494                    (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1495                     instantiatedString.indexOf(senders));
1496            final int secondaryOffset =
1497                    (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1498                     instantiatedString.indexOf(subjectSnippet));
1499            spannableString.setSpan(notificationPrimarySpan,
1500                    primaryOffset, primaryOffset + senders.length(), 0);
1501            spannableString.setSpan(notificationSecondarySpan,
1502                    secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1503            return spannableString;
1504        }
1505    }
1506
1507    /**
1508     * Sets the bigtext for a notification for a single new conversation
1509     * @param context
1510     * @param subject Subject of the new message that triggered the notification
1511     * @return a {@link CharSequence} suitable for use in
1512     * {@link NotificationCompat.Builder#setContentText}
1513     */
1514    private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1515        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1516                context, R.style.NotificationPrimaryText);
1517
1518        final SpannableString spannableString = new SpannableString(subject);
1519        spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1520
1521        return spannableString;
1522    }
1523
1524    /**
1525     * Sets the bigtext for a notification for a single new conversation
1526     *
1527     * @param context
1528     * @param subject Subject of the new message that triggered the notification
1529     * @param message the {@link Message} to be displayed.
1530     * @return a {@link CharSequence} suitable for use in
1531     *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1532     */
1533    private static CharSequence getSingleMessageBigText(Context context, String subject,
1534            final Message message) {
1535
1536        final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1537                context, R.style.NotificationPrimaryText);
1538
1539        final String snippet = getMessageBodyWithoutElidedText(message);
1540
1541        // Change multiple newlines (with potential white space between), into a single new line
1542        final String collapsedSnippet =
1543                !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1544
1545        if (TextUtils.isEmpty(subject)) {
1546            // If the subject is empty, just use the snippet.
1547            return snippet;
1548        } else if (TextUtils.isEmpty(collapsedSnippet)) {
1549            // If the snippet is empty, just use the subject.
1550            final SpannableString spannableString = new SpannableString(subject);
1551            spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1552
1553            return spannableString;
1554        } else {
1555            final String notificationBigTextFormat = context.getResources().getString(
1556                    R.string.single_new_message_notification_big_text);
1557
1558            // Localizers may change the order of the parameters, look at how the format
1559            // string is structured.
1560            final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1561                    notificationBigTextFormat.indexOf("%1$s");
1562            final String bigText =
1563                    String.format(notificationBigTextFormat, subject, collapsedSnippet);
1564            final SpannableString spannableString = new SpannableString(bigText);
1565
1566            final int subjectOffset =
1567                    (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1568            spannableString.setSpan(notificationSubjectSpan,
1569                    subjectOffset, subjectOffset + subject.length(), 0);
1570
1571            return spannableString;
1572        }
1573    }
1574
1575    /**
1576     * Gets the title for a notification for a single new conversation
1577     * @param context
1578     * @param sender Sender of the new message that triggered the notification.
1579     * @param subject Subject of the new message that triggered the notification
1580     * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1581     */
1582    private static CharSequence getSingleMessageNotificationTitle(Context context,
1583            String sender, String subject) {
1584
1585        if (TextUtils.isEmpty(subject)) {
1586            // If the subject is empty, just set the title to the sender's information.
1587            return sender;
1588        } else {
1589            final String notificationTitleFormat = context.getResources().getString(
1590                    R.string.single_new_message_notification_title);
1591
1592            // Localizers may change the order of the parameters, look at how the format
1593            // string is structured.
1594            final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1595                    notificationTitleFormat.indexOf("%1$s");
1596            final String titleString = String.format(notificationTitleFormat, sender, subject);
1597
1598            // Format the string so the subject is using the secondaryText style
1599            final SpannableString titleSpannable = new SpannableString(titleString);
1600
1601            // Find the offset of the subject.
1602            final int subjectOffset =
1603                    isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1604            final TextAppearanceSpan notificationSubjectSpan =
1605                    new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1606            titleSpannable.setSpan(notificationSubjectSpan,
1607                    subjectOffset, subjectOffset + subject.length(), 0);
1608            return titleSpannable;
1609        }
1610    }
1611
1612    /**
1613     * Clears the notifications for the specified account/folder.
1614     */
1615    public static void clearFolderNotification(Context context, Account account, Folder folder,
1616            final boolean markSeen) {
1617        LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.getEmailAddress(),
1618                folder.name);
1619        final NotificationMap notificationMap = getNotificationMap(context);
1620        final NotificationKey key = new NotificationKey(account, folder);
1621        notificationMap.remove(key);
1622        notificationMap.saveNotificationMap(context);
1623
1624        final NotificationManagerCompat notificationManager =
1625                NotificationManagerCompat.from(context);
1626        notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder));
1627
1628        cancelConversationNotifications(key, notificationManager);
1629
1630        if (markSeen) {
1631            markSeen(context, folder);
1632        }
1633    }
1634
1635    /**
1636     * Use content resolver to update a conversation.  Should not be called from a main thread.
1637     */
1638    public static void markConversationAsReadAndSeen(Context context, Uri conversationUri) {
1639        LogUtils.v(LOG_TAG, "markConversationAsReadAndSeen=%s", conversationUri);
1640
1641        final ContentValues values = new ContentValues(2);
1642        values.put(UIProvider.ConversationColumns.SEEN, Boolean.TRUE);
1643        values.put(UIProvider.ConversationColumns.READ, Boolean.TRUE);
1644        context.getContentResolver().update(conversationUri, values, null, null);
1645    }
1646
1647    /**
1648     * Clears all notifications for the specified account.
1649     */
1650    public static void clearAccountNotifications(final Context context,
1651            final android.accounts.Account account) {
1652        LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account);
1653        final NotificationMap notificationMap = getNotificationMap(context);
1654
1655        // Find all NotificationKeys for this account
1656        final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder();
1657
1658        for (final NotificationKey key : notificationMap.keySet()) {
1659            if (account.equals(key.account.getAccountManagerAccount())) {
1660                keyBuilder.add(key);
1661            }
1662        }
1663
1664        final List<NotificationKey> notificationKeys = keyBuilder.build();
1665
1666        final NotificationManagerCompat notificationManager =
1667                NotificationManagerCompat.from(context);
1668
1669        for (final NotificationKey notificationKey : notificationKeys) {
1670            final Folder folder = notificationKey.folder;
1671            notificationManager.cancel(getNotificationId(account, folder));
1672            notificationMap.remove(notificationKey);
1673
1674            cancelConversationNotifications(notificationKey, notificationManager);
1675        }
1676
1677        notificationMap.saveNotificationMap(context);
1678    }
1679
1680    private static void cancelConversationNotifications(NotificationKey key,
1681            NotificationManagerCompat nm) {
1682        final Set<Integer> conversationNotifications = sConversationNotificationMap.get(key);
1683        if (conversationNotifications != null) {
1684            for (Integer conversationNotification : conversationNotifications) {
1685                nm.cancel(conversationNotification);
1686            }
1687            sConversationNotificationMap.remove(key);
1688        }
1689    }
1690
1691    private static ContactIconInfo getContactIcon(final Context context, String accountName,
1692            final String displayName, final String senderAddress, final Folder folder,
1693            final ContactPhotoFetcher photoFetcher) {
1694        if (Looper.myLooper() == Looper.getMainLooper()) {
1695            throw new IllegalStateException(
1696                    "getContactIcon should not be called on the main thread.");
1697        }
1698
1699        final ContactIconInfo contactIconInfo;
1700        if (senderAddress == null) {
1701            contactIconInfo = new ContactIconInfo();
1702        } else {
1703            // Get the ideal size for this icon.
1704            final Resources res = context.getResources();
1705            final int idealIconHeight =
1706                    res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1707            final int idealIconWidth =
1708                    res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1709            final int idealWearableBgWidth =
1710                    res.getDimensionPixelSize(R.dimen.wearable_background_width);
1711            final int idealWearableBgHeight =
1712                    res.getDimensionPixelSize(R.dimen.wearable_background_height);
1713
1714            if (photoFetcher != null) {
1715                contactIconInfo = photoFetcher.getContactPhoto(context, accountName,
1716                        senderAddress, idealIconWidth, idealIconHeight, idealWearableBgWidth,
1717                        idealWearableBgHeight);
1718            } else {
1719                contactIconInfo = getContactInfo(context, senderAddress, idealIconWidth,
1720                        idealIconHeight, idealWearableBgWidth, idealWearableBgHeight);
1721            }
1722
1723            if (contactIconInfo.icon == null) {
1724                // Make a colorful tile!
1725                final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight,
1726                        Dimensions.SCALE_ONE);
1727
1728                contactIconInfo.icon = new LetterTileProvider(context).getLetterTile(dimensions,
1729                        displayName, senderAddress);
1730            }
1731            contactIconInfo.icon = cropSquareIconToCircle(contactIconInfo.icon);
1732        }
1733
1734        if (contactIconInfo.icon == null) {
1735            // Icon should be the default mail icon.
1736            contactIconInfo.icon = getDefaultNotificationIcon(context, folder,
1737                    false /* single new message */);
1738        }
1739
1740        if (contactIconInfo.wearableBg == null) {
1741            contactIconInfo.wearableBg = getDefaultWearableBg(context);
1742        }
1743
1744        return contactIconInfo;
1745    }
1746
1747    private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1748        ArrayList<String> whereArgs = new ArrayList<String>();
1749        StringBuilder whereBuilder = new StringBuilder();
1750        String[] questionMarks = new String[addresses.size()];
1751
1752        whereArgs.addAll(addresses);
1753        Arrays.fill(questionMarks, "?");
1754        whereBuilder.append(Email.DATA1 + " IN (").
1755                append(TextUtils.join(",", questionMarks)).
1756                append(")");
1757
1758        ContentResolver resolver = context.getContentResolver();
1759        Cursor c = resolver.query(Email.CONTENT_URI,
1760                new String[] {Email.CONTACT_ID}, whereBuilder.toString(),
1761                whereArgs.toArray(new String[0]), null);
1762
1763        ArrayList<Long> contactIds = new ArrayList<Long>();
1764        if (c == null) {
1765            return contactIds;
1766        }
1767        try {
1768            while (c.moveToNext()) {
1769                contactIds.add(c.getLong(0));
1770            }
1771        } finally {
1772            c.close();
1773        }
1774        return contactIds;
1775    }
1776
1777    public static ContactIconInfo getContactInfo(
1778            final Context context, final String senderAddress,
1779            final int idealIconWidth, final int idealIconHeight,
1780            final int idealWearableBgWidth, final int idealWearableBgHeight) {
1781        final ContactIconInfo contactIconInfo = new ContactIconInfo();
1782        final List<Long> contactIds = findContacts(context, Arrays.asList(
1783                new String[]{senderAddress}));
1784
1785        if (contactIds != null) {
1786            for (final long id : contactIds) {
1787                final Uri contactUri = ContentUris.withAppendedId(
1788                        ContactsContract.Contacts.CONTENT_URI, id);
1789                final InputStream inputStream =
1790                        ContactsContract.Contacts.openContactPhotoInputStream(
1791                                context.getContentResolver(), contactUri, true /*preferHighres*/);
1792
1793                if (inputStream != null) {
1794                    try {
1795                        final Bitmap source = BitmapFactory.decodeStream(inputStream);
1796                        if (source != null) {
1797                            // We should scale this image to fit the intended size
1798                            contactIconInfo.icon = Bitmap.createScaledBitmap(source, idealIconWidth,
1799                                    idealIconHeight, true);
1800
1801                            contactIconInfo.wearableBg = Bitmap.createScaledBitmap(source,
1802                                    idealWearableBgWidth, idealWearableBgHeight, true);
1803                        }
1804
1805                        if (contactIconInfo.icon != null) {
1806                            break;
1807                        }
1808                    } finally {
1809                        Closeables.closeQuietly(inputStream);
1810                    }
1811                }
1812            }
1813        }
1814
1815        return contactIconInfo;
1816    }
1817
1818    /**
1819     * Crop a square bitmap into a circular one. Used for both contact photos and letter tiles.
1820     * @param icon Square bitmap to crop
1821     * @return Circular bitmap
1822     */
1823    private static Bitmap cropSquareIconToCircle(Bitmap icon) {
1824        final int iconWidth = icon.getWidth();
1825        final Bitmap newIcon = Bitmap.createBitmap(iconWidth, iconWidth, Bitmap.Config.ARGB_8888);
1826        final Canvas canvas = new Canvas(newIcon);
1827        final Paint paint = new Paint();
1828        final Rect rect = new Rect(0, 0, icon.getWidth(),
1829                icon.getHeight());
1830
1831        paint.setAntiAlias(true);
1832        canvas.drawARGB(0, 0, 0, 0);
1833        canvas.drawCircle(iconWidth/2, iconWidth/2, iconWidth/2, paint);
1834        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
1835        canvas.drawBitmap(icon, rect, rect, paint);
1836
1837        return newIcon;
1838    }
1839
1840    private static String getMessageBodyWithoutElidedText(final Message message) {
1841        return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
1842    }
1843
1844    public static String getMessageBodyWithoutElidedText(String html) {
1845        if (TextUtils.isEmpty(html)) {
1846            return "";
1847        }
1848        // Get the html "tree" for this message body
1849        final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1850        htmlTree.setConverterFactory(MESSAGE_CONVERTER_FACTORY);
1851
1852        return htmlTree.getPlainText();
1853    }
1854
1855    public static void markSeen(final Context context, final Folder folder) {
1856        final Uri uri = folder.folderUri.fullUri;
1857
1858        final ContentValues values = new ContentValues(1);
1859        values.put(UIProvider.ConversationColumns.SEEN, 1);
1860
1861        context.getContentResolver().update(uri, values, null, null);
1862    }
1863
1864    /**
1865     * Returns a displayable string representing
1866     * the message sender. It has a preference toward showing the name,
1867     * but will fall back to the address if that is all that is available.
1868     */
1869    private static String getDisplayableSender(String sender) {
1870        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1871
1872        String displayableSender = address.getName();
1873
1874        if (!TextUtils.isEmpty(displayableSender)) {
1875            return Address.decodeAddressPersonal(displayableSender);
1876        }
1877
1878        // If that fails, default to the sender address.
1879        displayableSender = address.getAddress();
1880
1881        // If we were unable to tokenize a name or address,
1882        // just use whatever was in the sender.
1883        if (TextUtils.isEmpty(displayableSender)) {
1884            displayableSender = sender;
1885        }
1886        return displayableSender;
1887    }
1888
1889    /**
1890     * Returns only the address portion of a message sender.
1891     */
1892    private static String getSenderAddress(String sender) {
1893        final EmailAddress address = EmailAddress.getEmailAddress(sender);
1894
1895        String tokenizedAddress = address.getAddress();
1896
1897        // If we were unable to tokenize a name or address,
1898        // just use whatever was in the sender.
1899        if (TextUtils.isEmpty(tokenizedAddress)) {
1900            tokenizedAddress = sender;
1901        }
1902        return tokenizedAddress;
1903    }
1904
1905    public static int getNotificationId(final android.accounts.Account account,
1906            final Folder folder) {
1907        return 1 ^ account.hashCode() ^ folder.hashCode();
1908    }
1909
1910    private static int getNotificationId(int summaryNotificationId, int conversationHashCode) {
1911        return summaryNotificationId ^ conversationHashCode;
1912    }
1913
1914    private static class NotificationKey {
1915        public final Account account;
1916        public final Folder folder;
1917
1918        public NotificationKey(Account account, Folder folder) {
1919            this.account = account;
1920            this.folder = folder;
1921        }
1922
1923        @Override
1924        public boolean equals(Object other) {
1925            if (!(other instanceof NotificationKey)) {
1926                return false;
1927            }
1928            NotificationKey key = (NotificationKey) other;
1929            return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount())
1930                    && folder.equals(key.folder);
1931        }
1932
1933        @Override
1934        public String toString() {
1935            return account.getDisplayName() + " " + folder.name;
1936        }
1937
1938        @Override
1939        public int hashCode() {
1940            final int accountHashCode = account.getAccountManagerAccount().hashCode();
1941            final int folderHashCode = folder.hashCode();
1942            return accountHashCode ^ folderHashCode;
1943        }
1944    }
1945
1946    /**
1947     * Contains the logic for converting the contents of one HtmlTree into
1948     * plaintext.
1949     */
1950    public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1951        // Strings for parsing html message bodies
1952        private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1953        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1954        private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1955
1956        private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1957                new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1958
1959        private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
1960                HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
1961
1962        private int mEndNodeElidedTextBlock = -1;
1963
1964        @Override
1965        public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
1966            // If we are in the middle of an elided text block, don't add this node
1967            if (nodeNum < mEndNodeElidedTextBlock) {
1968                return;
1969            } else if (nodeNum == mEndNodeElidedTextBlock) {
1970                super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
1971                return;
1972            }
1973
1974            // If this tag starts another elided text block, we want to remember the end
1975            if (n instanceof HtmlDocument.Tag) {
1976                boolean foundElidedTextTag = false;
1977                final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
1978                final HTML.Element htmlElement = htmlTag.getElement();
1979                if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
1980                    // Make sure that the class is what is expected
1981                    final List<HtmlDocument.TagAttribute> attributes =
1982                            htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
1983                    for (HtmlDocument.TagAttribute attribute : attributes) {
1984                        if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
1985                                attribute.getValue())) {
1986                            // Found an "elided-text" div.  Remember information about this tag
1987                            mEndNodeElidedTextBlock = endNum;
1988                            foundElidedTextTag = true;
1989                            break;
1990                        }
1991                    }
1992                }
1993
1994                if (foundElidedTextTag) {
1995                    return;
1996                }
1997            }
1998
1999            super.addNode(n, nodeNum, endNum);
2000        }
2001    }
2002
2003    /**
2004     * During account setup in Email, we may not have an inbox yet, so the notification setting had
2005     * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
2006     * {@link FolderPreferences} now.
2007     */
2008    public static void moveNotificationSetting(final AccountPreferences accountPreferences,
2009            final FolderPreferences folderPreferences) {
2010        if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
2011            // If this setting has been changed some other way, don't overwrite it
2012            if (!folderPreferences.isNotificationsEnabledSet()) {
2013                final boolean notificationsEnabled =
2014                        accountPreferences.getDefaultInboxNotificationsEnabled();
2015
2016                folderPreferences.setNotificationsEnabled(notificationsEnabled);
2017            }
2018
2019            accountPreferences.clearDefaultInboxNotificationsEnabled();
2020        }
2021    }
2022
2023    private static class NotificationBuilders {
2024        public final NotificationCompat.Builder notifBuilder;
2025        public final NotificationCompat.WearableExtender wearableNotifBuilder;
2026
2027        private NotificationBuilders(NotificationCompat.Builder notifBuilder,
2028                NotificationCompat.WearableExtender wearableNotifBuilder) {
2029            this.notifBuilder = notifBuilder;
2030            this.wearableNotifBuilder = wearableNotifBuilder;
2031        }
2032
2033        public static NotificationBuilders of(NotificationCompat.Builder notifBuilder,
2034                NotificationCompat.WearableExtender wearableNotifBuilder) {
2035            return new NotificationBuilders(notifBuilder, wearableNotifBuilder);
2036        }
2037    }
2038
2039    private static class ConfigResult {
2040        public String notificationTicker;
2041        public ContactIconInfo contactIconInfo;
2042    }
2043
2044    public static class ContactIconInfo {
2045        public Bitmap icon;
2046        public Bitmap wearableBg;
2047    }
2048}
2049