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