blob: b91ee77f936699a4cd976d3daeeaef24114b4f59 [file] [log] [blame]
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.mail.utils;
17
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080018import android.app.Notification;
19import android.app.NotificationManager;
20import android.app.PendingIntent;
21import android.content.ContentResolver;
22import android.content.ContentUris;
23import android.content.ContentValues;
24import android.content.Context;
25import android.content.Intent;
26import android.content.res.Resources;
27import android.database.Cursor;
28import android.graphics.Bitmap;
29import android.graphics.BitmapFactory;
30import android.net.Uri;
31import android.provider.ContactsContract;
32import android.provider.ContactsContract.CommonDataKinds.Email;
33import android.provider.ContactsContract.Contacts.Photo;
34import android.support.v4.app.NotificationCompat;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080035import android.text.SpannableString;
36import android.text.SpannableStringBuilder;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080037import android.text.TextUtils;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080038import android.text.style.CharacterStyle;
39import android.text.style.TextAppearanceSpan;
40import android.util.Pair;
Scott Kennedy61bd0e82012-12-10 18:18:17 -080041import android.util.SparseArray;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080042
43import com.android.mail.EmailAddress;
44import com.android.mail.MailIntentService;
45import com.android.mail.R;
46import com.android.mail.browse.MessageCursor;
47import com.android.mail.browse.SendersView;
48import com.android.mail.preferences.AccountPreferences;
49import com.android.mail.preferences.FolderPreferences;
50import com.android.mail.preferences.MailPrefs;
51import com.android.mail.providers.Account;
52import com.android.mail.providers.Conversation;
53import com.android.mail.providers.Folder;
54import com.android.mail.providers.Message;
55import com.android.mail.providers.UIProvider;
56import com.android.mail.utils.NotificationActionUtils.NotificationAction;
Scott Kennedy09400ef2013-03-07 11:09:47 -080057import com.google.android.common.html.parser.HTML;
58import com.google.android.common.html.parser.HTML4;
59import com.google.android.common.html.parser.HtmlDocument;
60import com.google.android.common.html.parser.HtmlTree;
Andrew Sappersteinddd17bc2013-04-22 14:03:40 -070061import com.google.common.base.Objects;
Scott Kennedy7f8aed62013-05-22 18:35:17 -070062import com.google.common.collect.ImmutableList;
Scott Kennedy09400ef2013-03-07 11:09:47 -080063import com.google.common.collect.Lists;
Scott Kennedy09400ef2013-03-07 11:09:47 -080064import com.google.common.collect.Sets;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080065
66import java.io.ByteArrayInputStream;
67import java.util.ArrayList;
68import java.util.Arrays;
69import java.util.Collection;
Scott Kennedy01c196a2013-03-25 16:05:55 -040070import java.util.Deque;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080071import java.util.List;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080072import java.util.Set;
73import java.util.concurrent.ConcurrentHashMap;
74
75public class NotificationUtils {
76 public static final String LOG_TAG = LogTag.getLogTag();
77
78 /** Contains a list of <(account, label), unread conversations> */
79 private static NotificationMap sActiveNotificationMap = null;
80
Scott Kennedy61bd0e82012-12-10 18:18:17 -080081 private static final SparseArray<Bitmap> sNotificationIcons = new SparseArray<Bitmap>();
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080082
83 private static TextAppearanceSpan sNotificationUnreadStyleSpan;
84 private static CharacterStyle sNotificationReadStyleSpan;
85
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080086 /** A factory that produces a plain text converter that removes elided text. */
87 private static final HtmlTree.PlainTextConverterFactory MESSAGE_CONVERTER_FACTORY =
88 new HtmlTree.PlainTextConverterFactory() {
89 @Override
90 public HtmlTree.PlainTextConverter createInstance() {
91 return new MailMessagePlainTextConverter();
92 }
93 };
94
95 /**
96 * Clears all notifications in response to the user tapping "Clear" in the status bar.
97 */
98 public static void clearAllNotfications(Context context) {
Scott Kennedy13a73272013-05-09 09:45:03 -070099 LogUtils.v(LOG_TAG, "NotificationUtils: Clearing all notifications.");
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800100 final NotificationMap notificationMap = getNotificationMap(context);
101 notificationMap.clear();
102 notificationMap.saveNotificationMap(context);
103 }
104
105 /**
106 * Returns the notification map, creating it if necessary.
107 */
108 private static synchronized NotificationMap getNotificationMap(Context context) {
109 if (sActiveNotificationMap == null) {
110 sActiveNotificationMap = new NotificationMap();
111
112 // populate the map from the cached data
113 sActiveNotificationMap.loadNotificationMap(context);
114 }
115 return sActiveNotificationMap;
116 }
117
118 /**
119 * Class representing the existing notifications, and the number of unread and
120 * unseen conversations that triggered each.
121 */
122 private static class NotificationMap
123 extends ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>> {
124
125 private static final String NOTIFICATION_PART_SEPARATOR = " ";
126 private static final int NUM_NOTIFICATION_PARTS= 4;
127
128 /**
129 * Retuns the unread count for the given NotificationKey.
130 */
131 public Integer getUnread(NotificationKey key) {
132 final Pair<Integer, Integer> value = get(key);
133 return value != null ? value.first : null;
134 }
135
136 /**
137 * Retuns the unread unseen count for the given NotificationKey.
138 */
139 public Integer getUnseen(NotificationKey key) {
140 final Pair<Integer, Integer> value = get(key);
141 return value != null ? value.second : null;
142 }
143
144 /**
145 * Store the unread and unseen value for the given NotificationKey
146 */
147 public void put(NotificationKey key, int unread, int unseen) {
148 final Pair<Integer, Integer> value =
149 new Pair<Integer, Integer>(Integer.valueOf(unread), Integer.valueOf(unseen));
150 put(key, value);
151 }
152
153 /**
154 * Populates the notification map with previously cached data.
155 */
156 public synchronized void loadNotificationMap(final Context context) {
157 final MailPrefs mailPrefs = MailPrefs.get(context);
158 final Set<String> notificationSet = mailPrefs.getActiveNotificationSet();
159 if (notificationSet != null) {
160 for (String notificationEntry : notificationSet) {
161 // Get the parts of the string that make the notification entry
162 final String[] notificationParts =
163 TextUtils.split(notificationEntry, NOTIFICATION_PART_SEPARATOR);
164 if (notificationParts.length == NUM_NOTIFICATION_PARTS) {
165 final Uri accountUri = Uri.parse(notificationParts[0]);
166 final Cursor accountCursor = context.getContentResolver().query(
167 accountUri, UIProvider.ACCOUNTS_PROJECTION, null, null, null);
168 final Account account;
169 try {
170 if (accountCursor.moveToFirst()) {
171 account = new Account(accountCursor);
172 } else {
173 continue;
174 }
175 } finally {
176 accountCursor.close();
177 }
178
179 final Uri folderUri = Uri.parse(notificationParts[1]);
180 final Cursor folderCursor = context.getContentResolver().query(
181 folderUri, UIProvider.FOLDERS_PROJECTION, null, null, null);
182 final Folder folder;
183 try {
184 if (folderCursor.moveToFirst()) {
185 folder = new Folder(folderCursor);
186 } else {
187 continue;
188 }
189 } finally {
190 folderCursor.close();
191 }
192
193 final NotificationKey key = new NotificationKey(account, folder);
194 final Integer unreadValue = Integer.valueOf(notificationParts[2]);
195 final Integer unseenValue = Integer.valueOf(notificationParts[3]);
196 final Pair<Integer, Integer> unreadUnseenValue =
197 new Pair<Integer, Integer>(unreadValue, unseenValue);
198 put(key, unreadUnseenValue);
199 }
200 }
201 }
202 }
203
204 /**
205 * Cache the notification map.
206 */
207 public synchronized void saveNotificationMap(Context context) {
208 final Set<String> notificationSet = Sets.newHashSet();
209 final Set<NotificationKey> keys = keySet();
210 for (NotificationKey key : keys) {
211 final Pair<Integer, Integer> value = get(key);
212 final Integer unreadCount = value.first;
213 final Integer unseenCount = value.second;
Scott Kennedyff8553f2013-04-05 20:57:44 -0700214 if (unreadCount != null && unseenCount != null) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800215 final String[] partValues = new String[] {
216 key.account.uri.toString(), key.folder.uri.toString(),
217 unreadCount.toString(), unseenCount.toString()};
218 notificationSet.add(TextUtils.join(NOTIFICATION_PART_SEPARATOR, partValues));
219 }
220 }
221 final MailPrefs mailPrefs = MailPrefs.get(context);
222 mailPrefs.cacheActiveNotificationSet(notificationSet);
223 }
224 }
225
226 /**
227 * @return the title of this notification with each account and the number of unread and unseen
228 * conversations for it. Also remove any account in the map that has 0 unread.
229 */
230 private static String createNotificationString(NotificationMap notifications) {
231 StringBuilder result = new StringBuilder();
232 int i = 0;
233 Set<NotificationKey> keysToRemove = Sets.newHashSet();
234 for (NotificationKey key : notifications.keySet()) {
235 Integer unread = notifications.getUnread(key);
236 Integer unseen = notifications.getUnseen(key);
237 if (unread == null || unread.intValue() == 0) {
238 keysToRemove.add(key);
239 } else {
240 if (i > 0) result.append(", ");
241 result.append(key.toString() + " (" + unread + ", " + unseen + ")");
242 i++;
243 }
244 }
245
246 for (NotificationKey key : keysToRemove) {
247 notifications.remove(key);
248 }
249
250 return result.toString();
251 }
252
253 /**
254 * Get all notifications for all accounts and cancel them.
255 **/
256 public static void cancelAllNotifications(Context context) {
Scott Kennedy93486c02013-04-17 12:15:43 -0700257 LogUtils.d(LOG_TAG, "NotificationUtils: cancelAllNotifications - cancelling all");
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800258 NotificationManager nm = (NotificationManager) context.getSystemService(
259 Context.NOTIFICATION_SERVICE);
260 nm.cancelAll();
261 clearAllNotfications(context);
262 }
263
264 /**
265 * Get all notifications for all accounts, cancel them, and repost.
266 * This happens when locale changes.
267 **/
268 public static void cancelAndResendNotifications(Context context) {
Scott Kennedy4162f832013-05-06 17:37:55 -0700269 LogUtils.d(LOG_TAG, "NotificationUtils: cancelAndResendNotifications");
Andrew Sappersteinddd17bc2013-04-22 14:03:40 -0700270 resendNotifications(context, true, null, null);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800271 }
272
273 /**
274 * Get all notifications for all accounts, optionally cancel them, and repost.
Andrew Sappersteinddd17bc2013-04-22 14:03:40 -0700275 * This happens when locale changes. If you only want to resend messages from one
276 * account-folder pair, pass in the account and folder that should be resent.
277 * All other account-folder pairs will not have their notifications resent.
278 * All notifications will be resent if account or folder is null.
279 *
280 * @param context Current context.
281 * @param cancelExisting True, if all notifications should be canceled before resending.
282 * False, otherwise.
283 * @param accountUri The {@link Uri} of the {@link Account} of the notification
284 * upon which an action occurred.
285 * @param folderUri The {@link Uri} of the {@link Folder} of the notification
286 * upon which an action occurred.
287 */
288 public static void resendNotifications(Context context, final boolean cancelExisting,
289 final Uri accountUri, final Uri folderUri) {
Scott Kennedy2638b482013-05-06 16:05:11 -0700290 LogUtils.d(LOG_TAG, "NotificationUtils: resendNotifications ");
291
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800292 if (cancelExisting) {
Scott Kennedy93486c02013-04-17 12:15:43 -0700293 LogUtils.d(LOG_TAG, "NotificationUtils: resendNotifications - cancelling all");
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800294 NotificationManager nm =
295 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
296 nm.cancelAll();
297 }
298 // Re-validate the notifications.
299 final NotificationMap notificationMap = getNotificationMap(context);
300 final Set<NotificationKey> keys = notificationMap.keySet();
301 for (NotificationKey notification : keys) {
302 final Folder folder = notification.folder;
Andrew Sappersteinddd17bc2013-04-22 14:03:40 -0700303 final int notificationId = getNotificationId(notification.account.name,
304 folder);
305
306 // Only resend notifications if the notifications are from the same folder
307 // and same account as the undo notification that was previously displayed.
Scott Kennedy26b29ef2013-04-28 18:36:50 -0700308 if (accountUri != null && !Objects.equal(accountUri, notification.account.uri) &&
309 folderUri != null && !Objects.equal(folderUri, folder.uri)) {
Scott Kennedy2638b482013-05-06 16:05:11 -0700310 LogUtils.d(LOG_TAG, "NotificationUtils: resendNotifications - not resending %s / %s"
311 + " because it doesn't match %s / %s",
312 notification.account.uri, folder.uri, accountUri, folderUri);
Andrew Sappersteinddd17bc2013-04-22 14:03:40 -0700313 continue;
314 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800315
Scott Kennedy2638b482013-05-06 16:05:11 -0700316 LogUtils.d(LOG_TAG, "NotificationUtils: resendNotifications - resending %s / %s",
317 notification.account.uri, folder.uri);
318
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800319 final NotificationAction undoableAction =
320 NotificationActionUtils.sUndoNotifications.get(notificationId);
321 if (undoableAction == null) {
Andrew Sappersteinddd17bc2013-04-22 14:03:40 -0700322 validateNotifications(context, folder, notification.account, true,
323 false, notification);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800324 } else {
325 // Create an undo notification
326 NotificationActionUtils.createUndoNotification(context, undoableAction);
327 }
328 }
329 }
330
331 /**
332 * Validate the notifications for the specified account.
333 */
334 public static void validateAccountNotifications(Context context, String account) {
Scott Kennedy4162f832013-05-06 17:37:55 -0700335 LogUtils.d(LOG_TAG, "NotificationUtils: validateAccountNotifications - %s", account);
336
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800337 List<NotificationKey> notificationsToCancel = Lists.newArrayList();
338 // Iterate through the notification map to see if there are any entries that correspond to
339 // labels that are not in the sync set.
340 final NotificationMap notificationMap = getNotificationMap(context);
341 Set<NotificationKey> keys = notificationMap.keySet();
342 final AccountPreferences accountPreferences = new AccountPreferences(context, account);
343 final boolean enabled = accountPreferences.areNotificationsEnabled();
344 if (!enabled) {
345 // Cancel all notifications for this account
346 for (NotificationKey notification : keys) {
347 if (notification.account.name.equals(account)) {
348 notificationsToCancel.add(notification);
349 }
350 }
351 } else {
352 // Iterate through the notification map to see if there are any entries that
353 // correspond to labels that are not in the notification set.
354 for (NotificationKey notification : keys) {
355 if (notification.account.name.equals(account)) {
356 // If notification is not enabled for this label, remember this NotificationKey
357 // to later cancel the notification, and remove the entry from the map
358 final Folder folder = notification.folder;
359 final boolean isInbox =
360 notification.account.settings.defaultInbox.equals(folder.uri);
361 final FolderPreferences folderPreferences = new FolderPreferences(
362 context, notification.account.name, folder, isInbox);
363
364 if (!folderPreferences.areNotificationsEnabled()) {
365 notificationsToCancel.add(notification);
366 }
367 }
368 }
369 }
370
371 // Cancel & remove the invalid notifications.
372 if (notificationsToCancel.size() > 0) {
373 NotificationManager nm = (NotificationManager) context.getSystemService(
374 Context.NOTIFICATION_SERVICE);
375 for (NotificationKey notification : notificationsToCancel) {
376 final Folder folder = notification.folder;
377 final int notificationId = getNotificationId(notification.account.name, folder);
Scott Kennedy93486c02013-04-17 12:15:43 -0700378 LogUtils.d(LOG_TAG,
379 "NotificationUtils: validateAccountNotifications - cancelling %s / %s",
380 notification.account.name, folder.persistentId);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800381 nm.cancel(notificationId);
382 notificationMap.remove(notification);
383 NotificationActionUtils.sUndoNotifications.remove(notificationId);
384 NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
385 }
386 notificationMap.saveNotificationMap(context);
387 }
388 }
389
390 /**
391 * Display only one notification.
392 */
393 public static void setNewEmailIndicator(Context context, final int unreadCount,
394 final int unseenCount, final Account account, final Folder folder,
395 final boolean getAttention) {
Scott Kennedy4162f832013-05-06 17:37:55 -0700396 LogUtils.d(LOG_TAG, "NotificationUtils: setNewEmailIndicator unreadCount = %d, "
397 + "unseenCount = %d, account = %s, folder = %s, getAttention = %b", unreadCount,
398 unseenCount, account.name, folder.uri, getAttention);
399
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800400 boolean ignoreUnobtrusiveSetting = false;
401
Scott Kennedydab8a942013-02-22 12:31:30 -0800402 final int notificationId = getNotificationId(account.name, folder);
403
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800404 // Update the notification map
405 final NotificationMap notificationMap = getNotificationMap(context);
406 final NotificationKey key = new NotificationKey(account, folder);
407 if (unreadCount == 0) {
Scott Kennedy93486c02013-04-17 12:15:43 -0700408 LogUtils.d(LOG_TAG,
409 "NotificationUtils: setNewEmailIndicator - cancelling %s / %s",
410 account.name, folder.persistentId);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800411 notificationMap.remove(key);
Scott Kennedydab8a942013-02-22 12:31:30 -0800412 ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE))
413 .cancel(notificationId);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800414 } else {
415 if (!notificationMap.containsKey(key)) {
416 // This account previously didn't have any unread mail; ignore the "unobtrusive
417 // notifications" setting and play sound and/or vibrate the device even if a
418 // notification already exists (bug 2412348).
419 ignoreUnobtrusiveSetting = true;
420 }
421 notificationMap.put(key, unreadCount, unseenCount);
422 }
423 notificationMap.saveNotificationMap(context);
424
425 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700426 LogUtils.v(LOG_TAG, "NotificationUtils: New email: %s mapSize: %d getAttention: %b",
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800427 createNotificationString(notificationMap), notificationMap.size(),
428 getAttention);
429 }
430
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800431 if (NotificationActionUtils.sUndoNotifications.get(notificationId) == null) {
432 validateNotifications(context, folder, account, getAttention, ignoreUnobtrusiveSetting,
433 key);
434 }
435 }
436
437 /**
438 * Validate the notifications notification.
439 */
440 private static void validateNotifications(Context context, final Folder folder,
441 final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting,
442 NotificationKey key) {
443
444 NotificationManager nm = (NotificationManager)
445 context.getSystemService(Context.NOTIFICATION_SERVICE);
446
447 final NotificationMap notificationMap = getNotificationMap(context);
448 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700449 LogUtils.v(LOG_TAG, "NotificationUtils: Validating Notification: %s mapSize: %d "
450 + "folder: %s getAttention: %b", createNotificationString(notificationMap),
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800451 notificationMap.size(), folder.name, getAttention);
452 }
453 // The number of unread messages for this account and label.
454 final Integer unread = notificationMap.getUnread(key);
455 final int unreadCount = unread != null ? unread.intValue() : 0;
456 final Integer unseen = notificationMap.getUnseen(key);
457 int unseenCount = unseen != null ? unseen.intValue() : 0;
458
459 Cursor cursor = null;
460
461 try {
462 final Uri.Builder uriBuilder = folder.conversationListUri.buildUpon();
463 uriBuilder.appendQueryParameter(
464 UIProvider.SEEN_QUERY_PARAMETER, Boolean.FALSE.toString());
Andy Huang0be92432013-03-22 18:31:44 -0700465 // Do not allow this quick check to disrupt any active network-enabled conversation
466 // cursor.
467 uriBuilder.appendQueryParameter(
468 UIProvider.ConversationListQueryParameters.USE_NETWORK,
469 Boolean.FALSE.toString());
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800470 cursor = context.getContentResolver().query(uriBuilder.build(),
471 UIProvider.CONVERSATION_PROJECTION, null, null, null);
472 final int cursorUnseenCount = cursor.getCount();
473
474 // Make sure the unseen count matches the number of items in the cursor. But, we don't
475 // want to overwrite a 0 unseen count that was specified in the intent
476 if (unseenCount != 0 && unseenCount != cursorUnseenCount) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700477 LogUtils.d(LOG_TAG, "NotificationUtils: "
478 + "Unseen count doesn't match cursor count. unseen: %d cursor count: %d",
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800479 unseenCount, cursorUnseenCount);
480 unseenCount = cursorUnseenCount;
481 }
482
483 // For the purpose of the notifications, the unseen count should be capped at the num of
484 // unread conversations.
485 if (unseenCount > unreadCount) {
486 unseenCount = unreadCount;
487 }
488
489 final int notificationId = getNotificationId(account.name, folder);
490
491 if (unseenCount == 0) {
Scott Kennedy93486c02013-04-17 12:15:43 -0700492 LogUtils.d(LOG_TAG,
493 "NotificationUtils: validateNotifications - cancelling %s / %s",
494 account.name, folder.persistentId);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800495 nm.cancel(notificationId);
496 return;
497 }
498
499 // We now have all we need to create the notification and the pending intent
500 PendingIntent clickIntent;
501
502 NotificationCompat.Builder notification = new NotificationCompat.Builder(context);
503 notification.setSmallIcon(R.drawable.stat_notify_email);
504 notification.setTicker(account.name);
505
506 final long when;
507
508 final long oldWhen =
509 NotificationActionUtils.sNotificationTimestamps.get(notificationId);
510 if (oldWhen != 0) {
511 when = oldWhen;
512 } else {
513 when = System.currentTimeMillis();
514 }
515
516 notification.setWhen(when);
517
518 // The timestamp is now stored in the notification, so we can remove it from here
519 NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
520
521 // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a
522 // notification. Also this intent gets fired when the user taps on a notification as
523 // the AutoCancel flag has been set
524 final Intent cancelNotificationIntent =
525 new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
526 cancelNotificationIntent.setPackage(context.getPackageName());
Scott Kennedy09400ef2013-03-07 11:09:47 -0800527 cancelNotificationIntent.setData(Utils.appendVersionQueryParameter(context,
528 folder.uri));
Scott Kennedy48cfe462013-04-10 11:32:02 -0700529 cancelNotificationIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
530 cancelNotificationIntent.putExtra(Utils.EXTRA_FOLDER, folder);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800531
532 notification.setDeleteIntent(PendingIntent.getService(
533 context, notificationId, cancelNotificationIntent, 0));
534
535 // Ensure that the notification is cleared when the user selects it
536 notification.setAutoCancel(true);
537
538 boolean eventInfoConfigured = false;
539
540 final boolean isInbox = account.settings.defaultInbox.equals(folder.uri);
541 final FolderPreferences folderPreferences =
542 new FolderPreferences(context, account.name, folder, isInbox);
543
544 if (isInbox) {
545 final AccountPreferences accountPreferences =
546 new AccountPreferences(context, account.name);
547 moveNotificationSetting(accountPreferences, folderPreferences);
548 }
549
550 if (!folderPreferences.areNotificationsEnabled()) {
551 // Don't notify
552 return;
553 }
554
555 if (unreadCount > 0) {
556 // How can I order this properly?
557 if (cursor.moveToNext()) {
Scott Kennedy09400ef2013-03-07 11:09:47 -0800558 Intent notificationIntent = createViewConversationIntent(context, account,
559 folder, null);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800560
561 // Launch directly to the conversation, if the
562 // number of unseen conversations == 1
563 if (unseenCount == 1) {
Scott Kennedy09400ef2013-03-07 11:09:47 -0800564 notificationIntent = createViewConversationIntent(context, account, folder,
565 cursor);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800566 }
567
568 if (notificationIntent == null) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700569 LogUtils.e(LOG_TAG, "NotificationUtils: "
570 + "Null intent when building notification");
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800571 return;
572 }
573
574 clickIntent = PendingIntent.getActivity(context, -1, notificationIntent, 0);
575 configureLatestEventInfoFromConversation(context, account, folderPreferences,
576 notification, cursor, clickIntent, notificationIntent,
577 account.name, unreadCount, unseenCount, folder, when);
578 eventInfoConfigured = true;
579 }
580 }
581
582 final boolean vibrate = folderPreferences.isNotificationVibrateEnabled();
583 final String ringtoneUri = folderPreferences.getNotificationRingtoneUri();
584 final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled();
585
Scott Kennedyff8553f2013-04-05 20:57:44 -0700586 if (!ignoreUnobtrusiveSetting && notifyOnce) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800587 // If the user has "unobtrusive notifications" enabled, only alert the first time
588 // new mail is received in this account. This is the default behavior. See
589 // bugs 2412348 and 2413490.
590 notification.setOnlyAlertOnce(true);
591 }
592
Scott Kennedy13a73272013-05-09 09:45:03 -0700593 LogUtils.d(LOG_TAG, "NotificationUtils: Account: %s vibrate: %s", account.name,
Scott Kennedyff8553f2013-04-05 20:57:44 -0700594 Boolean.toString(folderPreferences.isNotificationVibrateEnabled()));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800595
596 int defaults = 0;
597
598 /*
599 * We do not want to notify if this is coming back from an Undo notification, hence the
600 * oldWhen check.
601 */
Scott Kennedyff8553f2013-04-05 20:57:44 -0700602 if (getAttention && oldWhen == 0) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800603 final AccountPreferences accountPreferences =
604 new AccountPreferences(context, account.name);
605 if (accountPreferences.areNotificationsEnabled()) {
606 if (vibrate) {
607 defaults |= Notification.DEFAULT_VIBRATE;
608 }
609
610 notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null
611 : Uri.parse(ringtoneUri));
Scott Kennedy13a73272013-05-09 09:45:03 -0700612 LogUtils.d(LOG_TAG, "NotificationUtils: "
613 + "New email in %s vibrateWhen: %s, playing notification: %s",
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800614 account.name, vibrate, ringtoneUri);
615 }
616 }
617
618 if (eventInfoConfigured) {
619 defaults |= Notification.DEFAULT_LIGHTS;
620 notification.setDefaults(defaults);
621
622 if (oldWhen != 0) {
623 // We do not want to display the ticker again if we are re-displaying this
624 // notification (like from an Undo notification)
625 notification.setTicker(null);
626 }
627
628 nm.notify(notificationId, notification.build());
629 }
630 } finally {
631 if (cursor != null) {
632 cursor.close();
633 }
634 }
635 }
636
637 /**
638 * @return an {@link Intent} which, if launched, will display the corresponding conversation
639 */
Scott Kennedy09400ef2013-03-07 11:09:47 -0800640 private static Intent createViewConversationIntent(final Context context, final Account account,
641 final Folder folder, final Cursor cursor) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800642 if (folder == null || account == null) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700643 LogUtils.e(LOG_TAG, "NotificationUtils#createViewConversationIntent(): "
644 + "Null account or folder. account: %s folder: %s", account, folder);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800645 return null;
646 }
647
648 final Intent intent;
649
650 if (cursor == null) {
Scott Kennedyb39aaf52013-03-06 19:17:22 -0800651 intent = Utils.createViewFolderIntent(context, folder.uri, account);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800652 } else {
653 // A conversation cursor has been specified, so this intent is intended to be go
654 // directly to the one new conversation
655
656 // Get the Conversation object
657 final Conversation conversation = new Conversation(cursor);
Scott Kennedyb39aaf52013-03-06 19:17:22 -0800658 intent = Utils.createViewConversationIntent(context, conversation, folder.uri,
659 account);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800660 }
661
662 return intent;
663 }
664
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800665 private static Bitmap getDefaultNotificationIcon(
666 final Context context, final Folder folder, final boolean multipleNew) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800667 final Bitmap icon;
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800668 if (folder.notificationIconResId != 0) {
669 icon = getIcon(context, folder.notificationIconResId);
670 } else if (multipleNew) {
671 icon = getIcon(context, R.drawable.ic_notification_multiple_mail_holo_dark);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800672 } else {
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800673 icon = getIcon(context, R.drawable.ic_contact_picture);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800674 }
675 return icon;
676 }
677
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800678 private static Bitmap getIcon(final Context context, final int resId) {
679 final Bitmap cachedIcon = sNotificationIcons.get(resId);
680 if (cachedIcon != null) {
681 return cachedIcon;
682 }
683
684 final Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resId);
685 sNotificationIcons.put(resId, icon);
686
687 return icon;
688 }
689
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800690 private static void configureLatestEventInfoFromConversation(final Context context,
691 final Account account, final FolderPreferences folderPreferences,
692 final NotificationCompat.Builder notification, final Cursor conversationCursor,
693 final PendingIntent clickIntent, final Intent notificationIntent,
694 final String notificationAccount, final int unreadCount, final int unseenCount,
695 final Folder folder, final long when) {
696 final Resources res = context.getResources();
697
Scott Kennedy13a73272013-05-09 09:45:03 -0700698 LogUtils.w(LOG_TAG, "NotificationUtils: Showing notification with unreadCount of %d and "
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800699 + "unseenCount of %d", unreadCount, unseenCount);
700
701 String notificationTicker = null;
702
703 // Boolean indicating that this notification is for a non-inbox label.
704 final boolean isInbox = account.settings.defaultInbox.equals(folder.uri);
705
706 // Notification label name for user label notifications.
707 final String notificationLabelName = isInbox ? null : folder.name;
708
709 if (unseenCount > 1) {
710 // Build the string that describes the number of new messages
711 final String newMessagesString = res.getString(R.string.new_messages, unseenCount);
712
713 // Use the default notification icon
714 notification.setLargeIcon(
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800715 getDefaultNotificationIcon(context, folder, true /* multiple new messages */));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800716
717 // The ticker initially start as the new messages string.
718 notificationTicker = newMessagesString;
719
720 // The title of the notification is the new messages string
721 notification.setContentTitle(newMessagesString);
722
Scott Kennedyc8bc5d12013-04-25 08:46:06 -0700723 // TODO(skennedy) Can we remove this check?
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800724 if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) {
725 // For a new-style notification
726 final int maxNumDigestItems = context.getResources().getInteger(
727 R.integer.max_num_notification_digest_items);
728
729 // The body of the notification is the account name, or the label name.
730 notification.setSubText(isInbox ? notificationAccount : notificationLabelName);
731
732 final NotificationCompat.InboxStyle digest =
733 new NotificationCompat.InboxStyle(notification);
734
Scott Kennedyc8bc5d12013-04-25 08:46:06 -0700735 // TODO(skennedy) I do not believe this line is necessary
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800736 digest.setBigContentTitle(newMessagesString);
737
738 int numDigestItems = 0;
739 do {
740 final Conversation conversation = new Conversation(conversationCursor);
741
742 if (!conversation.read) {
743 boolean multipleUnreadThread = false;
744 // TODO(cwren) extract this pattern into a helper
745
746 Cursor cursor = null;
747 MessageCursor messageCursor = null;
748 try {
749 final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon();
750 uriBuilder.appendQueryParameter(
751 UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName);
752 cursor = context.getContentResolver().query(uriBuilder.build(),
753 UIProvider.MESSAGE_PROJECTION, null, null, null);
754 messageCursor = new MessageCursor(cursor);
755
Yu Ping Hub18d75a2013-03-12 17:04:58 -0700756 String from = "";
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800757 String fromAddress = "";
758 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
759 final Message message = messageCursor.getMessage();
760 fromAddress = message.getFrom();
Yu Ping Hub18d75a2013-03-12 17:04:58 -0700761 if (fromAddress == null) {
762 fromAddress = "";
763 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800764 from = getDisplayableSender(fromAddress);
765 }
766 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
767 final Message message = messageCursor.getMessage();
768 if (!message.read &&
769 !fromAddress.contentEquals(message.getFrom())) {
770 multipleUnreadThread = true;
771 break;
772 }
773 }
774 final SpannableStringBuilder sendersBuilder;
775 if (multipleUnreadThread) {
776 final int sendersLength =
777 res.getInteger(R.integer.swipe_senders_length);
778
779 sendersBuilder = getStyledSenders(context, conversationCursor,
780 sendersLength, notificationAccount);
781 } else {
Paul Westbrook3507af92013-03-30 17:06:12 -0700782 if (from == null) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700783 LogUtils.e(LOG_TAG, "NotificationUtils: null from string in " +
Paul Westbrook3507af92013-03-30 17:06:12 -0700784 "configureLatestEventInfoFromConversation");
785 from = "";
786 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800787 sendersBuilder = new SpannableStringBuilder(from);
788 }
789 final CharSequence digestLine = getSingleMessageInboxLine(context,
790 sendersBuilder.toString(),
791 conversation.subject,
792 conversation.snippet);
793 digest.addLine(digestLine);
794 numDigestItems++;
795 } finally {
796 if (messageCursor != null) {
797 messageCursor.close();
798 }
799 if (cursor != null) {
800 cursor.close();
801 }
802 }
803 }
804 } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
805 } else {
806 // The body of the notification is the account name, or the label name.
807 notification.setContentText(
808 isInbox ? notificationAccount : notificationLabelName);
809 }
810 } else {
811 // For notifications for a single new conversation, we want to get the information from
812 // the conversation
813
814 // Move the cursor to the most recent unread conversation
815 seekToLatestUnreadConversation(conversationCursor);
816
817 final Conversation conversation = new Conversation(conversationCursor);
818
819 Cursor cursor = null;
820 MessageCursor messageCursor = null;
821 boolean multipleUnseenThread = false;
822 String from = null;
823 try {
Scott Kennedyffbf86f2013-03-08 11:48:15 -0800824 final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
825 UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
826 cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
827 null, null, null);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800828 messageCursor = new MessageCursor(cursor);
829 // Use the information from the last sender in the conversation that triggered
830 // this notification.
831
832 String fromAddress = "";
833 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
834 final Message message = messageCursor.getMessage();
835 fromAddress = message.getFrom();
836 from = getDisplayableSender(fromAddress);
837 notification.setLargeIcon(
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800838 getContactIcon(context, getSenderAddress(fromAddress), folder));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800839 }
840
841 // Assume that the last message in this conversation is unread
842 int firstUnseenMessagePos = messageCursor.getPosition();
843 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
844 final Message message = messageCursor.getMessage();
845 final boolean unseen = !message.seen;
846 if (unseen) {
847 firstUnseenMessagePos = messageCursor.getPosition();
848 if (!multipleUnseenThread
849 && !fromAddress.contentEquals(message.getFrom())) {
850 multipleUnseenThread = true;
851 }
852 }
853 }
854
Scott Kennedyc8bc5d12013-04-25 08:46:06 -0700855 // TODO(skennedy) Can we remove this check?
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800856 if (Utils.isRunningJellybeanOrLater()) {
857 // For a new-style notification
858
859 if (multipleUnseenThread) {
860 // The title of a single conversation is the list of senders.
861 int sendersLength = res.getInteger(R.integer.swipe_senders_length);
862
863 final SpannableStringBuilder sendersBuilder = getStyledSenders(
864 context, conversationCursor, sendersLength, notificationAccount);
865
866 notification.setContentTitle(sendersBuilder);
867 // For a single new conversation, the ticker is based on the sender's name.
868 notificationTicker = sendersBuilder.toString();
869 } else {
870 // The title of a single message the sender.
871 notification.setContentTitle(from);
872 // For a single new conversation, the ticker is based on the sender's name.
873 notificationTicker = from;
874 }
875
876 // The notification content will be the subject of the conversation.
877 notification.setContentText(
878 getSingleMessageLittleText(context, conversation.subject));
879
880 // The notification subtext will be the subject of the conversation for inbox
881 // notifications, or will based on the the label name for user label
882 // notifications.
883 notification.setSubText(isInbox ? notificationAccount : notificationLabelName);
884
885 if (multipleUnseenThread) {
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800886 notification.setLargeIcon(
887 getDefaultNotificationIcon(context, folder, true));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800888 }
889 final NotificationCompat.BigTextStyle bigText =
890 new NotificationCompat.BigTextStyle(notification);
891
892 // Seek the message cursor to the first unread message
Paul Westbrook92b21742013-03-11 17:36:40 -0700893 final Message message;
894 if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
895 message = messageCursor.getMessage();
896 bigText.bigText(getSingleMessageBigText(context,
897 conversation.subject, message));
898 } else {
Scott Kennedy13a73272013-05-09 09:45:03 -0700899 LogUtils.e(LOG_TAG, "NotificationUtils: Failed to load message");
Paul Westbrook92b21742013-03-11 17:36:40 -0700900 message = null;
901 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800902
Paul Westbrook92b21742013-03-11 17:36:40 -0700903 if (message != null) {
904 final Set<String> notificationActions =
Scott Kennedycde6eb02013-03-21 18:49:32 -0700905 folderPreferences.getNotificationActions(account);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800906
Paul Westbrook92b21742013-03-11 17:36:40 -0700907 final int notificationId = getNotificationId(notificationAccount, folder);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800908
Paul Westbrook92b21742013-03-11 17:36:40 -0700909 NotificationActionUtils.addNotificationActions(context, notificationIntent,
910 notification, account, conversation, message, folder,
911 notificationId, when, notificationActions);
912 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800913 } else {
914 // For an old-style notification
915
916 // The title of a single conversation notification is built from both the sender
917 // and subject of the new message.
918 notification.setContentTitle(getSingleMessageNotificationTitle(context,
919 from, conversation.subject));
920
921 // The notification content will be the subject of the conversation for inbox
922 // notifications, or will based on the the label name for user label
923 // notifications.
924 notification.setContentText(
925 isInbox ? notificationAccount : notificationLabelName);
926
927 // For a single new conversation, the ticker is based on the sender's name.
928 notificationTicker = from;
929 }
930 } finally {
931 if (messageCursor != null) {
932 messageCursor.close();
933 }
934 if (cursor != null) {
935 cursor.close();
936 }
937 }
938 }
939
940 // Build the notification ticker
941 if (notificationLabelName != null && notificationTicker != null) {
942 // This is a per label notification, format the ticker with that information
943 notificationTicker = res.getString(R.string.label_notification_ticker,
944 notificationLabelName, notificationTicker);
945 }
946
947 if (notificationTicker != null) {
948 // If we didn't generate a notification ticker, it will default to account name
949 notification.setTicker(notificationTicker);
950 }
951
952 // Set the number in the notification
953 if (unreadCount > 1) {
954 notification.setNumber(unreadCount);
955 }
956
957 notification.setContentIntent(clickIntent);
958 }
959
960 private static SpannableStringBuilder getStyledSenders(final Context context,
961 final Cursor conversationCursor, final int maxLength, final String account) {
962 final Conversation conversation = new Conversation(conversationCursor);
963 final com.android.mail.providers.ConversationInfo conversationInfo =
964 conversation.conversationInfo;
965 final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
966 if (sNotificationUnreadStyleSpan == null) {
967 sNotificationUnreadStyleSpan = new TextAppearanceSpan(
968 context, R.style.NotificationSendersUnreadTextAppearance);
969 sNotificationReadStyleSpan =
970 new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
971 }
972 SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
973 sNotificationUnreadStyleSpan, sNotificationReadStyleSpan, false);
974
975 return ellipsizeStyledSenders(context, senders);
976 }
977
978 private static String sSendersSplitToken = null;
979 private static String sElidedPaddingToken = null;
980
981 private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
982 ArrayList<SpannableString> styledSenders) {
983 if (sSendersSplitToken == null) {
984 sSendersSplitToken = context.getString(R.string.senders_split_token);
985 sElidedPaddingToken = context.getString(R.string.elided_padding_token);
986 }
987
988 SpannableStringBuilder builder = new SpannableStringBuilder();
989 SpannableString prevSender = null;
990 for (SpannableString sender : styledSenders) {
991 if (sender == null) {
Scott Kennedy13a73272013-05-09 09:45:03 -0700992 LogUtils.e(LOG_TAG, "NotificationUtils: null sender iterating over styledSenders");
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800993 continue;
994 }
995 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
996 if (SendersView.sElidedString.equals(sender.toString())) {
997 prevSender = sender;
998 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
999 } else if (builder.length() > 0
1000 && (prevSender == null || !SendersView.sElidedString.equals(prevSender
1001 .toString()))) {
1002 prevSender = sender;
1003 sender = copyStyles(spans, sSendersSplitToken + sender);
1004 } else {
1005 prevSender = sender;
1006 }
1007 builder.append(sender);
1008 }
1009 return builder;
1010 }
1011
1012 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
1013 SpannableString s = new SpannableString(newText);
1014 if (spans != null && spans.length > 0) {
1015 s.setSpan(spans[0], 0, s.length(), 0);
1016 }
1017 return s;
1018 }
1019
1020 /**
1021 * Seeks the cursor to the position of the most recent unread conversation. If no unread
1022 * conversation is found, the position of the cursor will be restored, and false will be
1023 * returned.
1024 */
1025 private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
1026 final int initialPosition = cursor.getPosition();
1027 do {
1028 final Conversation conversation = new Conversation(cursor);
1029 if (!conversation.read) {
1030 return true;
1031 }
1032 } while (cursor.moveToNext());
1033
1034 // Didn't find an unread conversation, reset the position.
1035 cursor.moveToPosition(initialPosition);
1036 return false;
1037 }
1038
1039 /**
1040 * Sets the bigtext for a notification for a single new conversation
1041 *
1042 * @param context
1043 * @param senders Sender of the new message that triggered the notification.
1044 * @param subject Subject of the new message that triggered the notification
1045 * @param snippet Snippet of the new message that triggered the notification
1046 * @return a {@link CharSequence} suitable for use in
1047 * {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1048 */
1049 private static CharSequence getSingleMessageInboxLine(Context context,
1050 String senders, String subject, String snippet) {
1051 // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1052
1053 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1054
1055 final TextAppearanceSpan notificationPrimarySpan =
1056 new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1057
1058 if (TextUtils.isEmpty(senders)) {
1059 // If the senders are empty, just use the subject/snippet.
1060 return subjectSnippet;
1061 } else if (TextUtils.isEmpty(subjectSnippet)) {
1062 // If the subject/snippet is empty, just use the senders.
1063 final SpannableString spannableString = new SpannableString(senders);
1064 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1065
1066 return spannableString;
1067 } else {
1068 final String formatString = context.getResources().getString(
1069 R.string.multiple_new_message_notification_item);
1070 final TextAppearanceSpan notificationSecondarySpan =
1071 new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1072
1073 final String instantiatedString = String.format(formatString, senders, subjectSnippet);
1074
1075 final SpannableString spannableString = new SpannableString(instantiatedString);
1076
1077 final boolean isOrderReversed = formatString.indexOf("%2$s") <
1078 formatString.indexOf("%1$s");
1079 final int primaryOffset =
1080 (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1081 instantiatedString.indexOf(senders));
1082 final int secondaryOffset =
1083 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1084 instantiatedString.indexOf(subjectSnippet));
1085 spannableString.setSpan(notificationPrimarySpan,
1086 primaryOffset, primaryOffset + senders.length(), 0);
1087 spannableString.setSpan(notificationSecondarySpan,
1088 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1089 return spannableString;
1090 }
1091 }
1092
1093 /**
1094 * Sets the bigtext for a notification for a single new conversation
1095 * @param context
1096 * @param subject Subject of the new message that triggered the notification
1097 * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText}
1098 */
1099 private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1100 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1101 context, R.style.NotificationPrimaryText);
1102
1103 final SpannableString spannableString = new SpannableString(subject);
1104 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1105
1106 return spannableString;
1107 }
1108
1109 /**
1110 * Sets the bigtext for a notification for a single new conversation
1111 *
1112 * @param context
1113 * @param subject Subject of the new message that triggered the notification
1114 * @param message the {@link Message} to be displayed.
1115 * @return a {@link CharSequence} suitable for use in
1116 * {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1117 */
1118 private static CharSequence getSingleMessageBigText(Context context, String subject,
1119 final Message message) {
1120
1121 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1122 context, R.style.NotificationPrimaryText);
1123
1124 final String snippet = getMessageBodyWithoutElidedText(message);
1125
1126 // Change multiple newlines (with potential white space between), into a single new line
1127 final String collapsedSnippet =
1128 !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1129
1130 if (TextUtils.isEmpty(subject)) {
1131 // If the subject is empty, just use the snippet.
1132 return snippet;
1133 } else if (TextUtils.isEmpty(collapsedSnippet)) {
1134 // If the snippet is empty, just use the subject.
1135 final SpannableString spannableString = new SpannableString(subject);
1136 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1137
1138 return spannableString;
1139 } else {
1140 final String notificationBigTextFormat = context.getResources().getString(
1141 R.string.single_new_message_notification_big_text);
1142
1143 // Localizers may change the order of the parameters, look at how the format
1144 // string is structured.
1145 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1146 notificationBigTextFormat.indexOf("%1$s");
1147 final String bigText =
1148 String.format(notificationBigTextFormat, subject, collapsedSnippet);
1149 final SpannableString spannableString = new SpannableString(bigText);
1150
1151 final int subjectOffset =
1152 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1153 spannableString.setSpan(notificationSubjectSpan,
1154 subjectOffset, subjectOffset + subject.length(), 0);
1155
1156 return spannableString;
1157 }
1158 }
1159
1160 /**
1161 * Gets the title for a notification for a single new conversation
1162 * @param context
1163 * @param sender Sender of the new message that triggered the notification.
1164 * @param subject Subject of the new message that triggered the notification
1165 * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1166 */
1167 private static CharSequence getSingleMessageNotificationTitle(Context context,
1168 String sender, String subject) {
1169
1170 if (TextUtils.isEmpty(subject)) {
1171 // If the subject is empty, just set the title to the sender's information.
1172 return sender;
1173 } else {
1174 final String notificationTitleFormat = context.getResources().getString(
1175 R.string.single_new_message_notification_title);
1176
1177 // Localizers may change the order of the parameters, look at how the format
1178 // string is structured.
1179 final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1180 notificationTitleFormat.indexOf("%1$s");
1181 final String titleString = String.format(notificationTitleFormat, sender, subject);
1182
1183 // Format the string so the subject is using the secondaryText style
1184 final SpannableString titleSpannable = new SpannableString(titleString);
1185
1186 // Find the offset of the subject.
1187 final int subjectOffset =
1188 isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1189 final TextAppearanceSpan notificationSubjectSpan =
1190 new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1191 titleSpannable.setSpan(notificationSubjectSpan,
1192 subjectOffset, subjectOffset + subject.length(), 0);
1193 return titleSpannable;
1194 }
1195 }
1196
1197 /**
Scott Kennedy7f8aed62013-05-22 18:35:17 -07001198 * Clears the notifications for the specified account/folder.
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001199 */
Scott Kennedy7f8aed62013-05-22 18:35:17 -07001200 public static void clearFolderNotification(Context context, Account account, Folder folder,
1201 final boolean markSeen) {
Scott Kennedy13a73272013-05-09 09:45:03 -07001202 LogUtils.v(LOG_TAG, "NotificationUtils: Clearing all notifications for %s/%s", account.name,
1203 folder.name);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001204 final NotificationMap notificationMap = getNotificationMap(context);
1205 final NotificationKey key = new NotificationKey(account, folder);
1206 notificationMap.remove(key);
1207 notificationMap.saveNotificationMap(context);
1208
Scott Kennedy7f8aed62013-05-22 18:35:17 -07001209 final NotificationManager notificationManager =
1210 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
1211 notificationManager.cancel(getNotificationId(account.name, folder));
1212
1213 if (markSeen) {
1214 markSeen(context, folder);
1215 }
1216 }
1217
1218 /**
1219 * Clears all notifications for the specified account.
1220 */
1221 public static void clearAccountNotifications(final Context context, final String account) {
1222 LogUtils.v(LOG_TAG, "NotificationUtils: Clearing all notifications for %s", account);
1223 final NotificationMap notificationMap = getNotificationMap(context);
1224
1225 // Find all NotificationKeys for this account
1226 final ImmutableList.Builder<NotificationKey> keyBuilder = ImmutableList.builder();
1227
1228 for (final NotificationKey key : notificationMap.keySet()) {
1229 if (account.equals(key.account.name)) {
1230 keyBuilder.add(key);
1231 }
1232 }
1233
1234 final List<NotificationKey> notificationKeys = keyBuilder.build();
1235
1236 final NotificationManager notificationManager =
1237 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
1238
1239 for (final NotificationKey notificationKey : notificationKeys) {
1240 final Folder folder = notificationKey.folder;
1241 notificationManager.cancel(getNotificationId(account, folder));
1242 notificationMap.remove(notificationKey);
1243 }
1244
1245 notificationMap.saveNotificationMap(context);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001246 }
1247
1248 private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1249 ArrayList<String> whereArgs = new ArrayList<String>();
1250 StringBuilder whereBuilder = new StringBuilder();
1251 String[] questionMarks = new String[addresses.size()];
1252
1253 whereArgs.addAll(addresses);
1254 Arrays.fill(questionMarks, "?");
1255 whereBuilder.append(Email.DATA1 + " IN (").
1256 append(TextUtils.join(",", questionMarks)).
1257 append(")");
1258
1259 ContentResolver resolver = context.getContentResolver();
1260 Cursor c = resolver.query(Email.CONTENT_URI,
1261 new String[]{Email.CONTACT_ID}, whereBuilder.toString(),
1262 whereArgs.toArray(new String[0]), null);
1263
1264 ArrayList<Long> contactIds = new ArrayList<Long>();
1265 if (c == null) {
1266 return contactIds;
1267 }
1268 try {
1269 while (c.moveToNext()) {
1270 contactIds.add(c.getLong(0));
1271 }
1272 } finally {
1273 c.close();
1274 }
1275 return contactIds;
1276 }
1277
Scott Kennedy61bd0e82012-12-10 18:18:17 -08001278 private static Bitmap getContactIcon(
1279 Context context, String senderAddress, final Folder folder) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001280 if (senderAddress == null) {
1281 return null;
1282 }
1283 Bitmap icon = null;
1284 ArrayList<Long> contactIds = findContacts(
1285 context, Arrays.asList(new String[] { senderAddress }));
1286
1287 if (contactIds != null) {
1288 // Get the ideal size for this icon.
1289 final Resources res = context.getResources();
1290 final int idealIconHeight =
1291 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1292 final int idealIconWidth =
1293 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1294 for (long id : contactIds) {
1295 final Uri contactUri =
1296 ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id);
1297 final Uri photoUri = Uri.withAppendedPath(contactUri, Photo.CONTENT_DIRECTORY);
1298 final Cursor cursor = context.getContentResolver().query(
1299 photoUri, new String[] { Photo.PHOTO }, null, null, null);
1300
1301 if (cursor != null) {
1302 try {
1303 if (cursor.moveToFirst()) {
1304 byte[] data = cursor.getBlob(0);
1305 if (data != null) {
1306 icon = BitmapFactory.decodeStream(new ByteArrayInputStream(data));
1307 if (icon != null && icon.getHeight() < idealIconHeight) {
1308 // We should scale this image to fit the intended size
1309 icon = Bitmap.createScaledBitmap(
1310 icon, idealIconWidth, idealIconHeight, true);
1311 }
1312 if (icon != null) {
1313 break;
1314 }
1315 }
1316 }
1317 } finally {
1318 cursor.close();
1319 }
1320 }
1321 }
1322 }
1323 if (icon == null) {
1324 // icon should be the default gmail icon.
Scott Kennedy61bd0e82012-12-10 18:18:17 -08001325 icon = getDefaultNotificationIcon(context, folder, false /* single new message */);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001326 }
1327 return icon;
1328 }
1329
1330 private static String getMessageBodyWithoutElidedText(final Message message) {
Scott Kennedy5e7e88b2013-03-07 16:42:12 -08001331 return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001332 }
1333
1334 public static String getMessageBodyWithoutElidedText(String html) {
1335 if (TextUtils.isEmpty(html)) {
1336 return "";
1337 }
1338 // Get the html "tree" for this message body
1339 final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1340 htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY);
1341
1342 return htmlTree.getPlainText();
1343 }
1344
1345 public static void markSeen(final Context context, final Folder folder) {
1346 final Uri uri = folder.uri;
1347
1348 final ContentValues values = new ContentValues(1);
1349 values.put(UIProvider.ConversationColumns.SEEN, 1);
1350
1351 context.getContentResolver().update(uri, values, null, null);
1352 }
1353
1354 /**
1355 * Returns a displayable string representing
1356 * the message sender. It has a preference toward showing the name,
1357 * but will fall back to the address if that is all that is available.
1358 */
1359 private static String getDisplayableSender(String sender) {
1360 final EmailAddress address = EmailAddress.getEmailAddress(sender);
1361
1362 String displayableSender = address.getName();
1363 // If that fails, default to the sender address.
1364 if (TextUtils.isEmpty(displayableSender)) {
1365 displayableSender = address.getAddress();
1366 }
1367 // If we were unable to tokenize a name or address,
1368 // just use whatever was in the sender.
1369 if (TextUtils.isEmpty(displayableSender)) {
1370 displayableSender = sender;
1371 }
1372 return displayableSender;
1373 }
1374
1375 /**
1376 * Returns only the address portion of a message sender.
1377 */
1378 private static String getSenderAddress(String sender) {
1379 final EmailAddress address = EmailAddress.getEmailAddress(sender);
1380
1381 String tokenizedAddress = address.getAddress();
1382
1383 // If we were unable to tokenize a name or address,
1384 // just use whatever was in the sender.
1385 if (TextUtils.isEmpty(tokenizedAddress)) {
1386 tokenizedAddress = sender;
1387 }
1388 return tokenizedAddress;
1389 }
1390
1391 public static int getNotificationId(final String account, final Folder folder) {
1392 return 1 ^ account.hashCode() ^ folder.hashCode();
1393 }
1394
1395 private static class NotificationKey {
1396 public final Account account;
1397 public final Folder folder;
1398
1399 public NotificationKey(Account account, Folder folder) {
1400 this.account = account;
1401 this.folder = folder;
1402 }
1403
1404 @Override
1405 public boolean equals(Object other) {
1406 if (!(other instanceof NotificationKey)) {
1407 return false;
1408 }
1409 NotificationKey key = (NotificationKey) other;
1410 return account.equals(key.account) && folder.equals(key.folder);
1411 }
1412
1413 @Override
1414 public String toString() {
Scott Kennedye8a7c4c2013-05-09 11:56:50 -07001415 return account.name + " " + folder.name;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001416 }
1417
1418 @Override
1419 public int hashCode() {
1420 final int accountHashCode = account.hashCode();
1421 final int folderHashCode = folder.hashCode();
1422 return accountHashCode ^ folderHashCode;
1423 }
1424 }
1425
1426 /**
1427 * Contains the logic for converting the contents of one HtmlTree into
1428 * plaintext.
1429 */
1430 public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1431 // Strings for parsing html message bodies
1432 private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1433 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1434 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1435
1436 private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1437 new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1438
1439 private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
1440 HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
1441
Scott Kennedy01c196a2013-03-25 16:05:55 -04001442 private static final String STYLE_ELEMENT_ATTRIBUTE_CLASS_VALUE = "style";
1443
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001444 private int mEndNodeElidedTextBlock = -1;
Scott Kennedy01c196a2013-03-25 16:05:55 -04001445 /**
1446 * A stack of the end tag numbers for <style /> tags. We don't want to
1447 * include anything between these.
1448 */
1449 private Deque<Integer> mStyleNodeEnds = Lists.newLinkedList();
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001450
1451 @Override
1452 public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
1453 // If we are in the middle of an elided text block, don't add this node
1454 if (nodeNum < mEndNodeElidedTextBlock) {
1455 return;
1456 } else if (nodeNum == mEndNodeElidedTextBlock) {
1457 super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
1458 return;
1459 }
1460
1461 // If this tag starts another elided text block, we want to remember the end
1462 if (n instanceof HtmlDocument.Tag) {
1463 boolean foundElidedTextTag = false;
1464 final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
1465 final HTML.Element htmlElement = htmlTag.getElement();
1466 if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
1467 // Make sure that the class is what is expected
1468 final List<HtmlDocument.TagAttribute> attributes =
1469 htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
1470 for (HtmlDocument.TagAttribute attribute : attributes) {
1471 if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
1472 attribute.getValue())) {
1473 // Found an "elided-text" div. Remember information about this tag
1474 mEndNodeElidedTextBlock = endNum;
1475 foundElidedTextTag = true;
1476 break;
1477 }
1478 }
Scott Kennedy01c196a2013-03-25 16:05:55 -04001479 } else if (STYLE_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(htmlElement.getName())) {
1480 mStyleNodeEnds.push(endNum);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001481 }
1482
1483 if (foundElidedTextTag) {
1484 return;
1485 }
1486 }
1487
Scott Kennedy01c196a2013-03-25 16:05:55 -04001488 if (!mStyleNodeEnds.isEmpty() && mStyleNodeEnds.peek() == nodeNum) {
1489 mStyleNodeEnds.pop();
1490 }
1491
1492 if (mStyleNodeEnds.isEmpty()) {
1493 super.addNode(n, nodeNum, endNum);
1494 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001495 }
1496 }
1497
1498 /**
1499 * During account setup in Email, we may not have an inbox yet, so the notification setting had
1500 * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
1501 * {@link FolderPreferences} now.
1502 */
1503 public static void moveNotificationSetting(final AccountPreferences accountPreferences,
1504 final FolderPreferences folderPreferences) {
1505 if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
1506 // If this setting has been changed some other way, don't overwrite it
1507 if (!folderPreferences.isNotificationsEnabledSet()) {
1508 final boolean notificationsEnabled =
1509 accountPreferences.getDefaultInboxNotificationsEnabled();
1510
1511 folderPreferences.setNotificationsEnabled(notificationsEnabled);
1512 }
1513
1514 accountPreferences.clearDefaultInboxNotificationsEnabled();
1515 }
1516 }
1517}