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