• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.NotificationManager;
20 import android.app.PendingIntent;
21 import android.content.ContentResolver;
22 import android.content.ContentUris;
23 import android.content.ContentValues;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.content.res.Resources;
27 import android.database.Cursor;
28 import android.graphics.Bitmap;
29 import android.graphics.BitmapFactory;
30 import android.net.Uri;
31 import android.provider.ContactsContract;
32 import android.provider.ContactsContract.CommonDataKinds.Email;
33 import android.provider.ContactsContract.Contacts.Photo;
34 import android.support.v4.app.NotificationCompat;
35 import android.support.v4.text.BidiFormatter;
36 import android.text.SpannableString;
37 import android.text.SpannableStringBuilder;
38 import android.text.TextUtils;
39 import android.text.style.CharacterStyle;
40 import android.text.style.TextAppearanceSpan;
41 import android.util.Pair;
42 import android.util.SparseArray;
43 
44 import com.android.mail.EmailAddress;
45 import com.android.mail.MailIntentService;
46 import com.android.mail.R;
47 import com.android.mail.analytics.Analytics;
48 import com.android.mail.analytics.AnalyticsUtils;
49 import com.android.mail.browse.MessageCursor;
50 import com.android.mail.browse.SendersView;
51 import com.android.mail.photomanager.LetterTileProvider;
52 import com.android.mail.preferences.AccountPreferences;
53 import com.android.mail.preferences.FolderPreferences;
54 import com.android.mail.preferences.MailPrefs;
55 import com.android.mail.providers.Account;
56 import com.android.mail.providers.Address;
57 import com.android.mail.providers.Conversation;
58 import com.android.mail.providers.Folder;
59 import com.android.mail.providers.Message;
60 import com.android.mail.providers.UIProvider;
61 import com.android.mail.ui.ImageCanvas.Dimensions;
62 import com.android.mail.utils.NotificationActionUtils.NotificationAction;
63 import com.google.android.mail.common.html.parser.HTML;
64 import com.google.android.mail.common.html.parser.HTML4;
65 import com.google.android.mail.common.html.parser.HtmlDocument;
66 import com.google.android.mail.common.html.parser.HtmlTree;
67 import com.google.common.base.Objects;
68 import com.google.common.collect.ImmutableList;
69 import com.google.common.collect.Lists;
70 import com.google.common.collect.Sets;
71 
72 import java.io.ByteArrayInputStream;
73 import java.util.ArrayList;
74 import java.util.Arrays;
75 import java.util.Collection;
76 import java.util.List;
77 import java.util.Set;
78 import java.util.concurrent.ConcurrentHashMap;
79 
80 public class NotificationUtils {
81     public static final String LOG_TAG = "NotifUtils";
82 
83     /** Contains a list of <(account, label), unread conversations> */
84     private static NotificationMap sActiveNotificationMap = null;
85 
86     private static final SparseArray<Bitmap> sNotificationIcons = new SparseArray<Bitmap>();
87 
88     private static TextAppearanceSpan sNotificationUnreadStyleSpan;
89     private static CharacterStyle sNotificationReadStyleSpan;
90 
91     /** A factory that produces a plain text converter that removes elided text. */
92     private static final HtmlTree.PlainTextConverterFactory MESSAGE_CONVERTER_FACTORY =
93             new HtmlTree.PlainTextConverterFactory() {
94                 @Override
95                 public HtmlTree.PlainTextConverter createInstance() {
96                     return new MailMessagePlainTextConverter();
97                 }
98             };
99 
100     private static final BidiFormatter BIDI_FORMATTER = BidiFormatter.getInstance();
101 
102     /**
103      * Clears all notifications in response to the user tapping "Clear" in the status bar.
104      */
clearAllNotfications(Context context)105     public static void clearAllNotfications(Context context) {
106         LogUtils.v(LOG_TAG, "Clearing all notifications.");
107         final NotificationMap notificationMap = getNotificationMap(context);
108         notificationMap.clear();
109         notificationMap.saveNotificationMap(context);
110     }
111 
112     /**
113      * Returns the notification map, creating it if necessary.
114      */
getNotificationMap(Context context)115     private static synchronized NotificationMap getNotificationMap(Context context) {
116         if (sActiveNotificationMap == null) {
117             sActiveNotificationMap = new NotificationMap();
118 
119             // populate the map from the cached data
120             sActiveNotificationMap.loadNotificationMap(context);
121         }
122         return sActiveNotificationMap;
123     }
124 
125     /**
126      * Class representing the existing notifications, and the number of unread and
127      * unseen conversations that triggered each.
128      */
129     private static class NotificationMap
130             extends ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>> {
131 
132         private static final String NOTIFICATION_PART_SEPARATOR = " ";
133         private static final int NUM_NOTIFICATION_PARTS= 4;
134 
135         /**
136          * Retuns the unread count for the given NotificationKey.
137          */
getUnread(NotificationKey key)138         public Integer getUnread(NotificationKey key) {
139             final Pair<Integer, Integer> value = get(key);
140             return value != null ? value.first : null;
141         }
142 
143         /**
144          * Retuns the unread unseen count for the given NotificationKey.
145          */
getUnseen(NotificationKey key)146         public Integer getUnseen(NotificationKey key) {
147             final Pair<Integer, Integer> value = get(key);
148             return value != null ? value.second : null;
149         }
150 
151         /**
152          * Store the unread and unseen value for the given NotificationKey
153          */
put(NotificationKey key, int unread, int unseen)154         public void put(NotificationKey key, int unread, int unseen) {
155             final Pair<Integer, Integer> value =
156                     new Pair<Integer, Integer>(Integer.valueOf(unread), Integer.valueOf(unseen));
157             put(key, value);
158         }
159 
160         /**
161          * Populates the notification map with previously cached data.
162          */
loadNotificationMap(final Context context)163         public synchronized void loadNotificationMap(final Context context) {
164             final MailPrefs mailPrefs = MailPrefs.get(context);
165             final Set<String> notificationSet = mailPrefs.getActiveNotificationSet();
166             if (notificationSet != null) {
167                 for (String notificationEntry : notificationSet) {
168                     // Get the parts of the string that make the notification entry
169                     final String[] notificationParts =
170                             TextUtils.split(notificationEntry, NOTIFICATION_PART_SEPARATOR);
171                     if (notificationParts.length == NUM_NOTIFICATION_PARTS) {
172                         final Uri accountUri = Uri.parse(notificationParts[0]);
173                         final Cursor accountCursor = context.getContentResolver().query(
174                                 accountUri, UIProvider.ACCOUNTS_PROJECTION, null, null, null);
175                         final Account account;
176                         try {
177                             if (accountCursor.moveToFirst()) {
178                                 account = new Account(accountCursor);
179                             } else {
180                                 continue;
181                             }
182                         } finally {
183                             accountCursor.close();
184                         }
185 
186                         final Uri folderUri = Uri.parse(notificationParts[1]);
187                         final Cursor folderCursor = context.getContentResolver().query(
188                                 folderUri, UIProvider.FOLDERS_PROJECTION, null, null, null);
189                         final Folder folder;
190                         try {
191                             if (folderCursor.moveToFirst()) {
192                                 folder = new Folder(folderCursor);
193                             } else {
194                                 continue;
195                             }
196                         } finally {
197                             folderCursor.close();
198                         }
199 
200                         final NotificationKey key = new NotificationKey(account, folder);
201                         final Integer unreadValue = Integer.valueOf(notificationParts[2]);
202                         final Integer unseenValue = Integer.valueOf(notificationParts[3]);
203                         final Pair<Integer, Integer> unreadUnseenValue =
204                                 new Pair<Integer, Integer>(unreadValue, unseenValue);
205                         put(key, unreadUnseenValue);
206                     }
207                 }
208             }
209         }
210 
211         /**
212          * Cache the notification map.
213          */
saveNotificationMap(Context context)214         public synchronized void saveNotificationMap(Context context) {
215             final Set<String> notificationSet = Sets.newHashSet();
216             final Set<NotificationKey> keys = keySet();
217             for (NotificationKey key : keys) {
218                 final Pair<Integer, Integer> value = get(key);
219                 final Integer unreadCount = value.first;
220                 final Integer unseenCount = value.second;
221                 if (unreadCount != null && unseenCount != null) {
222                     final String[] partValues = new String[] {
223                             key.account.uri.toString(), key.folder.folderUri.fullUri.toString(),
224                             unreadCount.toString(), unseenCount.toString()};
225                     notificationSet.add(TextUtils.join(NOTIFICATION_PART_SEPARATOR, partValues));
226                 }
227             }
228             final MailPrefs mailPrefs = MailPrefs.get(context);
229             mailPrefs.cacheActiveNotificationSet(notificationSet);
230         }
231     }
232 
233     /**
234      * @return the title of this notification with each account and the number of unread and unseen
235      * conversations for it. Also remove any account in the map that has 0 unread.
236      */
createNotificationString(NotificationMap notifications)237     private static String createNotificationString(NotificationMap notifications) {
238         StringBuilder result = new StringBuilder();
239         int i = 0;
240         Set<NotificationKey> keysToRemove = Sets.newHashSet();
241         for (NotificationKey key : notifications.keySet()) {
242             Integer unread = notifications.getUnread(key);
243             Integer unseen = notifications.getUnseen(key);
244             if (unread == null || unread.intValue() == 0) {
245                 keysToRemove.add(key);
246             } else {
247                 if (i > 0) result.append(", ");
248                 result.append(key.toString() + " (" + unread + ", " + unseen + ")");
249                 i++;
250             }
251         }
252 
253         for (NotificationKey key : keysToRemove) {
254             notifications.remove(key);
255         }
256 
257         return result.toString();
258     }
259 
260     /**
261      * Get all notifications for all accounts and cancel them.
262      **/
cancelAllNotifications(Context context)263     public static void cancelAllNotifications(Context context) {
264         LogUtils.d(LOG_TAG, "cancelAllNotifications - cancelling all");
265         NotificationManager nm = (NotificationManager) context.getSystemService(
266                 Context.NOTIFICATION_SERVICE);
267         nm.cancelAll();
268         clearAllNotfications(context);
269     }
270 
271     /**
272      * Get all notifications for all accounts, cancel them, and repost.
273      * This happens when locale changes.
274      **/
cancelAndResendNotifications(Context context)275     public static void cancelAndResendNotifications(Context context) {
276         LogUtils.d(LOG_TAG, "cancelAndResendNotifications");
277         resendNotifications(context, true, null, null);
278     }
279 
280     /**
281      * Get all notifications for all accounts, optionally cancel them, and repost.
282      * This happens when locale changes. If you only want to resend messages from one
283      * account-folder pair, pass in the account and folder that should be resent.
284      * All other account-folder pairs will not have their notifications resent.
285      * All notifications will be resent if account or folder is null.
286      *
287      * @param context Current context.
288      * @param cancelExisting True, if all notifications should be canceled before resending.
289      *                       False, otherwise.
290      * @param accountUri The {@link Uri} of the {@link Account} of the notification
291      *                   upon which an action occurred.
292      * @param folderUri The {@link Uri} of the {@link Folder} of the notification
293      *                  upon which an action occurred.
294      */
resendNotifications(Context context, final boolean cancelExisting, final Uri accountUri, final FolderUri folderUri)295     public static void resendNotifications(Context context, final boolean cancelExisting,
296             final Uri accountUri, final FolderUri folderUri) {
297         LogUtils.d(LOG_TAG, "resendNotifications ");
298 
299         if (cancelExisting) {
300             LogUtils.d(LOG_TAG, "resendNotifications - cancelling all");
301             NotificationManager nm =
302                     (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
303             nm.cancelAll();
304         }
305         // Re-validate the notifications.
306         final NotificationMap notificationMap = getNotificationMap(context);
307         final Set<NotificationKey> keys = notificationMap.keySet();
308         for (NotificationKey notification : keys) {
309             final Folder folder = notification.folder;
310             final int notificationId =
311                     getNotificationId(notification.account.getAccountManagerAccount(), folder);
312 
313             // Only resend notifications if the notifications are from the same folder
314             // and same account as the undo notification that was previously displayed.
315             if (accountUri != null && !Objects.equal(accountUri, notification.account.uri) &&
316                     folderUri != null && !Objects.equal(folderUri, folder.folderUri)) {
317                 LogUtils.d(LOG_TAG, "resendNotifications - not resending %s / %s"
318                         + " because it doesn't match %s / %s",
319                         notification.account.uri, folder.folderUri, accountUri, folderUri);
320                 continue;
321             }
322 
323             LogUtils.d(LOG_TAG, "resendNotifications - resending %s / %s",
324                     notification.account.uri, folder.folderUri);
325 
326             final NotificationAction undoableAction =
327                     NotificationActionUtils.sUndoNotifications.get(notificationId);
328             if (undoableAction == null) {
329                 validateNotifications(context, folder, notification.account, true,
330                         false, notification);
331             } else {
332                 // Create an undo notification
333                 NotificationActionUtils.createUndoNotification(context, undoableAction);
334             }
335         }
336     }
337 
338     /**
339      * Validate the notifications for the specified account.
340      */
validateAccountNotifications(Context context, String account)341     public static void validateAccountNotifications(Context context, String account) {
342         LogUtils.d(LOG_TAG, "validateAccountNotifications - %s", account);
343 
344         List<NotificationKey> notificationsToCancel = Lists.newArrayList();
345         // Iterate through the notification map to see if there are any entries that correspond to
346         // labels that are not in the sync set.
347         final NotificationMap notificationMap = getNotificationMap(context);
348         Set<NotificationKey> keys = notificationMap.keySet();
349         final AccountPreferences accountPreferences = new AccountPreferences(context, account);
350         final boolean enabled = accountPreferences.areNotificationsEnabled();
351         if (!enabled) {
352             // Cancel all notifications for this account
353             for (NotificationKey notification : keys) {
354                 if (notification.account.getAccountManagerAccount().name.equals(account)) {
355                     notificationsToCancel.add(notification);
356                 }
357             }
358         } else {
359             // Iterate through the notification map to see if there are any entries that
360             // correspond to labels that are not in the notification set.
361             for (NotificationKey notification : keys) {
362                 if (notification.account.getAccountManagerAccount().name.equals(account)) {
363                     // If notification is not enabled for this label, remember this NotificationKey
364                     // to later cancel the notification, and remove the entry from the map
365                     final Folder folder = notification.folder;
366                     final boolean isInbox = folder.folderUri.equals(
367                             notification.account.settings.defaultInbox);
368                     final FolderPreferences folderPreferences = new FolderPreferences(
369                             context, notification.account.getEmailAddress(), folder, isInbox);
370 
371                     if (!folderPreferences.areNotificationsEnabled()) {
372                         notificationsToCancel.add(notification);
373                     }
374                 }
375             }
376         }
377 
378         // Cancel & remove the invalid notifications.
379         if (notificationsToCancel.size() > 0) {
380             NotificationManager nm = (NotificationManager) context.getSystemService(
381                     Context.NOTIFICATION_SERVICE);
382             for (NotificationKey notification : notificationsToCancel) {
383                 final Folder folder = notification.folder;
384                 final int notificationId =
385                         getNotificationId(notification.account.getAccountManagerAccount(), folder);
386                 LogUtils.d(LOG_TAG, "validateAccountNotifications - cancelling %s / %s",
387                         notification.account.name, folder.persistentId);
388                 nm.cancel(notificationId);
389                 notificationMap.remove(notification);
390                 NotificationActionUtils.sUndoNotifications.remove(notificationId);
391                 NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
392             }
393             notificationMap.saveNotificationMap(context);
394         }
395     }
396 
397     /**
398      * Display only one notification.
399      */
setNewEmailIndicator(Context context, final int unreadCount, final int unseenCount, final Account account, final Folder folder, final boolean getAttention)400     public static void setNewEmailIndicator(Context context, final int unreadCount,
401             final int unseenCount, final Account account, final Folder folder,
402             final boolean getAttention) {
403         LogUtils.d(LOG_TAG, "setNewEmailIndicator unreadCount = %d, unseenCount = %d, account = %s,"
404                 + " folder = %s, getAttention = %b", unreadCount, unseenCount, account.name,
405                 folder.folderUri, getAttention);
406 
407         boolean ignoreUnobtrusiveSetting = false;
408 
409         final int notificationId = getNotificationId(account.getAccountManagerAccount(), folder);
410 
411         // Update the notification map
412         final NotificationMap notificationMap = getNotificationMap(context);
413         final NotificationKey key = new NotificationKey(account, folder);
414         if (unreadCount == 0) {
415             LogUtils.d(LOG_TAG, "setNewEmailIndicator - cancelling %s / %s", account.name,
416                     folder.persistentId);
417             notificationMap.remove(key);
418             ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE))
419                     .cancel(notificationId);
420         } else {
421             if (!notificationMap.containsKey(key)) {
422                 // This account previously didn't have any unread mail; ignore the "unobtrusive
423                 // notifications" setting and play sound and/or vibrate the device even if a
424                 // notification already exists (bug 2412348).
425                 ignoreUnobtrusiveSetting = true;
426             }
427             notificationMap.put(key, unreadCount, unseenCount);
428         }
429         notificationMap.saveNotificationMap(context);
430 
431         if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
432             LogUtils.v(LOG_TAG, "New email: %s mapSize: %d getAttention: %b",
433                     createNotificationString(notificationMap), notificationMap.size(),
434                     getAttention);
435         }
436 
437         if (NotificationActionUtils.sUndoNotifications.get(notificationId) == null) {
438             validateNotifications(context, folder, account, getAttention, ignoreUnobtrusiveSetting,
439                     key);
440         }
441     }
442 
443     /**
444      * Validate the notifications notification.
445      */
validateNotifications(Context context, final Folder folder, final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting, NotificationKey key)446     private static void validateNotifications(Context context, final Folder folder,
447             final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting,
448             NotificationKey key) {
449 
450         NotificationManager nm = (NotificationManager)
451                 context.getSystemService(Context.NOTIFICATION_SERVICE);
452 
453         final NotificationMap notificationMap = getNotificationMap(context);
454         if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
455             LogUtils.i(LOG_TAG, "Validating Notification: %s mapSize: %d "
456                     + "folder: %s getAttention: %b", createNotificationString(notificationMap),
457                     notificationMap.size(), folder.name, getAttention);
458         } else {
459             LogUtils.i(LOG_TAG, "Validating Notification, mapSize: %d "
460                     + "getAttention: %b", notificationMap.size(), getAttention);
461         }
462         // The number of unread messages for this account and label.
463         final Integer unread = notificationMap.getUnread(key);
464         final int unreadCount = unread != null ? unread.intValue() : 0;
465         final Integer unseen = notificationMap.getUnseen(key);
466         int unseenCount = unseen != null ? unseen.intValue() : 0;
467 
468         Cursor cursor = null;
469 
470         try {
471             final Uri.Builder uriBuilder = folder.conversationListUri.buildUpon();
472             uriBuilder.appendQueryParameter(
473                     UIProvider.SEEN_QUERY_PARAMETER, Boolean.FALSE.toString());
474             // Do not allow this quick check to disrupt any active network-enabled conversation
475             // cursor.
476             uriBuilder.appendQueryParameter(
477                     UIProvider.ConversationListQueryParameters.USE_NETWORK,
478                     Boolean.FALSE.toString());
479             cursor = context.getContentResolver().query(uriBuilder.build(),
480                     UIProvider.CONVERSATION_PROJECTION, null, null, null);
481             if (cursor == null) {
482                 // This folder doesn't exist.
483                 LogUtils.i(LOG_TAG,
484                         "The cursor is null, so the specified folder probably does not exist");
485                 clearFolderNotification(context, account, folder, false);
486                 return;
487             }
488             final int cursorUnseenCount = cursor.getCount();
489 
490             // Make sure the unseen count matches the number of items in the cursor.  But, we don't
491             // want to overwrite a 0 unseen count that was specified in the intent
492             if (unseenCount != 0 && unseenCount != cursorUnseenCount) {
493                 LogUtils.i(LOG_TAG,
494                         "Unseen count doesn't match cursor count.  unseen: %d cursor count: %d",
495                         unseenCount, cursorUnseenCount);
496                 unseenCount = cursorUnseenCount;
497             }
498 
499             // For the purpose of the notifications, the unseen count should be capped at the num of
500             // unread conversations.
501             if (unseenCount > unreadCount) {
502                 unseenCount = unreadCount;
503             }
504 
505             final int notificationId =
506                     getNotificationId(account.getAccountManagerAccount(), folder);
507 
508             if (unseenCount == 0) {
509                 LogUtils.i(LOG_TAG, "validateNotifications - cancelling account %s / folder %s",
510                         LogUtils.sanitizeName(LOG_TAG, account.name),
511                         LogUtils.sanitizeName(LOG_TAG, folder.persistentId));
512                 nm.cancel(notificationId);
513                 return;
514             }
515 
516             // We now have all we need to create the notification and the pending intent
517             PendingIntent clickIntent;
518 
519             NotificationCompat.Builder notification = new NotificationCompat.Builder(context);
520             notification.setSmallIcon(R.drawable.stat_notify_email);
521             notification.setTicker(account.name);
522 
523             final long when;
524 
525             final long oldWhen =
526                     NotificationActionUtils.sNotificationTimestamps.get(notificationId);
527             if (oldWhen != 0) {
528                 when = oldWhen;
529             } else {
530                 when = System.currentTimeMillis();
531             }
532 
533             notification.setWhen(when);
534 
535             // The timestamp is now stored in the notification, so we can remove it from here
536             NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
537 
538             // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a
539             // notification.  Also this intent gets fired when the user taps on a notification as
540             // the AutoCancel flag has been set
541             final Intent cancelNotificationIntent =
542                     new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
543             cancelNotificationIntent.setPackage(context.getPackageName());
544             cancelNotificationIntent.setData(Utils.appendVersionQueryParameter(context,
545                     folder.folderUri.fullUri));
546             cancelNotificationIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
547             cancelNotificationIntent.putExtra(Utils.EXTRA_FOLDER, folder);
548 
549             notification.setDeleteIntent(PendingIntent.getService(
550                     context, notificationId, cancelNotificationIntent, 0));
551 
552             // Ensure that the notification is cleared when the user selects it
553             notification.setAutoCancel(true);
554 
555             boolean eventInfoConfigured = false;
556 
557             final boolean isInbox = folder.folderUri.equals(account.settings.defaultInbox);
558             final FolderPreferences folderPreferences =
559                     new FolderPreferences(context, account.getEmailAddress(), folder, isInbox);
560 
561             if (isInbox) {
562                 final AccountPreferences accountPreferences =
563                         new AccountPreferences(context, account.getEmailAddress());
564                 moveNotificationSetting(accountPreferences, folderPreferences);
565             }
566 
567             if (!folderPreferences.areNotificationsEnabled()) {
568                 LogUtils.i(LOG_TAG, "Notifications are disabled for this folder; not notifying");
569                 // Don't notify
570                 return;
571             }
572 
573             if (unreadCount > 0) {
574                 // How can I order this properly?
575                 if (cursor.moveToNext()) {
576                     final Intent notificationIntent;
577 
578                     // Launch directly to the conversation, if there is only 1 unseen conversation
579                     final boolean launchConversationMode = (unseenCount == 1);
580                     if (launchConversationMode) {
581                         notificationIntent = createViewConversationIntent(context, account, folder,
582                                 cursor);
583                     } else {
584                         notificationIntent = createViewConversationIntent(context, account, folder,
585                                 null);
586                     }
587 
588                     Analytics.getInstance().sendEvent("notification_create",
589                             launchConversationMode ? "conversation" : "conversation_list",
590                             folder.getTypeDescription(), unseenCount);
591 
592                     if (notificationIntent == null) {
593                         LogUtils.e(LOG_TAG, "Null intent when building notification");
594                         return;
595                     }
596 
597                     // Amend the click intent with a hint that its source was a notification,
598                     // but remove the hint before it's used to generate notification action
599                     // intents. This prevents the following sequence:
600                     // 1. generate single notification
601                     // 2. user clicks reply, then completes Compose activity
602                     // 3. main activity launches, gets FROM_NOTIFICATION hint in intent
603                     notificationIntent.putExtra(Utils.EXTRA_FROM_NOTIFICATION, true);
604                     clickIntent = PendingIntent.getActivity(context, -1, notificationIntent,
605                             PendingIntent.FLAG_UPDATE_CURRENT);
606                     notificationIntent.removeExtra(Utils.EXTRA_FROM_NOTIFICATION);
607 
608                     configureLatestEventInfoFromConversation(context, account, folderPreferences,
609                             notification, cursor, clickIntent, notificationIntent,
610                             unreadCount, unseenCount, folder, when);
611                     eventInfoConfigured = true;
612                 }
613             }
614 
615             final boolean vibrate = folderPreferences.isNotificationVibrateEnabled();
616             final String ringtoneUri = folderPreferences.getNotificationRingtoneUri();
617             final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled();
618 
619             if (!ignoreUnobtrusiveSetting && notifyOnce) {
620                 // If the user has "unobtrusive notifications" enabled, only alert the first time
621                 // new mail is received in this account.  This is the default behavior.  See
622                 // bugs 2412348 and 2413490.
623                 notification.setOnlyAlertOnce(true);
624             }
625 
626             LogUtils.i(LOG_TAG, "Account: %s vibrate: %s",
627                     LogUtils.sanitizeName(LOG_TAG, account.name),
628                     Boolean.toString(folderPreferences.isNotificationVibrateEnabled()));
629 
630             int defaults = 0;
631 
632             /*
633              * We do not want to notify if this is coming back from an Undo notification, hence the
634              * oldWhen check.
635              */
636             if (getAttention && oldWhen == 0) {
637                 final AccountPreferences accountPreferences =
638                         new AccountPreferences(context, account.name);
639                 if (accountPreferences.areNotificationsEnabled()) {
640                     if (vibrate) {
641                         defaults |= Notification.DEFAULT_VIBRATE;
642                     }
643 
644                     notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null
645                             : Uri.parse(ringtoneUri));
646                     LogUtils.i(LOG_TAG, "New email in %s vibrateWhen: %s, playing notification: %s",
647                             LogUtils.sanitizeName(LOG_TAG, account.name), vibrate, ringtoneUri);
648                 }
649             }
650 
651             // TODO(skennedy) Why do we do any of the above if we're just going to bail here?
652             if (eventInfoConfigured) {
653                 defaults |= Notification.DEFAULT_LIGHTS;
654                 notification.setDefaults(defaults);
655 
656                 if (oldWhen != 0) {
657                     // We do not want to display the ticker again if we are re-displaying this
658                     // notification (like from an Undo notification)
659                     notification.setTicker(null);
660                 }
661 
662                 nm.notify(notificationId, notification.build());
663             } else {
664                 LogUtils.i(LOG_TAG, "event info not configured - not notifying");
665             }
666         } finally {
667             if (cursor != null) {
668                 cursor.close();
669             }
670         }
671     }
672 
673     /**
674      * @return an {@link Intent} which, if launched, will display the corresponding conversation
675      */
createViewConversationIntent(final Context context, final Account account, final Folder folder, final Cursor cursor)676     private static Intent createViewConversationIntent(final Context context, final Account account,
677             final Folder folder, final Cursor cursor) {
678         if (folder == null || account == null) {
679             LogUtils.e(LOG_TAG, "createViewConversationIntent(): "
680                     + "Null account or folder.  account: %s folder: %s", account, folder);
681             return null;
682         }
683 
684         final Intent intent;
685 
686         if (cursor == null) {
687             intent = Utils.createViewFolderIntent(context, folder.folderUri.fullUri, account);
688         } else {
689             // A conversation cursor has been specified, so this intent is intended to be go
690             // directly to the one new conversation
691 
692             // Get the Conversation object
693             final Conversation conversation = new Conversation(cursor);
694             intent = Utils.createViewConversationIntent(context, conversation,
695                     folder.folderUri.fullUri, account);
696         }
697 
698         return intent;
699     }
700 
getDefaultNotificationIcon( final Context context, final Folder folder, final boolean multipleNew)701     private static Bitmap getDefaultNotificationIcon(
702             final Context context, final Folder folder, final boolean multipleNew) {
703         final int resId;
704         if (folder.notificationIconResId != 0) {
705             resId = folder.notificationIconResId;
706         } else if (multipleNew) {
707             resId = R.drawable.ic_notification_multiple_mail_holo_dark;
708         } else {
709             resId = R.drawable.ic_contact_picture;
710         }
711 
712         final Bitmap icon = getIcon(context, resId);
713 
714         if (icon == null) {
715             LogUtils.e(LOG_TAG, "Couldn't decode notif icon res id %d", resId);
716         }
717 
718         return icon;
719     }
720 
getIcon(final Context context, final int resId)721     private static Bitmap getIcon(final Context context, final int resId) {
722         final Bitmap cachedIcon = sNotificationIcons.get(resId);
723         if (cachedIcon != null) {
724             return cachedIcon;
725         }
726 
727         final Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resId);
728         sNotificationIcons.put(resId, icon);
729 
730         return icon;
731     }
732 
configureLatestEventInfoFromConversation(final Context context, final Account account, final FolderPreferences folderPreferences, final NotificationCompat.Builder notification, final Cursor conversationCursor, final PendingIntent clickIntent, final Intent notificationIntent, final int unreadCount, final int unseenCount, final Folder folder, final long when)733     private static void configureLatestEventInfoFromConversation(final Context context,
734             final Account account, final FolderPreferences folderPreferences,
735             final NotificationCompat.Builder notification, final Cursor conversationCursor,
736             final PendingIntent clickIntent, final Intent notificationIntent,
737             final int unreadCount, final int unseenCount,
738             final Folder folder, final long when) {
739         final Resources res = context.getResources();
740         final String notificationAccount = account.name;
741 
742         LogUtils.i(LOG_TAG, "Showing notification with unreadCount of %d and unseenCount of %d",
743                 unreadCount, unseenCount);
744 
745         String notificationTicker = null;
746 
747         // Boolean indicating that this notification is for a non-inbox label.
748         final boolean isInbox = folder.folderUri.fullUri.equals(account.settings.defaultInbox);
749 
750         // Notification label name for user label notifications.
751         final String notificationLabelName = isInbox ? null : folder.name;
752 
753         if (unseenCount > 1) {
754             // Build the string that describes the number of new messages
755             final String newMessagesString = res.getString(R.string.new_messages, unseenCount);
756 
757             // Use the default notification icon
758             notification.setLargeIcon(
759                     getDefaultNotificationIcon(context, folder, true /* multiple new messages */));
760 
761             // The ticker initially start as the new messages string.
762             notificationTicker = newMessagesString;
763 
764             // The title of the notification is the new messages string
765             notification.setContentTitle(newMessagesString);
766 
767             // TODO(skennedy) Can we remove this check?
768             if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) {
769                 // For a new-style notification
770                 final int maxNumDigestItems = context.getResources().getInteger(
771                         R.integer.max_num_notification_digest_items);
772 
773                 // The body of the notification is the account name, or the label name.
774                 notification.setSubText(isInbox ? notificationAccount : notificationLabelName);
775 
776                 final NotificationCompat.InboxStyle digest =
777                         new NotificationCompat.InboxStyle(notification);
778 
779                 int numDigestItems = 0;
780                 do {
781                     final Conversation conversation = new Conversation(conversationCursor);
782 
783                     if (!conversation.read) {
784                         boolean multipleUnreadThread = false;
785                         // TODO(cwren) extract this pattern into a helper
786 
787                         Cursor cursor = null;
788                         MessageCursor messageCursor = null;
789                         try {
790                             final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon();
791                             uriBuilder.appendQueryParameter(
792                                     UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName);
793                             cursor = context.getContentResolver().query(uriBuilder.build(),
794                                     UIProvider.MESSAGE_PROJECTION, null, null, null);
795                             messageCursor = new MessageCursor(cursor);
796 
797                             String from = "";
798                             String fromAddress = "";
799                             if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
800                                 final Message message = messageCursor.getMessage();
801                                 fromAddress = message.getFrom();
802                                 if (fromAddress == null) {
803                                     fromAddress = "";
804                                 }
805                                 from = getDisplayableSender(fromAddress);
806                             }
807                             while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
808                                 final Message message = messageCursor.getMessage();
809                                 if (!message.read &&
810                                         !fromAddress.contentEquals(message.getFrom())) {
811                                     multipleUnreadThread = true;
812                                     break;
813                                 }
814                             }
815                             final SpannableStringBuilder sendersBuilder;
816                             if (multipleUnreadThread) {
817                                 final int sendersLength =
818                                         res.getInteger(R.integer.swipe_senders_length);
819 
820                                 sendersBuilder = getStyledSenders(context, conversationCursor,
821                                         sendersLength, notificationAccount);
822                             } else {
823                                 sendersBuilder =
824                                         new SpannableStringBuilder(getWrappedFromString(from));
825                             }
826                             final CharSequence digestLine = getSingleMessageInboxLine(context,
827                                     sendersBuilder.toString(),
828                                     conversation.subject,
829                                     conversation.snippet);
830                             digest.addLine(digestLine);
831                             numDigestItems++;
832                         } finally {
833                             if (messageCursor != null) {
834                                 messageCursor.close();
835                             }
836                             if (cursor != null) {
837                                 cursor.close();
838                             }
839                         }
840                     }
841                 } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
842             } else {
843                 // The body of the notification is the account name, or the label name.
844                 notification.setContentText(
845                         isInbox ? notificationAccount : notificationLabelName);
846             }
847         } else {
848             // For notifications for a single new conversation, we want to get the information from
849             // the conversation
850 
851             // Move the cursor to the most recent unread conversation
852             seekToLatestUnreadConversation(conversationCursor);
853 
854             final Conversation conversation = new Conversation(conversationCursor);
855 
856             Cursor cursor = null;
857             MessageCursor messageCursor = null;
858             boolean multipleUnseenThread = false;
859             String from = null;
860             try {
861                 final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
862                         UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
863                 cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
864                         null, null, null);
865                 messageCursor = new MessageCursor(cursor);
866                 // Use the information from the last sender in the conversation that triggered
867                 // this notification.
868 
869                 String fromAddress = "";
870                 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
871                     final Message message = messageCursor.getMessage();
872                     fromAddress = message.getFrom();
873                     from = getDisplayableSender(fromAddress);
874                     notification.setLargeIcon(
875                             getContactIcon(context, from, getSenderAddress(fromAddress), folder));
876                 }
877 
878                 // Assume that the last message in this conversation is unread
879                 int firstUnseenMessagePos = messageCursor.getPosition();
880                 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
881                     final Message message = messageCursor.getMessage();
882                     final boolean unseen = !message.seen;
883                     if (unseen) {
884                         firstUnseenMessagePos = messageCursor.getPosition();
885                         if (!multipleUnseenThread
886                                 && !fromAddress.contentEquals(message.getFrom())) {
887                             multipleUnseenThread = true;
888                         }
889                     }
890                 }
891 
892                 // TODO(skennedy) Can we remove this check?
893                 if (Utils.isRunningJellybeanOrLater()) {
894                     // For a new-style notification
895 
896                     if (multipleUnseenThread) {
897                         // The title of a single conversation is the list of senders.
898                         int sendersLength = res.getInteger(R.integer.swipe_senders_length);
899 
900                         final SpannableStringBuilder sendersBuilder = getStyledSenders(
901                                 context, conversationCursor, sendersLength, notificationAccount);
902 
903                         notification.setContentTitle(sendersBuilder);
904                         // For a single new conversation, the ticker is based on the sender's name.
905                         notificationTicker = sendersBuilder.toString();
906                     } else {
907                         from = getWrappedFromString(from);
908                         // The title of a single message the sender.
909                         notification.setContentTitle(from);
910                         // For a single new conversation, the ticker is based on the sender's name.
911                         notificationTicker = from;
912                     }
913 
914                     // The notification content will be the subject of the conversation.
915                     notification.setContentText(
916                             getSingleMessageLittleText(context, conversation.subject));
917 
918                     // The notification subtext will be the subject of the conversation for inbox
919                     // notifications, or will based on the the label name for user label
920                     // notifications.
921                     notification.setSubText(isInbox ? notificationAccount : notificationLabelName);
922 
923                     if (multipleUnseenThread) {
924                         notification.setLargeIcon(
925                                 getDefaultNotificationIcon(context, folder, true));
926                     }
927                     final NotificationCompat.BigTextStyle bigText =
928                             new NotificationCompat.BigTextStyle(notification);
929 
930                     // Seek the message cursor to the first unread message
931                     final Message message;
932                     if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
933                         message = messageCursor.getMessage();
934                         bigText.bigText(getSingleMessageBigText(context,
935                                 conversation.subject, message));
936                     } else {
937                         LogUtils.e(LOG_TAG, "Failed to load message");
938                         message = null;
939                     }
940 
941                     if (message != null) {
942                         final Set<String> notificationActions =
943                                 folderPreferences.getNotificationActions(account);
944 
945                         final int notificationId = getNotificationId(
946                                 account.getAccountManagerAccount(), folder);
947 
948                         NotificationActionUtils.addNotificationActions(context, notificationIntent,
949                                 notification, account, conversation, message, folder,
950                                 notificationId, when, notificationActions);
951                     }
952                 } else {
953                     // For an old-style notification
954 
955                     // The title of a single conversation notification is built from both the sender
956                     // and subject of the new message.
957                     notification.setContentTitle(getSingleMessageNotificationTitle(context,
958                             from, conversation.subject));
959 
960                     // The notification content will be the subject of the conversation for inbox
961                     // notifications, or will based on the the label name for user label
962                     // notifications.
963                     notification.setContentText(
964                             isInbox ? notificationAccount : notificationLabelName);
965 
966                     // For a single new conversation, the ticker is based on the sender's name.
967                     notificationTicker = from;
968                 }
969             } finally {
970                 if (messageCursor != null) {
971                     messageCursor.close();
972                 }
973                 if (cursor != null) {
974                     cursor.close();
975                 }
976             }
977         }
978 
979         // Build the notification ticker
980         if (notificationLabelName != null && notificationTicker != null) {
981             // This is a per label notification, format the ticker with that information
982             notificationTicker = res.getString(R.string.label_notification_ticker,
983                     notificationLabelName, notificationTicker);
984         }
985 
986         if (notificationTicker != null) {
987             // If we didn't generate a notification ticker, it will default to account name
988             notification.setTicker(notificationTicker);
989         }
990 
991         // Set the number in the notification
992         if (unreadCount > 1) {
993             notification.setNumber(unreadCount);
994         }
995 
996         notification.setContentIntent(clickIntent);
997     }
998 
getWrappedFromString(String from)999     private static String getWrappedFromString(String from) {
1000         if (from == null) {
1001             LogUtils.e(LOG_TAG, "null from string in getWrappedFromString");
1002             from = "";
1003         }
1004         from = BIDI_FORMATTER.unicodeWrap(from);
1005         return from;
1006     }
1007 
getStyledSenders(final Context context, final Cursor conversationCursor, final int maxLength, final String account)1008     private static SpannableStringBuilder getStyledSenders(final Context context,
1009             final Cursor conversationCursor, final int maxLength, final String account) {
1010         final Conversation conversation = new Conversation(conversationCursor);
1011         final com.android.mail.providers.ConversationInfo conversationInfo =
1012                 conversation.conversationInfo;
1013         final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
1014         if (sNotificationUnreadStyleSpan == null) {
1015             sNotificationUnreadStyleSpan = new TextAppearanceSpan(
1016                     context, R.style.NotificationSendersUnreadTextAppearance);
1017             sNotificationReadStyleSpan =
1018                     new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
1019         }
1020         SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
1021                 sNotificationUnreadStyleSpan, sNotificationReadStyleSpan, false);
1022 
1023         return ellipsizeStyledSenders(context, senders);
1024     }
1025 
1026     private static String sSendersSplitToken = null;
1027     private static String sElidedPaddingToken = null;
1028 
ellipsizeStyledSenders(final Context context, ArrayList<SpannableString> styledSenders)1029     private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
1030             ArrayList<SpannableString> styledSenders) {
1031         if (sSendersSplitToken == null) {
1032             sSendersSplitToken = context.getString(R.string.senders_split_token);
1033             sElidedPaddingToken = context.getString(R.string.elided_padding_token);
1034         }
1035 
1036         SpannableStringBuilder builder = new SpannableStringBuilder();
1037         SpannableString prevSender = null;
1038         for (SpannableString sender : styledSenders) {
1039             if (sender == null) {
1040                 LogUtils.e(LOG_TAG, "null sender iterating over styledSenders");
1041                 continue;
1042             }
1043             CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
1044             if (SendersView.sElidedString.equals(sender.toString())) {
1045                 prevSender = sender;
1046                 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
1047             } else if (builder.length() > 0
1048                     && (prevSender == null || !SendersView.sElidedString.equals(prevSender
1049                             .toString()))) {
1050                 prevSender = sender;
1051                 sender = copyStyles(spans, sSendersSplitToken + sender);
1052             } else {
1053                 prevSender = sender;
1054             }
1055             builder.append(sender);
1056         }
1057         return builder;
1058     }
1059 
copyStyles(CharacterStyle[] spans, CharSequence newText)1060     private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
1061         SpannableString s = new SpannableString(newText);
1062         if (spans != null && spans.length > 0) {
1063             s.setSpan(spans[0], 0, s.length(), 0);
1064         }
1065         return s;
1066     }
1067 
1068     /**
1069      * Seeks the cursor to the position of the most recent unread conversation. If no unread
1070      * conversation is found, the position of the cursor will be restored, and false will be
1071      * returned.
1072      */
seekToLatestUnreadConversation(final Cursor cursor)1073     private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
1074         final int initialPosition = cursor.getPosition();
1075         do {
1076             final Conversation conversation = new Conversation(cursor);
1077             if (!conversation.read) {
1078                 return true;
1079             }
1080         } while (cursor.moveToNext());
1081 
1082         // Didn't find an unread conversation, reset the position.
1083         cursor.moveToPosition(initialPosition);
1084         return false;
1085     }
1086 
1087     /**
1088      * Sets the bigtext for a notification for a single new conversation
1089      *
1090      * @param context
1091      * @param senders Sender of the new message that triggered the notification.
1092      * @param subject Subject of the new message that triggered the notification
1093      * @param snippet Snippet of the new message that triggered the notification
1094      * @return a {@link CharSequence} suitable for use in
1095      *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1096      */
getSingleMessageInboxLine(Context context, String senders, String subject, String snippet)1097     private static CharSequence getSingleMessageInboxLine(Context context,
1098             String senders, String subject, String snippet) {
1099         // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1100 
1101         final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1102 
1103         final TextAppearanceSpan notificationPrimarySpan =
1104                 new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1105 
1106         if (TextUtils.isEmpty(senders)) {
1107             // If the senders are empty, just use the subject/snippet.
1108             return subjectSnippet;
1109         } else if (TextUtils.isEmpty(subjectSnippet)) {
1110             // If the subject/snippet is empty, just use the senders.
1111             final SpannableString spannableString = new SpannableString(senders);
1112             spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1113 
1114             return spannableString;
1115         } else {
1116             final String formatString = context.getResources().getString(
1117                     R.string.multiple_new_message_notification_item);
1118             final TextAppearanceSpan notificationSecondarySpan =
1119                     new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1120 
1121             // senders is already individually unicode wrapped so it does not need to be done here
1122             final String instantiatedString = String.format(formatString,
1123                     senders,
1124                     BIDI_FORMATTER.unicodeWrap(subjectSnippet));
1125 
1126             final SpannableString spannableString = new SpannableString(instantiatedString);
1127 
1128             final boolean isOrderReversed = formatString.indexOf("%2$s") <
1129                     formatString.indexOf("%1$s");
1130             final int primaryOffset =
1131                     (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1132                      instantiatedString.indexOf(senders));
1133             final int secondaryOffset =
1134                     (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1135                      instantiatedString.indexOf(subjectSnippet));
1136             spannableString.setSpan(notificationPrimarySpan,
1137                     primaryOffset, primaryOffset + senders.length(), 0);
1138             spannableString.setSpan(notificationSecondarySpan,
1139                     secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1140             return spannableString;
1141         }
1142     }
1143 
1144     /**
1145      * Sets the bigtext for a notification for a single new conversation
1146      * @param context
1147      * @param subject Subject of the new message that triggered the notification
1148      * @return a {@link CharSequence} suitable for use in
1149      * {@link NotificationCompat.Builder#setContentText}
1150      */
1151     private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1152         final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1153                 context, R.style.NotificationPrimaryText);
1154 
1155         final SpannableString spannableString = new SpannableString(subject);
1156         spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1157 
1158         return spannableString;
1159     }
1160 
1161     /**
1162      * Sets the bigtext for a notification for a single new conversation
1163      *
1164      * @param context
1165      * @param subject Subject of the new message that triggered the notification
1166      * @param message the {@link Message} to be displayed.
1167      * @return a {@link CharSequence} suitable for use in
1168      *         {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1169      */
1170     private static CharSequence getSingleMessageBigText(Context context, String subject,
1171             final Message message) {
1172 
1173         final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1174                 context, R.style.NotificationPrimaryText);
1175 
1176         final String snippet = getMessageBodyWithoutElidedText(message);
1177 
1178         // Change multiple newlines (with potential white space between), into a single new line
1179         final String collapsedSnippet =
1180                 !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1181 
1182         if (TextUtils.isEmpty(subject)) {
1183             // If the subject is empty, just use the snippet.
1184             return snippet;
1185         } else if (TextUtils.isEmpty(collapsedSnippet)) {
1186             // If the snippet is empty, just use the subject.
1187             final SpannableString spannableString = new SpannableString(subject);
1188             spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1189 
1190             return spannableString;
1191         } else {
1192             final String notificationBigTextFormat = context.getResources().getString(
1193                     R.string.single_new_message_notification_big_text);
1194 
1195             // Localizers may change the order of the parameters, look at how the format
1196             // string is structured.
1197             final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1198                     notificationBigTextFormat.indexOf("%1$s");
1199             final String bigText =
1200                     String.format(notificationBigTextFormat, subject, collapsedSnippet);
1201             final SpannableString spannableString = new SpannableString(bigText);
1202 
1203             final int subjectOffset =
1204                     (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1205             spannableString.setSpan(notificationSubjectSpan,
1206                     subjectOffset, subjectOffset + subject.length(), 0);
1207 
1208             return spannableString;
1209         }
1210     }
1211 
1212     /**
1213      * Gets the title for a notification for a single new conversation
1214      * @param context
1215      * @param sender Sender of the new message that triggered the notification.
1216      * @param subject Subject of the new message that triggered the notification
1217      * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1218      */
getSingleMessageNotificationTitle(Context context, String sender, String subject)1219     private static CharSequence getSingleMessageNotificationTitle(Context context,
1220             String sender, String subject) {
1221 
1222         if (TextUtils.isEmpty(subject)) {
1223             // If the subject is empty, just set the title to the sender's information.
1224             return sender;
1225         } else {
1226             final String notificationTitleFormat = context.getResources().getString(
1227                     R.string.single_new_message_notification_title);
1228 
1229             // Localizers may change the order of the parameters, look at how the format
1230             // string is structured.
1231             final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1232                     notificationTitleFormat.indexOf("%1$s");
1233             final String titleString = String.format(notificationTitleFormat, sender, subject);
1234 
1235             // Format the string so the subject is using the secondaryText style
1236             final SpannableString titleSpannable = new SpannableString(titleString);
1237 
1238             // Find the offset of the subject.
1239             final int subjectOffset =
1240                     isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1241             final TextAppearanceSpan notificationSubjectSpan =
1242                     new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1243             titleSpannable.setSpan(notificationSubjectSpan,
1244                     subjectOffset, subjectOffset + subject.length(), 0);
1245             return titleSpannable;
1246         }
1247     }
1248 
1249     /**
1250      * Clears the notifications for the specified account/folder.
1251      */
clearFolderNotification(Context context, Account account, Folder folder, final boolean markSeen)1252     public static void clearFolderNotification(Context context, Account account, Folder folder,
1253             final boolean markSeen) {
1254         LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.name, folder.name);
1255         final NotificationMap notificationMap = getNotificationMap(context);
1256         final NotificationKey key = new NotificationKey(account, folder);
1257         notificationMap.remove(key);
1258         notificationMap.saveNotificationMap(context);
1259 
1260         final NotificationManager notificationManager =
1261                 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
1262         notificationManager.cancel(getNotificationId(account.getAccountManagerAccount(), folder));
1263 
1264         if (markSeen) {
1265             markSeen(context, folder);
1266         }
1267     }
1268 
1269     /**
1270      * Clears all notifications for the specified account.
1271      */
clearAccountNotifications(final Context context, final android.accounts.Account account)1272     public static void clearAccountNotifications(final Context context,
1273             final android.accounts.Account account) {
1274         LogUtils.v(LOG_TAG, "Clearing all notifications for %s", account);
1275         final NotificationMap notificationMap = getNotificationMap(context);
1276 
1277         // Find all NotificationKeys for this account
1278         final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder();
1279 
1280         for (final NotificationKey key : notificationMap.keySet()) {
1281             if (account.equals(key.account.getAccountManagerAccount())) {
1282                 keyBuilder.add(key);
1283             }
1284         }
1285 
1286         final List<NotificationKey> notificationKeys = keyBuilder.build();
1287 
1288         final NotificationManager notificationManager =
1289                 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
1290 
1291         for (final NotificationKey notificationKey : notificationKeys) {
1292             final Folder folder = notificationKey.folder;
1293             notificationManager.cancel(getNotificationId(account, folder));
1294             notificationMap.remove(notificationKey);
1295         }
1296 
1297         notificationMap.saveNotificationMap(context);
1298     }
1299 
findContacts(Context context, Collection<String> addresses)1300     private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1301         ArrayList<String> whereArgs = new ArrayList<String>();
1302         StringBuilder whereBuilder = new StringBuilder();
1303         String[] questionMarks = new String[addresses.size()];
1304 
1305         whereArgs.addAll(addresses);
1306         Arrays.fill(questionMarks, "?");
1307         whereBuilder.append(Email.DATA1 + " IN (").
1308                 append(TextUtils.join(",", questionMarks)).
1309                 append(")");
1310 
1311         ContentResolver resolver = context.getContentResolver();
1312         Cursor c = resolver.query(Email.CONTENT_URI,
1313                 new String[]{Email.CONTACT_ID}, whereBuilder.toString(),
1314                 whereArgs.toArray(new String[0]), null);
1315 
1316         ArrayList<Long> contactIds = new ArrayList<Long>();
1317         if (c == null) {
1318             return contactIds;
1319         }
1320         try {
1321             while (c.moveToNext()) {
1322                 contactIds.add(c.getLong(0));
1323             }
1324         } finally {
1325             c.close();
1326         }
1327         return contactIds;
1328     }
1329 
getContactIcon(final Context context, final String displayName, final String senderAddress, final Folder folder)1330     private static Bitmap getContactIcon(final Context context, final String displayName,
1331             final String senderAddress, final Folder folder) {
1332         if (senderAddress == null) {
1333             return null;
1334         }
1335 
1336         Bitmap icon = null;
1337 
1338         final List<Long> contactIds = findContacts( context, Arrays.asList(
1339                 new String[] { senderAddress }));
1340 
1341         // Get the ideal size for this icon.
1342         final Resources res = context.getResources();
1343         final int idealIconHeight =
1344                 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1345         final int idealIconWidth =
1346                 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1347 
1348         if (contactIds != null) {
1349             for (final long id : contactIds) {
1350                 final Uri contactUri =
1351                         ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id);
1352                 final Uri photoUri = Uri.withAppendedPath(contactUri, Photo.CONTENT_DIRECTORY);
1353                 final Cursor cursor = context.getContentResolver().query(
1354                         photoUri, new String[] { Photo.PHOTO }, null, null, null);
1355 
1356                 if (cursor != null) {
1357                     try {
1358                         if (cursor.moveToFirst()) {
1359                             final byte[] data = cursor.getBlob(0);
1360                             if (data != null) {
1361                                 icon = BitmapFactory.decodeStream(new ByteArrayInputStream(data));
1362                                 if (icon != null && icon.getHeight() < idealIconHeight) {
1363                                     // We should scale this image to fit the intended size
1364                                     icon = Bitmap.createScaledBitmap(
1365                                             icon, idealIconWidth, idealIconHeight, true);
1366                                 }
1367                                 if (icon != null) {
1368                                     break;
1369                                 }
1370                             }
1371                         }
1372                     } finally {
1373                         cursor.close();
1374                     }
1375                 }
1376             }
1377         }
1378 
1379         if (icon == null) {
1380             // Make a colorful tile!
1381             final Dimensions dimensions = new Dimensions(idealIconWidth, idealIconHeight,
1382                     Dimensions.SCALE_ONE);
1383 
1384             icon = new LetterTileProvider(context).getLetterTile(dimensions,
1385                     displayName, senderAddress);
1386         }
1387 
1388         if (icon == null) {
1389             // Icon should be the default mail icon.
1390             icon = getDefaultNotificationIcon(context, folder, false /* single new message */);
1391         }
1392         return icon;
1393     }
1394 
getMessageBodyWithoutElidedText(final Message message)1395     private static String getMessageBodyWithoutElidedText(final Message message) {
1396         return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
1397     }
1398 
getMessageBodyWithoutElidedText(String html)1399     public static String getMessageBodyWithoutElidedText(String html) {
1400         if (TextUtils.isEmpty(html)) {
1401             return "";
1402         }
1403         // Get the html "tree" for this message body
1404         final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1405         htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY);
1406 
1407         return htmlTree.getPlainText();
1408     }
1409 
markSeen(final Context context, final Folder folder)1410     public static void markSeen(final Context context, final Folder folder) {
1411         final Uri uri = folder.folderUri.fullUri;
1412 
1413         final ContentValues values = new ContentValues(1);
1414         values.put(UIProvider.ConversationColumns.SEEN, 1);
1415 
1416         context.getContentResolver().update(uri, values, null, null);
1417     }
1418 
1419     /**
1420      * Returns a displayable string representing
1421      * the message sender. It has a preference toward showing the name,
1422      * but will fall back to the address if that is all that is available.
1423      */
getDisplayableSender(String sender)1424     private static String getDisplayableSender(String sender) {
1425         final EmailAddress address = EmailAddress.getEmailAddress(sender);
1426 
1427         String displayableSender = address.getName();
1428 
1429         if (!TextUtils.isEmpty(displayableSender)) {
1430             return Address.decodeAddressName(displayableSender);
1431         }
1432 
1433         // If that fails, default to the sender address.
1434         displayableSender = address.getAddress();
1435 
1436         // If we were unable to tokenize a name or address,
1437         // just use whatever was in the sender.
1438         if (TextUtils.isEmpty(displayableSender)) {
1439             displayableSender = sender;
1440         }
1441         return displayableSender;
1442     }
1443 
1444     /**
1445      * Returns only the address portion of a message sender.
1446      */
getSenderAddress(String sender)1447     private static String getSenderAddress(String sender) {
1448         final EmailAddress address = EmailAddress.getEmailAddress(sender);
1449 
1450         String tokenizedAddress = address.getAddress();
1451 
1452         // If we were unable to tokenize a name or address,
1453         // just use whatever was in the sender.
1454         if (TextUtils.isEmpty(tokenizedAddress)) {
1455             tokenizedAddress = sender;
1456         }
1457         return tokenizedAddress;
1458     }
1459 
getNotificationId(final android.accounts.Account account, final Folder folder)1460     public static int getNotificationId(final android.accounts.Account account,
1461             final Folder folder) {
1462         return 1 ^ account.hashCode() ^ folder.hashCode();
1463     }
1464 
1465     private static class NotificationKey {
1466         public final Account account;
1467         public final Folder folder;
1468 
NotificationKey(Account account, Folder folder)1469         public NotificationKey(Account account, Folder folder) {
1470             this.account = account;
1471             this.folder = folder;
1472         }
1473 
1474         @Override
equals(Object other)1475         public boolean equals(Object other) {
1476             if (!(other instanceof NotificationKey)) {
1477                 return false;
1478             }
1479             NotificationKey key = (NotificationKey) other;
1480             return account.getAccountManagerAccount().equals(key.account.getAccountManagerAccount())
1481                     && folder.equals(key.folder);
1482         }
1483 
1484         @Override
toString()1485         public String toString() {
1486             return account.name + " " + folder.name;
1487         }
1488 
1489         @Override
hashCode()1490         public int hashCode() {
1491             final int accountHashCode = account.getAccountManagerAccount().hashCode();
1492             final int folderHashCode = folder.hashCode();
1493             return accountHashCode ^ folderHashCode;
1494         }
1495     }
1496 
1497     /**
1498      * Contains the logic for converting the contents of one HtmlTree into
1499      * plaintext.
1500      */
1501     public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1502         // Strings for parsing html message bodies
1503         private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1504         private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1505         private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1506 
1507         private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1508                 new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1509 
1510         private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
1511                 HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
1512 
1513         private int mEndNodeElidedTextBlock = -1;
1514 
1515         @Override
addNode(HtmlDocument.Node n, int nodeNum, int endNum)1516         public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
1517             // If we are in the middle of an elided text block, don't add this node
1518             if (nodeNum < mEndNodeElidedTextBlock) {
1519                 return;
1520             } else if (nodeNum == mEndNodeElidedTextBlock) {
1521                 super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
1522                 return;
1523             }
1524 
1525             // If this tag starts another elided text block, we want to remember the end
1526             if (n instanceof HtmlDocument.Tag) {
1527                 boolean foundElidedTextTag = false;
1528                 final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
1529                 final HTML.Element htmlElement = htmlTag.getElement();
1530                 if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
1531                     // Make sure that the class is what is expected
1532                     final List<HtmlDocument.TagAttribute> attributes =
1533                             htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
1534                     for (HtmlDocument.TagAttribute attribute : attributes) {
1535                         if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
1536                                 attribute.getValue())) {
1537                             // Found an "elided-text" div.  Remember information about this tag
1538                             mEndNodeElidedTextBlock = endNum;
1539                             foundElidedTextTag = true;
1540                             break;
1541                         }
1542                     }
1543                 }
1544 
1545                 if (foundElidedTextTag) {
1546                     return;
1547                 }
1548             }
1549 
1550             super.addNode(n, nodeNum, endNum);
1551         }
1552     }
1553 
1554     /**
1555      * During account setup in Email, we may not have an inbox yet, so the notification setting had
1556      * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
1557      * {@link FolderPreferences} now.
1558      */
moveNotificationSetting(final AccountPreferences accountPreferences, final FolderPreferences folderPreferences)1559     public static void moveNotificationSetting(final AccountPreferences accountPreferences,
1560             final FolderPreferences folderPreferences) {
1561         if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
1562             // If this setting has been changed some other way, don't overwrite it
1563             if (!folderPreferences.isNotificationsEnabledSet()) {
1564                 final boolean notificationsEnabled =
1565                         accountPreferences.getDefaultInboxNotificationsEnabled();
1566 
1567                 folderPreferences.setNotificationsEnabled(notificationsEnabled);
1568             }
1569 
1570             accountPreferences.clearDefaultInboxNotificationsEnabled();
1571         }
1572     }
1573 }
1574