blob: fd485e875824329a2ba6b95cce4a8b9e51def2be [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;
35import android.text.Html;
36import android.text.Spannable;
37import android.text.SpannableString;
38import android.text.SpannableStringBuilder;
39import android.text.Spanned;
40import android.text.TextUtils;
41import android.text.TextUtils.SimpleStringSplitter;
42import android.text.style.CharacterStyle;
43import android.text.style.TextAppearanceSpan;
44import android.util.Pair;
Scott Kennedy61bd0e82012-12-10 18:18:17 -080045import android.util.SparseArray;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080046
47import com.android.mail.EmailAddress;
48import com.android.mail.MailIntentService;
49import com.android.mail.R;
50import com.android.mail.browse.MessageCursor;
51import com.android.mail.browse.SendersView;
52import com.android.mail.preferences.AccountPreferences;
53import com.android.mail.preferences.FolderPreferences;
54import com.android.mail.preferences.MailPrefs;
55import com.android.mail.providers.Account;
56import com.android.mail.providers.Conversation;
57import com.android.mail.providers.Folder;
58import com.android.mail.providers.Message;
59import com.android.mail.providers.UIProvider;
60import com.android.mail.utils.NotificationActionUtils.NotificationAction;
Scott Kennedy09400ef2013-03-07 11:09:47 -080061import com.google.android.common.html.parser.HTML;
62import com.google.android.common.html.parser.HTML4;
63import com.google.android.common.html.parser.HtmlDocument;
64import com.google.android.common.html.parser.HtmlTree;
65import com.google.common.collect.Lists;
66import com.google.common.collect.Maps;
67import com.google.common.collect.Sets;
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080068
69import java.io.ByteArrayInputStream;
70import java.util.ArrayList;
71import java.util.Arrays;
72import java.util.Collection;
73import java.util.List;
74import java.util.Map;
75import java.util.Set;
76import java.util.concurrent.ConcurrentHashMap;
77
78public class NotificationUtils {
79 public static final String LOG_TAG = LogTag.getLogTag();
80
81 /** Contains a list of <(account, label), unread conversations> */
82 private static NotificationMap sActiveNotificationMap = null;
83
Scott Kennedy61bd0e82012-12-10 18:18:17 -080084 private static final SparseArray<Bitmap> sNotificationIcons = new SparseArray<Bitmap>();
Scott Kennedyd5edd2d2012-12-05 11:11:32 -080085
86 private static TextAppearanceSpan sNotificationUnreadStyleSpan;
87 private static CharacterStyle sNotificationReadStyleSpan;
88
89 private static final Map<Integer, Integer> sPriorityToLength = Maps.newHashMap();
90 private static final SimpleStringSplitter SENDER_LIST_SPLITTER =
91 new SimpleStringSplitter(Utils.SENDER_LIST_SEPARATOR);
92 private static String[] sSenderFragments = new String[8];
93
94 /** A factory that produces a plain text converter that removes elided text. */
95 private static final HtmlTree.PlainTextConverterFactory MESSAGE_CONVERTER_FACTORY =
96 new HtmlTree.PlainTextConverterFactory() {
97 @Override
98 public HtmlTree.PlainTextConverter createInstance() {
99 return new MailMessagePlainTextConverter();
100 }
101 };
102
103 /**
104 * Clears all notifications in response to the user tapping "Clear" in the status bar.
105 */
106 public static void clearAllNotfications(Context context) {
107 LogUtils.v(LOG_TAG, "Clearing all notifications.");
108 final NotificationMap notificationMap = getNotificationMap(context);
109 notificationMap.clear();
110 notificationMap.saveNotificationMap(context);
111 }
112
113 /**
114 * Returns the notification map, creating it if necessary.
115 */
116 private static synchronized NotificationMap getNotificationMap(Context context) {
117 if (sActiveNotificationMap == null) {
118 sActiveNotificationMap = new NotificationMap();
119
120 // populate the map from the cached data
121 sActiveNotificationMap.loadNotificationMap(context);
122 }
123 return sActiveNotificationMap;
124 }
125
126 /**
127 * Class representing the existing notifications, and the number of unread and
128 * unseen conversations that triggered each.
129 */
130 private static class NotificationMap
131 extends ConcurrentHashMap<NotificationKey, Pair<Integer, Integer>> {
132
133 private static final String NOTIFICATION_PART_SEPARATOR = " ";
134 private static final int NUM_NOTIFICATION_PARTS= 4;
135
136 /**
137 * Retuns the unread count for the given NotificationKey.
138 */
139 public Integer getUnread(NotificationKey key) {
140 final Pair<Integer, Integer> value = get(key);
141 return value != null ? value.first : null;
142 }
143
144 /**
145 * Retuns the unread unseen count for the given NotificationKey.
146 */
147 public Integer getUnseen(NotificationKey key) {
148 final Pair<Integer, Integer> value = get(key);
149 return value != null ? value.second : null;
150 }
151
152 /**
153 * Store the unread and unseen value for the given NotificationKey
154 */
155 public void put(NotificationKey key, int unread, int unseen) {
156 final Pair<Integer, Integer> value =
157 new Pair<Integer, Integer>(Integer.valueOf(unread), Integer.valueOf(unseen));
158 put(key, value);
159 }
160
161 /**
162 * Populates the notification map with previously cached data.
163 */
164 public synchronized void loadNotificationMap(final Context context) {
165 final MailPrefs mailPrefs = MailPrefs.get(context);
166 final Set<String> notificationSet = mailPrefs.getActiveNotificationSet();
167 if (notificationSet != null) {
168 for (String notificationEntry : notificationSet) {
169 // Get the parts of the string that make the notification entry
170 final String[] notificationParts =
171 TextUtils.split(notificationEntry, NOTIFICATION_PART_SEPARATOR);
172 if (notificationParts.length == NUM_NOTIFICATION_PARTS) {
173 final Uri accountUri = Uri.parse(notificationParts[0]);
174 final Cursor accountCursor = context.getContentResolver().query(
175 accountUri, UIProvider.ACCOUNTS_PROJECTION, null, null, null);
176 final Account account;
177 try {
178 if (accountCursor.moveToFirst()) {
179 account = new Account(accountCursor);
180 } else {
181 continue;
182 }
183 } finally {
184 accountCursor.close();
185 }
186
187 final Uri folderUri = Uri.parse(notificationParts[1]);
188 final Cursor folderCursor = context.getContentResolver().query(
189 folderUri, UIProvider.FOLDERS_PROJECTION, null, null, null);
190 final Folder folder;
191 try {
192 if (folderCursor.moveToFirst()) {
193 folder = new Folder(folderCursor);
194 } else {
195 continue;
196 }
197 } finally {
198 folderCursor.close();
199 }
200
201 final NotificationKey key = new NotificationKey(account, folder);
202 final Integer unreadValue = Integer.valueOf(notificationParts[2]);
203 final Integer unseenValue = Integer.valueOf(notificationParts[3]);
204 final Pair<Integer, Integer> unreadUnseenValue =
205 new Pair<Integer, Integer>(unreadValue, unseenValue);
206 put(key, unreadUnseenValue);
207 }
208 }
209 }
210 }
211
212 /**
213 * Cache the notification map.
214 */
215 public synchronized void saveNotificationMap(Context context) {
216 final Set<String> notificationSet = Sets.newHashSet();
217 final Set<NotificationKey> keys = keySet();
218 for (NotificationKey key : keys) {
219 final Pair<Integer, Integer> value = get(key);
220 final Integer unreadCount = value.first;
221 final Integer unseenCount = value.second;
222 if (value != null && unreadCount != null && unseenCount != null) {
223 final String[] partValues = new String[] {
224 key.account.uri.toString(), key.folder.uri.toString(),
225 unreadCount.toString(), unseenCount.toString()};
226 notificationSet.add(TextUtils.join(NOTIFICATION_PART_SEPARATOR, partValues));
227 }
228 }
229 final MailPrefs mailPrefs = MailPrefs.get(context);
230 mailPrefs.cacheActiveNotificationSet(notificationSet);
231 }
232 }
233
234 /**
235 * @return the title of this notification with each account and the number of unread and unseen
236 * conversations for it. Also remove any account in the map that has 0 unread.
237 */
238 private static String createNotificationString(NotificationMap notifications) {
239 StringBuilder result = new StringBuilder();
240 int i = 0;
241 Set<NotificationKey> keysToRemove = Sets.newHashSet();
242 for (NotificationKey key : notifications.keySet()) {
243 Integer unread = notifications.getUnread(key);
244 Integer unseen = notifications.getUnseen(key);
245 if (unread == null || unread.intValue() == 0) {
246 keysToRemove.add(key);
247 } else {
248 if (i > 0) result.append(", ");
249 result.append(key.toString() + " (" + unread + ", " + unseen + ")");
250 i++;
251 }
252 }
253
254 for (NotificationKey key : keysToRemove) {
255 notifications.remove(key);
256 }
257
258 return result.toString();
259 }
260
261 /**
262 * Get all notifications for all accounts and cancel them.
263 **/
264 public static void cancelAllNotifications(Context context) {
265 NotificationManager nm = (NotificationManager) context.getSystemService(
266 Context.NOTIFICATION_SERVICE);
267 nm.cancelAll();
268 clearAllNotfications(context);
269 }
270
271 /**
272 * Get all notifications for all accounts, cancel them, and repost.
273 * This happens when locale changes.
274 **/
275 public static void cancelAndResendNotifications(Context context) {
276 resendNotifications(context, true);
277 }
278
279 /**
280 * Get all notifications for all accounts, optionally cancel them, and repost.
281 * This happens when locale changes.
282 **/
283 public static void resendNotifications(Context context, final boolean cancelExisting) {
284 if (cancelExisting) {
285 NotificationManager nm =
286 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
287 nm.cancelAll();
288 }
289 // Re-validate the notifications.
290 final NotificationMap notificationMap = getNotificationMap(context);
291 final Set<NotificationKey> keys = notificationMap.keySet();
292 for (NotificationKey notification : keys) {
293 final Folder folder = notification.folder;
294 final int notificationId = getNotificationId(notification.account.name, folder);
295
296 final NotificationAction undoableAction =
297 NotificationActionUtils.sUndoNotifications.get(notificationId);
298 if (undoableAction == null) {
299 validateNotifications(context, folder, notification.account, true, false,
300 notification);
301 } else {
302 // Create an undo notification
303 NotificationActionUtils.createUndoNotification(context, undoableAction);
304 }
305 }
306 }
307
308 /**
309 * Validate the notifications for the specified account.
310 */
311 public static void validateAccountNotifications(Context context, String account) {
312 List<NotificationKey> notificationsToCancel = Lists.newArrayList();
313 // Iterate through the notification map to see if there are any entries that correspond to
314 // labels that are not in the sync set.
315 final NotificationMap notificationMap = getNotificationMap(context);
316 Set<NotificationKey> keys = notificationMap.keySet();
317 final AccountPreferences accountPreferences = new AccountPreferences(context, account);
318 final boolean enabled = accountPreferences.areNotificationsEnabled();
319 if (!enabled) {
320 // Cancel all notifications for this account
321 for (NotificationKey notification : keys) {
322 if (notification.account.name.equals(account)) {
323 notificationsToCancel.add(notification);
324 }
325 }
326 } else {
327 // Iterate through the notification map to see if there are any entries that
328 // correspond to labels that are not in the notification set.
329 for (NotificationKey notification : keys) {
330 if (notification.account.name.equals(account)) {
331 // If notification is not enabled for this label, remember this NotificationKey
332 // to later cancel the notification, and remove the entry from the map
333 final Folder folder = notification.folder;
334 final boolean isInbox =
335 notification.account.settings.defaultInbox.equals(folder.uri);
336 final FolderPreferences folderPreferences = new FolderPreferences(
337 context, notification.account.name, folder, isInbox);
338
339 if (!folderPreferences.areNotificationsEnabled()) {
340 notificationsToCancel.add(notification);
341 }
342 }
343 }
344 }
345
346 // Cancel & remove the invalid notifications.
347 if (notificationsToCancel.size() > 0) {
348 NotificationManager nm = (NotificationManager) context.getSystemService(
349 Context.NOTIFICATION_SERVICE);
350 for (NotificationKey notification : notificationsToCancel) {
351 final Folder folder = notification.folder;
352 final int notificationId = getNotificationId(notification.account.name, folder);
353 nm.cancel(notificationId);
354 notificationMap.remove(notification);
355 NotificationActionUtils.sUndoNotifications.remove(notificationId);
356 NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
357 }
358 notificationMap.saveNotificationMap(context);
359 }
360 }
361
362 /**
363 * Display only one notification.
364 */
365 public static void setNewEmailIndicator(Context context, final int unreadCount,
366 final int unseenCount, final Account account, final Folder folder,
367 final boolean getAttention) {
368 boolean ignoreUnobtrusiveSetting = false;
369
Scott Kennedydab8a942013-02-22 12:31:30 -0800370 final int notificationId = getNotificationId(account.name, folder);
371
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800372 // Update the notification map
373 final NotificationMap notificationMap = getNotificationMap(context);
374 final NotificationKey key = new NotificationKey(account, folder);
375 if (unreadCount == 0) {
376 notificationMap.remove(key);
Scott Kennedydab8a942013-02-22 12:31:30 -0800377 ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE))
378 .cancel(notificationId);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800379 } else {
380 if (!notificationMap.containsKey(key)) {
381 // This account previously didn't have any unread mail; ignore the "unobtrusive
382 // notifications" setting and play sound and/or vibrate the device even if a
383 // notification already exists (bug 2412348).
384 ignoreUnobtrusiveSetting = true;
385 }
386 notificationMap.put(key, unreadCount, unseenCount);
387 }
388 notificationMap.saveNotificationMap(context);
389
390 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
391 LogUtils.v(LOG_TAG, "New email: %s mapSize: %d getAttention: %b",
392 createNotificationString(notificationMap), notificationMap.size(),
393 getAttention);
394 }
395
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800396 if (NotificationActionUtils.sUndoNotifications.get(notificationId) == null) {
397 validateNotifications(context, folder, account, getAttention, ignoreUnobtrusiveSetting,
398 key);
399 }
400 }
401
402 /**
403 * Validate the notifications notification.
404 */
405 private static void validateNotifications(Context context, final Folder folder,
406 final Account account, boolean getAttention, boolean ignoreUnobtrusiveSetting,
407 NotificationKey key) {
408
409 NotificationManager nm = (NotificationManager)
410 context.getSystemService(Context.NOTIFICATION_SERVICE);
411
412 final NotificationMap notificationMap = getNotificationMap(context);
413 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
414 LogUtils.v(LOG_TAG, "Validating Notification: %s mapSize: %d folder: %s "
415 + "getAttention: %b", createNotificationString(notificationMap),
416 notificationMap.size(), folder.name, getAttention);
417 }
418 // The number of unread messages for this account and label.
419 final Integer unread = notificationMap.getUnread(key);
420 final int unreadCount = unread != null ? unread.intValue() : 0;
421 final Integer unseen = notificationMap.getUnseen(key);
422 int unseenCount = unseen != null ? unseen.intValue() : 0;
423
424 Cursor cursor = null;
425
426 try {
427 final Uri.Builder uriBuilder = folder.conversationListUri.buildUpon();
428 uriBuilder.appendQueryParameter(
429 UIProvider.SEEN_QUERY_PARAMETER, Boolean.FALSE.toString());
Andy Huang0be92432013-03-22 18:31:44 -0700430 // Do not allow this quick check to disrupt any active network-enabled conversation
431 // cursor.
432 uriBuilder.appendQueryParameter(
433 UIProvider.ConversationListQueryParameters.USE_NETWORK,
434 Boolean.FALSE.toString());
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800435 cursor = context.getContentResolver().query(uriBuilder.build(),
436 UIProvider.CONVERSATION_PROJECTION, null, null, null);
437 final int cursorUnseenCount = cursor.getCount();
438
439 // Make sure the unseen count matches the number of items in the cursor. But, we don't
440 // want to overwrite a 0 unseen count that was specified in the intent
441 if (unseenCount != 0 && unseenCount != cursorUnseenCount) {
442 LogUtils.d(LOG_TAG,
443 "Unseen count doesn't match cursor count. unseen: %d cursor count: %d",
444 unseenCount, cursorUnseenCount);
445 unseenCount = cursorUnseenCount;
446 }
447
448 // For the purpose of the notifications, the unseen count should be capped at the num of
449 // unread conversations.
450 if (unseenCount > unreadCount) {
451 unseenCount = unreadCount;
452 }
453
454 final int notificationId = getNotificationId(account.name, folder);
455
456 if (unseenCount == 0) {
457 nm.cancel(notificationId);
458 return;
459 }
460
461 // We now have all we need to create the notification and the pending intent
462 PendingIntent clickIntent;
463
464 NotificationCompat.Builder notification = new NotificationCompat.Builder(context);
465 notification.setSmallIcon(R.drawable.stat_notify_email);
466 notification.setTicker(account.name);
467
468 final long when;
469
470 final long oldWhen =
471 NotificationActionUtils.sNotificationTimestamps.get(notificationId);
472 if (oldWhen != 0) {
473 when = oldWhen;
474 } else {
475 when = System.currentTimeMillis();
476 }
477
478 notification.setWhen(when);
479
480 // The timestamp is now stored in the notification, so we can remove it from here
481 NotificationActionUtils.sNotificationTimestamps.delete(notificationId);
482
483 // Dispatch a CLEAR_NEW_MAIL_NOTIFICATIONS intent if the user taps the "X" next to a
484 // notification. Also this intent gets fired when the user taps on a notification as
485 // the AutoCancel flag has been set
486 final Intent cancelNotificationIntent =
487 new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
488 cancelNotificationIntent.setPackage(context.getPackageName());
Scott Kennedy09400ef2013-03-07 11:09:47 -0800489 cancelNotificationIntent.setData(Utils.appendVersionQueryParameter(context,
490 folder.uri));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800491 cancelNotificationIntent.putExtra(MailIntentService.ACCOUNT_EXTRA, account);
492 cancelNotificationIntent.putExtra(MailIntentService.FOLDER_EXTRA, folder);
493
494 notification.setDeleteIntent(PendingIntent.getService(
495 context, notificationId, cancelNotificationIntent, 0));
496
497 // Ensure that the notification is cleared when the user selects it
498 notification.setAutoCancel(true);
499
500 boolean eventInfoConfigured = false;
501
502 final boolean isInbox = account.settings.defaultInbox.equals(folder.uri);
503 final FolderPreferences folderPreferences =
504 new FolderPreferences(context, account.name, folder, isInbox);
505
506 if (isInbox) {
507 final AccountPreferences accountPreferences =
508 new AccountPreferences(context, account.name);
509 moveNotificationSetting(accountPreferences, folderPreferences);
510 }
511
512 if (!folderPreferences.areNotificationsEnabled()) {
513 // Don't notify
514 return;
515 }
516
517 if (unreadCount > 0) {
518 // How can I order this properly?
519 if (cursor.moveToNext()) {
Scott Kennedy09400ef2013-03-07 11:09:47 -0800520 Intent notificationIntent = createViewConversationIntent(context, account,
521 folder, null);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800522
523 // Launch directly to the conversation, if the
524 // number of unseen conversations == 1
525 if (unseenCount == 1) {
Scott Kennedy09400ef2013-03-07 11:09:47 -0800526 notificationIntent = createViewConversationIntent(context, account, folder,
527 cursor);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800528 }
529
530 if (notificationIntent == null) {
531 LogUtils.e(LOG_TAG, "Null intent when building notification");
532 return;
533 }
534
535 clickIntent = PendingIntent.getActivity(context, -1, notificationIntent, 0);
536 configureLatestEventInfoFromConversation(context, account, folderPreferences,
537 notification, cursor, clickIntent, notificationIntent,
538 account.name, unreadCount, unseenCount, folder, when);
539 eventInfoConfigured = true;
540 }
541 }
542
543 final boolean vibrate = folderPreferences.isNotificationVibrateEnabled();
544 final String ringtoneUri = folderPreferences.getNotificationRingtoneUri();
545 final boolean notifyOnce = !folderPreferences.isEveryMessageNotificationEnabled();
546
547 if (!ignoreUnobtrusiveSetting && account != null && notifyOnce) {
548 // If the user has "unobtrusive notifications" enabled, only alert the first time
549 // new mail is received in this account. This is the default behavior. See
550 // bugs 2412348 and 2413490.
551 notification.setOnlyAlertOnce(true);
552 }
553
554 if (account != null) {
555 LogUtils.d(LOG_TAG, "Account: %s vibrate: %s", account.name,
556 Boolean.toString(folderPreferences.isNotificationVibrateEnabled()));
557 }
558
559 int defaults = 0;
560
561 /*
562 * We do not want to notify if this is coming back from an Undo notification, hence the
563 * oldWhen check.
564 */
565 if (getAttention && account != null && oldWhen == 0) {
566 final AccountPreferences accountPreferences =
567 new AccountPreferences(context, account.name);
568 if (accountPreferences.areNotificationsEnabled()) {
569 if (vibrate) {
570 defaults |= Notification.DEFAULT_VIBRATE;
571 }
572
573 notification.setSound(TextUtils.isEmpty(ringtoneUri) ? null
574 : Uri.parse(ringtoneUri));
575 LogUtils.d(LOG_TAG, "New email in %s vibrateWhen: %s, playing notification: %s",
576 account.name, vibrate, ringtoneUri);
577 }
578 }
579
580 if (eventInfoConfigured) {
581 defaults |= Notification.DEFAULT_LIGHTS;
582 notification.setDefaults(defaults);
583
584 if (oldWhen != 0) {
585 // We do not want to display the ticker again if we are re-displaying this
586 // notification (like from an Undo notification)
587 notification.setTicker(null);
588 }
589
590 nm.notify(notificationId, notification.build());
591 }
592 } finally {
593 if (cursor != null) {
594 cursor.close();
595 }
596 }
597 }
598
599 /**
600 * @return an {@link Intent} which, if launched, will display the corresponding conversation
601 */
Scott Kennedy09400ef2013-03-07 11:09:47 -0800602 private static Intent createViewConversationIntent(final Context context, final Account account,
603 final Folder folder, final Cursor cursor) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800604 if (folder == null || account == null) {
605 LogUtils.e(LOG_TAG, "Null account or folder. account: %s folder: %s",
606 account, folder);
607 return null;
608 }
609
610 final Intent intent;
611
612 if (cursor == null) {
Scott Kennedyb39aaf52013-03-06 19:17:22 -0800613 intent = Utils.createViewFolderIntent(context, folder.uri, account);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800614 } else {
615 // A conversation cursor has been specified, so this intent is intended to be go
616 // directly to the one new conversation
617
618 // Get the Conversation object
619 final Conversation conversation = new Conversation(cursor);
Scott Kennedyb39aaf52013-03-06 19:17:22 -0800620 intent = Utils.createViewConversationIntent(context, conversation, folder.uri,
621 account);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800622 }
623
624 return intent;
625 }
626
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800627 private static Bitmap getDefaultNotificationIcon(
628 final Context context, final Folder folder, final boolean multipleNew) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800629 final Bitmap icon;
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800630 if (folder.notificationIconResId != 0) {
631 icon = getIcon(context, folder.notificationIconResId);
632 } else if (multipleNew) {
633 icon = getIcon(context, R.drawable.ic_notification_multiple_mail_holo_dark);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800634 } else {
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800635 icon = getIcon(context, R.drawable.ic_contact_picture);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800636 }
637 return icon;
638 }
639
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800640 private static Bitmap getIcon(final Context context, final int resId) {
641 final Bitmap cachedIcon = sNotificationIcons.get(resId);
642 if (cachedIcon != null) {
643 return cachedIcon;
644 }
645
646 final Bitmap icon = BitmapFactory.decodeResource(context.getResources(), resId);
647 sNotificationIcons.put(resId, icon);
648
649 return icon;
650 }
651
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800652 private static void configureLatestEventInfoFromConversation(final Context context,
653 final Account account, final FolderPreferences folderPreferences,
654 final NotificationCompat.Builder notification, final Cursor conversationCursor,
655 final PendingIntent clickIntent, final Intent notificationIntent,
656 final String notificationAccount, final int unreadCount, final int unseenCount,
657 final Folder folder, final long when) {
658 final Resources res = context.getResources();
659
660 LogUtils.w(LOG_TAG, "Showing notification with unreadCount of %d and "
661 + "unseenCount of %d", unreadCount, unseenCount);
662
663 String notificationTicker = null;
664
665 // Boolean indicating that this notification is for a non-inbox label.
666 final boolean isInbox = account.settings.defaultInbox.equals(folder.uri);
667
668 // Notification label name for user label notifications.
669 final String notificationLabelName = isInbox ? null : folder.name;
670
671 if (unseenCount > 1) {
672 // Build the string that describes the number of new messages
673 final String newMessagesString = res.getString(R.string.new_messages, unseenCount);
674
675 // Use the default notification icon
676 notification.setLargeIcon(
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800677 getDefaultNotificationIcon(context, folder, true /* multiple new messages */));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800678
679 // The ticker initially start as the new messages string.
680 notificationTicker = newMessagesString;
681
682 // The title of the notification is the new messages string
683 notification.setContentTitle(newMessagesString);
684
685 if (com.android.mail.utils.Utils.isRunningJellybeanOrLater()) {
686 // For a new-style notification
687 final int maxNumDigestItems = context.getResources().getInteger(
688 R.integer.max_num_notification_digest_items);
689
690 // The body of the notification is the account name, or the label name.
691 notification.setSubText(isInbox ? notificationAccount : notificationLabelName);
692
693 final NotificationCompat.InboxStyle digest =
694 new NotificationCompat.InboxStyle(notification);
695
696 digest.setBigContentTitle(newMessagesString);
697
698 int numDigestItems = 0;
699 do {
700 final Conversation conversation = new Conversation(conversationCursor);
701
702 if (!conversation.read) {
703 boolean multipleUnreadThread = false;
704 // TODO(cwren) extract this pattern into a helper
705
706 Cursor cursor = null;
707 MessageCursor messageCursor = null;
708 try {
709 final Uri.Builder uriBuilder = conversation.messageListUri.buildUpon();
710 uriBuilder.appendQueryParameter(
711 UIProvider.LABEL_QUERY_PARAMETER, notificationLabelName);
712 cursor = context.getContentResolver().query(uriBuilder.build(),
713 UIProvider.MESSAGE_PROJECTION, null, null, null);
714 messageCursor = new MessageCursor(cursor);
715
Yu Ping Hub18d75a2013-03-12 17:04:58 -0700716 String from = "";
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800717 String fromAddress = "";
718 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
719 final Message message = messageCursor.getMessage();
720 fromAddress = message.getFrom();
Yu Ping Hub18d75a2013-03-12 17:04:58 -0700721 if (fromAddress == null) {
722 fromAddress = "";
723 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800724 from = getDisplayableSender(fromAddress);
725 }
726 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
727 final Message message = messageCursor.getMessage();
728 if (!message.read &&
729 !fromAddress.contentEquals(message.getFrom())) {
730 multipleUnreadThread = true;
731 break;
732 }
733 }
734 final SpannableStringBuilder sendersBuilder;
735 if (multipleUnreadThread) {
736 final int sendersLength =
737 res.getInteger(R.integer.swipe_senders_length);
738
739 sendersBuilder = getStyledSenders(context, conversationCursor,
740 sendersLength, notificationAccount);
741 } else {
742 sendersBuilder = new SpannableStringBuilder(from);
743 }
744 final CharSequence digestLine = getSingleMessageInboxLine(context,
745 sendersBuilder.toString(),
746 conversation.subject,
747 conversation.snippet);
748 digest.addLine(digestLine);
749 numDigestItems++;
750 } finally {
751 if (messageCursor != null) {
752 messageCursor.close();
753 }
754 if (cursor != null) {
755 cursor.close();
756 }
757 }
758 }
759 } while (numDigestItems <= maxNumDigestItems && conversationCursor.moveToNext());
760 } else {
761 // The body of the notification is the account name, or the label name.
762 notification.setContentText(
763 isInbox ? notificationAccount : notificationLabelName);
764 }
765 } else {
766 // For notifications for a single new conversation, we want to get the information from
767 // the conversation
768
769 // Move the cursor to the most recent unread conversation
770 seekToLatestUnreadConversation(conversationCursor);
771
772 final Conversation conversation = new Conversation(conversationCursor);
773
774 Cursor cursor = null;
775 MessageCursor messageCursor = null;
776 boolean multipleUnseenThread = false;
777 String from = null;
778 try {
Scott Kennedyffbf86f2013-03-08 11:48:15 -0800779 final Uri uri = conversation.messageListUri.buildUpon().appendQueryParameter(
780 UIProvider.LABEL_QUERY_PARAMETER, folder.persistentId).build();
781 cursor = context.getContentResolver().query(uri, UIProvider.MESSAGE_PROJECTION,
782 null, null, null);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800783 messageCursor = new MessageCursor(cursor);
784 // Use the information from the last sender in the conversation that triggered
785 // this notification.
786
787 String fromAddress = "";
788 if (messageCursor.moveToPosition(messageCursor.getCount() - 1)) {
789 final Message message = messageCursor.getMessage();
790 fromAddress = message.getFrom();
791 from = getDisplayableSender(fromAddress);
792 notification.setLargeIcon(
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800793 getContactIcon(context, getSenderAddress(fromAddress), folder));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800794 }
795
796 // Assume that the last message in this conversation is unread
797 int firstUnseenMessagePos = messageCursor.getPosition();
798 while (messageCursor.moveToPosition(messageCursor.getPosition() - 1)) {
799 final Message message = messageCursor.getMessage();
800 final boolean unseen = !message.seen;
801 if (unseen) {
802 firstUnseenMessagePos = messageCursor.getPosition();
803 if (!multipleUnseenThread
804 && !fromAddress.contentEquals(message.getFrom())) {
805 multipleUnseenThread = true;
806 }
807 }
808 }
809
810 if (Utils.isRunningJellybeanOrLater()) {
811 // For a new-style notification
812
813 if (multipleUnseenThread) {
814 // The title of a single conversation is the list of senders.
815 int sendersLength = res.getInteger(R.integer.swipe_senders_length);
816
817 final SpannableStringBuilder sendersBuilder = getStyledSenders(
818 context, conversationCursor, sendersLength, notificationAccount);
819
820 notification.setContentTitle(sendersBuilder);
821 // For a single new conversation, the ticker is based on the sender's name.
822 notificationTicker = sendersBuilder.toString();
823 } else {
824 // The title of a single message the sender.
825 notification.setContentTitle(from);
826 // For a single new conversation, the ticker is based on the sender's name.
827 notificationTicker = from;
828 }
829
830 // The notification content will be the subject of the conversation.
831 notification.setContentText(
832 getSingleMessageLittleText(context, conversation.subject));
833
834 // The notification subtext will be the subject of the conversation for inbox
835 // notifications, or will based on the the label name for user label
836 // notifications.
837 notification.setSubText(isInbox ? notificationAccount : notificationLabelName);
838
839 if (multipleUnseenThread) {
Scott Kennedy61bd0e82012-12-10 18:18:17 -0800840 notification.setLargeIcon(
841 getDefaultNotificationIcon(context, folder, true));
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800842 }
843 final NotificationCompat.BigTextStyle bigText =
844 new NotificationCompat.BigTextStyle(notification);
845
846 // Seek the message cursor to the first unread message
Paul Westbrook92b21742013-03-11 17:36:40 -0700847 final Message message;
848 if (messageCursor.moveToPosition(firstUnseenMessagePos)) {
849 message = messageCursor.getMessage();
850 bigText.bigText(getSingleMessageBigText(context,
851 conversation.subject, message));
852 } else {
853 LogUtils.e(LOG_TAG, "Failed to load message");
854 message = null;
855 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800856
Paul Westbrook92b21742013-03-11 17:36:40 -0700857 if (message != null) {
858 final Set<String> notificationActions =
Scott Kennedycde6eb02013-03-21 18:49:32 -0700859 folderPreferences.getNotificationActions(account);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800860
Paul Westbrook92b21742013-03-11 17:36:40 -0700861 final int notificationId = getNotificationId(notificationAccount, folder);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800862
Paul Westbrook92b21742013-03-11 17:36:40 -0700863 NotificationActionUtils.addNotificationActions(context, notificationIntent,
864 notification, account, conversation, message, folder,
865 notificationId, when, notificationActions);
866 }
Scott Kennedyd5edd2d2012-12-05 11:11:32 -0800867 } else {
868 // For an old-style notification
869
870 // The title of a single conversation notification is built from both the sender
871 // and subject of the new message.
872 notification.setContentTitle(getSingleMessageNotificationTitle(context,
873 from, conversation.subject));
874
875 // The notification content will be the subject of the conversation for inbox
876 // notifications, or will based on the the label name for user label
877 // notifications.
878 notification.setContentText(
879 isInbox ? notificationAccount : notificationLabelName);
880
881 // For a single new conversation, the ticker is based on the sender's name.
882 notificationTicker = from;
883 }
884 } finally {
885 if (messageCursor != null) {
886 messageCursor.close();
887 }
888 if (cursor != null) {
889 cursor.close();
890 }
891 }
892 }
893
894 // Build the notification ticker
895 if (notificationLabelName != null && notificationTicker != null) {
896 // This is a per label notification, format the ticker with that information
897 notificationTicker = res.getString(R.string.label_notification_ticker,
898 notificationLabelName, notificationTicker);
899 }
900
901 if (notificationTicker != null) {
902 // If we didn't generate a notification ticker, it will default to account name
903 notification.setTicker(notificationTicker);
904 }
905
906 // Set the number in the notification
907 if (unreadCount > 1) {
908 notification.setNumber(unreadCount);
909 }
910
911 notification.setContentIntent(clickIntent);
912 }
913
914 private static SpannableStringBuilder getStyledSenders(final Context context,
915 final Cursor conversationCursor, final int maxLength, final String account) {
916 final Conversation conversation = new Conversation(conversationCursor);
917 final com.android.mail.providers.ConversationInfo conversationInfo =
918 conversation.conversationInfo;
919 final ArrayList<SpannableString> senders = new ArrayList<SpannableString>();
920 if (sNotificationUnreadStyleSpan == null) {
921 sNotificationUnreadStyleSpan = new TextAppearanceSpan(
922 context, R.style.NotificationSendersUnreadTextAppearance);
923 sNotificationReadStyleSpan =
924 new TextAppearanceSpan(context, R.style.NotificationSendersReadTextAppearance);
925 }
926 SendersView.format(context, conversationInfo, "", maxLength, senders, null, null, account,
927 sNotificationUnreadStyleSpan, sNotificationReadStyleSpan, false);
928
929 return ellipsizeStyledSenders(context, senders);
930 }
931
932 private static String sSendersSplitToken = null;
933 private static String sElidedPaddingToken = null;
934
935 private static SpannableStringBuilder ellipsizeStyledSenders(final Context context,
936 ArrayList<SpannableString> styledSenders) {
937 if (sSendersSplitToken == null) {
938 sSendersSplitToken = context.getString(R.string.senders_split_token);
939 sElidedPaddingToken = context.getString(R.string.elided_padding_token);
940 }
941
942 SpannableStringBuilder builder = new SpannableStringBuilder();
943 SpannableString prevSender = null;
944 for (SpannableString sender : styledSenders) {
945 if (sender == null) {
946 LogUtils.e(LOG_TAG, "null sender while iterating over styledSenders");
947 continue;
948 }
949 CharacterStyle[] spans = sender.getSpans(0, sender.length(), CharacterStyle.class);
950 if (SendersView.sElidedString.equals(sender.toString())) {
951 prevSender = sender;
952 sender = copyStyles(spans, sElidedPaddingToken + sender + sElidedPaddingToken);
953 } else if (builder.length() > 0
954 && (prevSender == null || !SendersView.sElidedString.equals(prevSender
955 .toString()))) {
956 prevSender = sender;
957 sender = copyStyles(spans, sSendersSplitToken + sender);
958 } else {
959 prevSender = sender;
960 }
961 builder.append(sender);
962 }
963 return builder;
964 }
965
966 private static SpannableString copyStyles(CharacterStyle[] spans, CharSequence newText) {
967 SpannableString s = new SpannableString(newText);
968 if (spans != null && spans.length > 0) {
969 s.setSpan(spans[0], 0, s.length(), 0);
970 }
971 return s;
972 }
973
974 /**
975 * Seeks the cursor to the position of the most recent unread conversation. If no unread
976 * conversation is found, the position of the cursor will be restored, and false will be
977 * returned.
978 */
979 private static boolean seekToLatestUnreadConversation(final Cursor cursor) {
980 final int initialPosition = cursor.getPosition();
981 do {
982 final Conversation conversation = new Conversation(cursor);
983 if (!conversation.read) {
984 return true;
985 }
986 } while (cursor.moveToNext());
987
988 // Didn't find an unread conversation, reset the position.
989 cursor.moveToPosition(initialPosition);
990 return false;
991 }
992
993 /**
994 * Sets the bigtext for a notification for a single new conversation
995 *
996 * @param context
997 * @param senders Sender of the new message that triggered the notification.
998 * @param subject Subject of the new message that triggered the notification
999 * @param snippet Snippet of the new message that triggered the notification
1000 * @return a {@link CharSequence} suitable for use in
1001 * {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1002 */
1003 private static CharSequence getSingleMessageInboxLine(Context context,
1004 String senders, String subject, String snippet) {
1005 // TODO(cwren) finish this step toward commmon code with getSingleMessageBigText
1006
1007 final String subjectSnippet = !TextUtils.isEmpty(subject) ? subject : snippet;
1008
1009 final TextAppearanceSpan notificationPrimarySpan =
1010 new TextAppearanceSpan(context, R.style.NotificationPrimaryText);
1011
1012 if (TextUtils.isEmpty(senders)) {
1013 // If the senders are empty, just use the subject/snippet.
1014 return subjectSnippet;
1015 } else if (TextUtils.isEmpty(subjectSnippet)) {
1016 // If the subject/snippet is empty, just use the senders.
1017 final SpannableString spannableString = new SpannableString(senders);
1018 spannableString.setSpan(notificationPrimarySpan, 0, senders.length(), 0);
1019
1020 return spannableString;
1021 } else {
1022 final String formatString = context.getResources().getString(
1023 R.string.multiple_new_message_notification_item);
1024 final TextAppearanceSpan notificationSecondarySpan =
1025 new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1026
1027 final String instantiatedString = String.format(formatString, senders, subjectSnippet);
1028
1029 final SpannableString spannableString = new SpannableString(instantiatedString);
1030
1031 final boolean isOrderReversed = formatString.indexOf("%2$s") <
1032 formatString.indexOf("%1$s");
1033 final int primaryOffset =
1034 (isOrderReversed ? instantiatedString.lastIndexOf(senders) :
1035 instantiatedString.indexOf(senders));
1036 final int secondaryOffset =
1037 (isOrderReversed ? instantiatedString.lastIndexOf(subjectSnippet) :
1038 instantiatedString.indexOf(subjectSnippet));
1039 spannableString.setSpan(notificationPrimarySpan,
1040 primaryOffset, primaryOffset + senders.length(), 0);
1041 spannableString.setSpan(notificationSecondarySpan,
1042 secondaryOffset, secondaryOffset + subjectSnippet.length(), 0);
1043 return spannableString;
1044 }
1045 }
1046
1047 /**
1048 * Sets the bigtext for a notification for a single new conversation
1049 * @param context
1050 * @param subject Subject of the new message that triggered the notification
1051 * @return a {@link CharSequence} suitable for use in {@link Notification.ContentText}
1052 */
1053 private static CharSequence getSingleMessageLittleText(Context context, String subject) {
1054 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1055 context, R.style.NotificationPrimaryText);
1056
1057 final SpannableString spannableString = new SpannableString(subject);
1058 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1059
1060 return spannableString;
1061 }
1062
1063 /**
1064 * Sets the bigtext for a notification for a single new conversation
1065 *
1066 * @param context
1067 * @param subject Subject of the new message that triggered the notification
1068 * @param message the {@link Message} to be displayed.
1069 * @return a {@link CharSequence} suitable for use in
1070 * {@link android.support.v4.app.NotificationCompat.BigTextStyle}
1071 */
1072 private static CharSequence getSingleMessageBigText(Context context, String subject,
1073 final Message message) {
1074
1075 final TextAppearanceSpan notificationSubjectSpan = new TextAppearanceSpan(
1076 context, R.style.NotificationPrimaryText);
1077
1078 final String snippet = getMessageBodyWithoutElidedText(message);
1079
1080 // Change multiple newlines (with potential white space between), into a single new line
1081 final String collapsedSnippet =
1082 !TextUtils.isEmpty(snippet) ? snippet.replaceAll("\\n\\s+", "\n") : "";
1083
1084 if (TextUtils.isEmpty(subject)) {
1085 // If the subject is empty, just use the snippet.
1086 return snippet;
1087 } else if (TextUtils.isEmpty(collapsedSnippet)) {
1088 // If the snippet is empty, just use the subject.
1089 final SpannableString spannableString = new SpannableString(subject);
1090 spannableString.setSpan(notificationSubjectSpan, 0, subject.length(), 0);
1091
1092 return spannableString;
1093 } else {
1094 final String notificationBigTextFormat = context.getResources().getString(
1095 R.string.single_new_message_notification_big_text);
1096
1097 // Localizers may change the order of the parameters, look at how the format
1098 // string is structured.
1099 final boolean isSubjectFirst = notificationBigTextFormat.indexOf("%2$s") >
1100 notificationBigTextFormat.indexOf("%1$s");
1101 final String bigText =
1102 String.format(notificationBigTextFormat, subject, collapsedSnippet);
1103 final SpannableString spannableString = new SpannableString(bigText);
1104
1105 final int subjectOffset =
1106 (isSubjectFirst ? bigText.indexOf(subject) : bigText.lastIndexOf(subject));
1107 spannableString.setSpan(notificationSubjectSpan,
1108 subjectOffset, subjectOffset + subject.length(), 0);
1109
1110 return spannableString;
1111 }
1112 }
1113
1114 /**
1115 * Gets the title for a notification for a single new conversation
1116 * @param context
1117 * @param sender Sender of the new message that triggered the notification.
1118 * @param subject Subject of the new message that triggered the notification
1119 * @return a {@link CharSequence} suitable for use as a {@link Notification} title.
1120 */
1121 private static CharSequence getSingleMessageNotificationTitle(Context context,
1122 String sender, String subject) {
1123
1124 if (TextUtils.isEmpty(subject)) {
1125 // If the subject is empty, just set the title to the sender's information.
1126 return sender;
1127 } else {
1128 final String notificationTitleFormat = context.getResources().getString(
1129 R.string.single_new_message_notification_title);
1130
1131 // Localizers may change the order of the parameters, look at how the format
1132 // string is structured.
1133 final boolean isSubjectLast = notificationTitleFormat.indexOf("%2$s") >
1134 notificationTitleFormat.indexOf("%1$s");
1135 final String titleString = String.format(notificationTitleFormat, sender, subject);
1136
1137 // Format the string so the subject is using the secondaryText style
1138 final SpannableString titleSpannable = new SpannableString(titleString);
1139
1140 // Find the offset of the subject.
1141 final int subjectOffset =
1142 isSubjectLast ? titleString.lastIndexOf(subject) : titleString.indexOf(subject);
1143 final TextAppearanceSpan notificationSubjectSpan =
1144 new TextAppearanceSpan(context, R.style.NotificationSecondaryText);
1145 titleSpannable.setSpan(notificationSubjectSpan,
1146 subjectOffset, subjectOffset + subject.length(), 0);
1147 return titleSpannable;
1148 }
1149 }
1150
1151 /**
1152 * Adds a fragment with given style to a string builder.
1153 *
1154 * @param builder the current string builder
1155 * @param fragment the fragment to be added
1156 * @param style the style of the fragment
1157 * @param withSpaces whether to add the whole fragment or to divide it into
1158 * smaller ones
1159 */
1160 private static void addStyledFragment(SpannableStringBuilder builder, String fragment,
1161 CharacterStyle style, boolean withSpaces) {
1162 if (withSpaces) {
1163 int pos = builder.length();
1164 builder.append(fragment);
1165 builder.setSpan(CharacterStyle.wrap(style), pos, builder.length(),
1166 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1167 } else {
1168 int start = 0;
1169 while (true) {
1170 int pos = fragment.substring(start).indexOf(' ');
1171 if (pos == -1) {
1172 addStyledFragment(builder, fragment.substring(start), style, true);
1173 break;
1174 } else {
1175 pos += start;
1176 if (start < pos) {
1177 addStyledFragment(builder, fragment.substring(start, pos), style, true);
1178 builder.append(' ');
1179 }
1180 start = pos + 1;
1181 if (start >= fragment.length()) {
1182 break;
1183 }
1184 }
1185 }
1186 }
1187 }
1188
1189 /**
1190 * Uses sender instructions to build a formatted string.
1191 *
1192 * <p>Sender list instructions contain compact information about the sender list. Most work that
1193 * can be done without knowing how much room will be availble for the sender list is done when
1194 * creating the instructions.
1195 *
1196 * <p>The instructions string consists of tokens separated by SENDER_LIST_SEPARATOR. Here are
1197 * the tokens, one per line:<ul>
1198 * <li><tt>n</tt></li>
1199 * <li><em>int</em>, the number of non-draft messages in the conversation</li>
1200 * <li><tt>d</tt</li>
1201 * <li><em>int</em>, the number of drafts in the conversation</li>
1202 * <li><tt>l</tt></li>
1203 * <li><em>literal html to be included in the output</em></li>
1204 * <li><tt>s</tt> indicates that the message is sending (in the outbox without errors)</li>
1205 * <li><tt>f</tt> indicates that the message failed to send (in the outbox with errors)</li>
1206 * <li><em>for each message</em><ul>
1207 * <li><em>int</em>, 0 for read, 1 for unread</li>
1208 * <li><em>int</em>, the priority of the message. Zero is the most important</li>
1209 * <li><em>text</em>, the sender text or blank for messages from 'me'</li>
1210 * </ul></li>
1211 * <li><tt>e</tt> to indicate that one or more messages have been elided</li>
1212 *
1213 * <p>The instructions indicate how many messages and drafts are in the conversation and then
1214 * describe the most important messages in order, indicating the priority of each message and
1215 * whether the message is unread.
1216 *
1217 * @param instructions instructions as described above
1218 * @param senderBuilder the SpannableStringBuilder to append to for sender information
1219 * @param statusBuilder the SpannableStringBuilder to append to for status
1220 * @param maxChars the number of characters available to display the text
1221 * @param unreadStyle the CharacterStyle for unread messages, or null
1222 * @param draftsStyle the CharacterStyle for draft messages, or null
1223 * @param sendingString the string to use when there are messages scheduled to be sent
1224 * @param sendFailedString the string to use when there are messages that mailed to send
1225 * @param meString the string to use for messages sent by this user
1226 * @param draftString the string to use for "Draft"
1227 * @param draftPluralString the string to use for "Drafts"
1228 * @param showNumMessages false means do not show the message count
1229 * @param onlyShowUnread true means the only information from unread messages should be included
1230 */
1231 public static synchronized void getSenderSnippet(
1232 String instructions, SpannableStringBuilder senderBuilder,
1233 SpannableStringBuilder statusBuilder, int maxChars,
1234 CharacterStyle unreadStyle,
1235 CharacterStyle readStyle,
1236 CharacterStyle draftsStyle,
1237 CharSequence meString, CharSequence draftString, CharSequence draftPluralString,
1238 CharSequence sendingString, CharSequence sendFailedString,
1239 boolean forceAllUnread, boolean forceAllRead, boolean allowDraft,
1240 boolean showNumMessages, boolean onlyShowUnread) {
1241 assert !(forceAllUnread && forceAllRead);
1242 boolean unreadStatusIsForced = forceAllUnread || forceAllRead;
1243 boolean forcedUnreadStatus = forceAllUnread;
1244
1245 // Measure each fragment. It's ok to iterate over the entire set of fragments because it is
1246 // never a long list, even if there are many senders.
1247 final Map<Integer, Integer> priorityToLength = sPriorityToLength;
1248 priorityToLength.clear();
1249
1250 int maxFoundPriority = Integer.MIN_VALUE;
1251 int numMessages = 0;
1252 int numDrafts = 0;
1253 CharSequence draftsFragment = "";
1254 CharSequence sendingFragment = "";
1255 CharSequence sendFailedFragment = "";
1256
1257 SENDER_LIST_SPLITTER.setString(instructions);
1258 int numFragments = 0;
1259 String[] fragments = sSenderFragments;
1260 int currentSize = fragments.length;
1261 while (SENDER_LIST_SPLITTER.hasNext()) {
1262 fragments[numFragments++] = SENDER_LIST_SPLITTER.next();
1263 if (numFragments == currentSize) {
1264 sSenderFragments = new String[2 * currentSize];
1265 System.arraycopy(fragments, 0, sSenderFragments, 0, currentSize);
1266 currentSize *= 2;
1267 fragments = sSenderFragments;
1268 }
1269 }
1270
1271 for (int i = 0; i < numFragments;) {
1272 String fragment0 = fragments[i++];
1273 if ("".equals(fragment0)) {
1274 // This should be the final fragment.
1275 } else if (Utils.SENDER_LIST_TOKEN_ELIDED.equals(fragment0)) {
1276 // ignore
1277 } else if (Utils.SENDER_LIST_TOKEN_NUM_MESSAGES.equals(fragment0)) {
1278 numMessages = Integer.valueOf(fragments[i++]);
1279 } else if (Utils.SENDER_LIST_TOKEN_NUM_DRAFTS.equals(fragment0)) {
1280 String numDraftsString = fragments[i++];
1281 numDrafts = Integer.parseInt(numDraftsString);
1282 draftsFragment = numDrafts == 1 ? draftString :
1283 draftPluralString + " (" + numDraftsString + ")";
1284 } else if (Utils.SENDER_LIST_TOKEN_LITERAL.equals(fragment0)) {
1285 senderBuilder.append(Html.fromHtml(fragments[i++]));
1286 return;
1287 } else if (Utils.SENDER_LIST_TOKEN_SENDING.equals(fragment0)) {
1288 sendingFragment = sendingString;
1289 } else if (Utils.SENDER_LIST_TOKEN_SEND_FAILED.equals(fragment0)) {
1290 sendFailedFragment = sendFailedString;
1291 } else {
1292 final String unreadString = fragment0;
1293 final boolean unread = unreadStatusIsForced
1294 ? forcedUnreadStatus : Integer.parseInt(unreadString) != 0;
1295 String priorityString = fragments[i++];
1296 CharSequence nameString = fragments[i++];
1297 if (nameString.length() == 0) nameString = meString;
1298 int priority = Integer.parseInt(priorityString);
1299
1300 // We want to include this entry if
1301 // 1) The onlyShowUnread flags is not set
1302 // 2) The above flag is set, and the message is unread
1303 if (!onlyShowUnread || unread) {
1304 priorityToLength.put(priority, nameString.length());
1305 maxFoundPriority = Math.max(maxFoundPriority, priority);
1306 }
1307 }
1308 }
1309 final String numMessagesFragment =
1310 (numMessages != 0 && showNumMessages) ?
1311 " \u00A0" + Integer.toString(numMessages + numDrafts) : "";
1312
1313 // Don't allocate fixedFragment unless we need it
1314 SpannableStringBuilder fixedFragment = null;
1315 int fixedFragmentLength = 0;
1316 if (draftsFragment.length() != 0 && allowDraft) {
1317 if (fixedFragment == null) {
1318 fixedFragment = new SpannableStringBuilder();
1319 }
1320 fixedFragment.append(draftsFragment);
1321 if (draftsStyle != null) {
1322 fixedFragment.setSpan(
1323 CharacterStyle.wrap(draftsStyle),
1324 0, fixedFragment.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1325 }
1326 }
1327 if (sendingFragment.length() != 0) {
1328 if (fixedFragment == null) {
1329 fixedFragment = new SpannableStringBuilder();
1330 }
1331 if (fixedFragment.length() != 0) fixedFragment.append(", ");
1332 fixedFragment.append(sendingFragment);
1333 }
1334 if (sendFailedFragment.length() != 0) {
1335 if (fixedFragment == null) {
1336 fixedFragment = new SpannableStringBuilder();
1337 }
1338 if (fixedFragment.length() != 0) fixedFragment.append(", ");
1339 fixedFragment.append(sendFailedFragment);
1340 }
1341
1342 if (fixedFragment != null) {
1343 fixedFragmentLength = fixedFragment.length();
1344 }
1345 maxChars -= fixedFragmentLength;
1346
1347 int maxPriorityToInclude = -1; // inclusive
1348 int numCharsUsed = numMessagesFragment.length();
1349 int numSendersUsed = 0;
1350 while (maxPriorityToInclude < maxFoundPriority) {
1351 if (priorityToLength.containsKey(maxPriorityToInclude + 1)) {
1352 int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1);
1353 if (numCharsUsed > 0) length += 2;
1354 // We must show at least two senders if they exist. If we don't have space for both
1355 // then we will truncate names.
1356 if (length > maxChars && numSendersUsed >= 2) {
1357 break;
1358 }
1359 numCharsUsed = length;
1360 numSendersUsed++;
1361 }
1362 maxPriorityToInclude++;
1363 }
1364
1365 int numCharsToRemovePerWord = 0;
1366 if (numCharsUsed > maxChars) {
1367 numCharsToRemovePerWord = (numCharsUsed - maxChars) / numSendersUsed;
1368 }
1369
1370 String lastFragment = null;
1371 CharacterStyle lastStyle = null;
1372 for (int i = 0; i < numFragments;) {
1373 String fragment0 = fragments[i++];
1374 if ("".equals(fragment0)) {
1375 // This should be the final fragment.
1376 } else if (Utils.SENDER_LIST_TOKEN_ELIDED.equals(fragment0)) {
1377 if (lastFragment != null) {
1378 addStyledFragment(senderBuilder, lastFragment, lastStyle, false);
1379 senderBuilder.append(" ");
1380 addStyledFragment(senderBuilder, "..", lastStyle, true);
1381 senderBuilder.append(" ");
1382 }
1383 lastFragment = null;
1384 } else if (Utils.SENDER_LIST_TOKEN_NUM_MESSAGES.equals(fragment0)) {
1385 i++;
1386 } else if (Utils.SENDER_LIST_TOKEN_NUM_DRAFTS.equals(fragment0)) {
1387 i++;
1388 } else if (Utils.SENDER_LIST_TOKEN_SENDING.equals(fragment0)) {
1389 } else if (Utils.SENDER_LIST_TOKEN_SEND_FAILED.equals(fragment0)) {
1390 } else {
1391 final String unreadString = fragment0;
1392 final String priorityString = fragments[i++];
1393 String nameString = fragments[i++];
1394 final boolean unread = unreadStatusIsForced
1395 ? forcedUnreadStatus : Integer.parseInt(unreadString) != 0;
1396
1397 // We want to include this entry if
1398 // 1) The onlyShowUnread flags is not set
1399 // 2) The above flag is set, and the message is unread
1400 if (!onlyShowUnread || unread) {
1401 if (nameString.length() == 0) {
1402 nameString = meString.toString();
1403 } else {
1404 nameString = Html.fromHtml(nameString).toString();
1405 }
1406 if (numCharsToRemovePerWord != 0) {
1407 nameString = nameString.substring(
1408 0, Math.max(nameString.length() - numCharsToRemovePerWord, 0));
1409 }
1410 final int priority = Integer.parseInt(priorityString);
1411 if (priority <= maxPriorityToInclude) {
1412 if (lastFragment != null && !lastFragment.equals(nameString)) {
1413 addStyledFragment(
1414 senderBuilder, lastFragment.concat(","), lastStyle, false);
1415 senderBuilder.append(" ");
1416 }
1417 lastFragment = nameString;
1418 lastStyle = unread ? unreadStyle : readStyle;
1419 } else {
1420 if (lastFragment != null) {
1421 addStyledFragment(senderBuilder, lastFragment, lastStyle, false);
1422 // Adjacent spans can cause the TextView in Gmail widget
1423 // confused and leads to weird behavior on scrolling.
1424 // Our workaround here is to separate the spans by
1425 // spaces.
1426 senderBuilder.append(" ");
1427 addStyledFragment(senderBuilder, "..", lastStyle, true);
1428 senderBuilder.append(" ");
1429 }
1430 lastFragment = null;
1431 }
1432 }
1433 }
1434 }
1435 if (lastFragment != null) {
1436 addStyledFragment(senderBuilder, lastFragment, lastStyle, false);
1437 }
1438 senderBuilder.append(numMessagesFragment);
1439 if (fixedFragmentLength != 0) {
1440 statusBuilder.append(fixedFragment);
1441 }
1442 }
1443
1444 /**
1445 * Clears the notifications for the specified account/folder/conversation.
1446 */
1447 public static void clearFolderNotification(Context context, Account account, Folder folder) {
1448 LogUtils.v(LOG_TAG, "Clearing all notifications for %s/%s", account.name, folder.name);
1449 final NotificationMap notificationMap = getNotificationMap(context);
1450 final NotificationKey key = new NotificationKey(account, folder);
1451 notificationMap.remove(key);
1452 notificationMap.saveNotificationMap(context);
1453
1454 markSeen(context, folder);
1455 }
1456
1457 private static ArrayList<Long> findContacts(Context context, Collection<String> addresses) {
1458 ArrayList<String> whereArgs = new ArrayList<String>();
1459 StringBuilder whereBuilder = new StringBuilder();
1460 String[] questionMarks = new String[addresses.size()];
1461
1462 whereArgs.addAll(addresses);
1463 Arrays.fill(questionMarks, "?");
1464 whereBuilder.append(Email.DATA1 + " IN (").
1465 append(TextUtils.join(",", questionMarks)).
1466 append(")");
1467
1468 ContentResolver resolver = context.getContentResolver();
1469 Cursor c = resolver.query(Email.CONTENT_URI,
1470 new String[]{Email.CONTACT_ID}, whereBuilder.toString(),
1471 whereArgs.toArray(new String[0]), null);
1472
1473 ArrayList<Long> contactIds = new ArrayList<Long>();
1474 if (c == null) {
1475 return contactIds;
1476 }
1477 try {
1478 while (c.moveToNext()) {
1479 contactIds.add(c.getLong(0));
1480 }
1481 } finally {
1482 c.close();
1483 }
1484 return contactIds;
1485 }
1486
Scott Kennedy61bd0e82012-12-10 18:18:17 -08001487 private static Bitmap getContactIcon(
1488 Context context, String senderAddress, final Folder folder) {
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001489 if (senderAddress == null) {
1490 return null;
1491 }
1492 Bitmap icon = null;
1493 ArrayList<Long> contactIds = findContacts(
1494 context, Arrays.asList(new String[] { senderAddress }));
1495
1496 if (contactIds != null) {
1497 // Get the ideal size for this icon.
1498 final Resources res = context.getResources();
1499 final int idealIconHeight =
1500 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
1501 final int idealIconWidth =
1502 res.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
1503 for (long id : contactIds) {
1504 final Uri contactUri =
1505 ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, id);
1506 final Uri photoUri = Uri.withAppendedPath(contactUri, Photo.CONTENT_DIRECTORY);
1507 final Cursor cursor = context.getContentResolver().query(
1508 photoUri, new String[] { Photo.PHOTO }, null, null, null);
1509
1510 if (cursor != null) {
1511 try {
1512 if (cursor.moveToFirst()) {
1513 byte[] data = cursor.getBlob(0);
1514 if (data != null) {
1515 icon = BitmapFactory.decodeStream(new ByteArrayInputStream(data));
1516 if (icon != null && icon.getHeight() < idealIconHeight) {
1517 // We should scale this image to fit the intended size
1518 icon = Bitmap.createScaledBitmap(
1519 icon, idealIconWidth, idealIconHeight, true);
1520 }
1521 if (icon != null) {
1522 break;
1523 }
1524 }
1525 }
1526 } finally {
1527 cursor.close();
1528 }
1529 }
1530 }
1531 }
1532 if (icon == null) {
1533 // icon should be the default gmail icon.
Scott Kennedy61bd0e82012-12-10 18:18:17 -08001534 icon = getDefaultNotificationIcon(context, folder, false /* single new message */);
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001535 }
1536 return icon;
1537 }
1538
1539 private static String getMessageBodyWithoutElidedText(final Message message) {
Scott Kennedy5e7e88b2013-03-07 16:42:12 -08001540 return getMessageBodyWithoutElidedText(message.getBodyAsHtml());
Scott Kennedyd5edd2d2012-12-05 11:11:32 -08001541 }
1542
1543 public static String getMessageBodyWithoutElidedText(String html) {
1544 if (TextUtils.isEmpty(html)) {
1545 return "";
1546 }
1547 // Get the html "tree" for this message body
1548 final HtmlTree htmlTree = com.android.mail.utils.Utils.getHtmlTree(html);
1549 htmlTree.setPlainTextConverterFactory(MESSAGE_CONVERTER_FACTORY);
1550
1551 return htmlTree.getPlainText();
1552 }
1553
1554 public static void markSeen(final Context context, final Folder folder) {
1555 final Uri uri = folder.uri;
1556
1557 final ContentValues values = new ContentValues(1);
1558 values.put(UIProvider.ConversationColumns.SEEN, 1);
1559
1560 context.getContentResolver().update(uri, values, null, null);
1561 }
1562
1563 /**
1564 * Returns a displayable string representing
1565 * the message sender. It has a preference toward showing the name,
1566 * but will fall back to the address if that is all that is available.
1567 */
1568 private static String getDisplayableSender(String sender) {
1569 final EmailAddress address = EmailAddress.getEmailAddress(sender);
1570
1571 String displayableSender = address.getName();
1572 // If that fails, default to the sender address.
1573 if (TextUtils.isEmpty(displayableSender)) {
1574 displayableSender = address.getAddress();
1575 }
1576 // If we were unable to tokenize a name or address,
1577 // just use whatever was in the sender.
1578 if (TextUtils.isEmpty(displayableSender)) {
1579 displayableSender = sender;
1580 }
1581 return displayableSender;
1582 }
1583
1584 /**
1585 * Returns only the address portion of a message sender.
1586 */
1587 private static String getSenderAddress(String sender) {
1588 final EmailAddress address = EmailAddress.getEmailAddress(sender);
1589
1590 String tokenizedAddress = address.getAddress();
1591
1592 // If we were unable to tokenize a name or address,
1593 // just use whatever was in the sender.
1594 if (TextUtils.isEmpty(tokenizedAddress)) {
1595 tokenizedAddress = sender;
1596 }
1597 return tokenizedAddress;
1598 }
1599
1600 public static int getNotificationId(final String account, final Folder folder) {
1601 return 1 ^ account.hashCode() ^ folder.hashCode();
1602 }
1603
1604 private static class NotificationKey {
1605 public final Account account;
1606 public final Folder folder;
1607
1608 public NotificationKey(Account account, Folder folder) {
1609 this.account = account;
1610 this.folder = folder;
1611 }
1612
1613 @Override
1614 public boolean equals(Object other) {
1615 if (!(other instanceof NotificationKey)) {
1616 return false;
1617 }
1618 NotificationKey key = (NotificationKey) other;
1619 return account.equals(key.account) && folder.equals(key.folder);
1620 }
1621
1622 @Override
1623 public String toString() {
1624 return account.toString() + " " + folder.name;
1625 }
1626
1627 @Override
1628 public int hashCode() {
1629 final int accountHashCode = account.hashCode();
1630 final int folderHashCode = folder.hashCode();
1631 return accountHashCode ^ folderHashCode;
1632 }
1633 }
1634
1635 /**
1636 * Contains the logic for converting the contents of one HtmlTree into
1637 * plaintext.
1638 */
1639 public static class MailMessagePlainTextConverter extends HtmlTree.DefaultPlainTextConverter {
1640 // Strings for parsing html message bodies
1641 private static final String ELIDED_TEXT_ELEMENT_NAME = "div";
1642 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME = "class";
1643 private static final String ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE = "elided-text";
1644
1645 private static final HTML.Attribute ELIDED_TEXT_ATTRIBUTE =
1646 new HTML.Attribute(ELIDED_TEXT_ELEMENT_ATTRIBUTE_NAME, HTML.Attribute.NO_TYPE);
1647
1648 private static final HtmlDocument.Node ELIDED_TEXT_REPLACEMENT_NODE =
1649 HtmlDocument.createSelfTerminatingTag(HTML4.BR_ELEMENT, null, null, null);
1650
1651 private int mEndNodeElidedTextBlock = -1;
1652
1653 @Override
1654 public void addNode(HtmlDocument.Node n, int nodeNum, int endNum) {
1655 // If we are in the middle of an elided text block, don't add this node
1656 if (nodeNum < mEndNodeElidedTextBlock) {
1657 return;
1658 } else if (nodeNum == mEndNodeElidedTextBlock) {
1659 super.addNode(ELIDED_TEXT_REPLACEMENT_NODE, nodeNum, endNum);
1660 return;
1661 }
1662
1663 // If this tag starts another elided text block, we want to remember the end
1664 if (n instanceof HtmlDocument.Tag) {
1665 boolean foundElidedTextTag = false;
1666 final HtmlDocument.Tag htmlTag = (HtmlDocument.Tag)n;
1667 final HTML.Element htmlElement = htmlTag.getElement();
1668 if (ELIDED_TEXT_ELEMENT_NAME.equals(htmlElement.getName())) {
1669 // Make sure that the class is what is expected
1670 final List<HtmlDocument.TagAttribute> attributes =
1671 htmlTag.getAttributes(ELIDED_TEXT_ATTRIBUTE);
1672 for (HtmlDocument.TagAttribute attribute : attributes) {
1673 if (ELIDED_TEXT_ELEMENT_ATTRIBUTE_CLASS_VALUE.equals(
1674 attribute.getValue())) {
1675 // Found an "elided-text" div. Remember information about this tag
1676 mEndNodeElidedTextBlock = endNum;
1677 foundElidedTextTag = true;
1678 break;
1679 }
1680 }
1681 }
1682
1683 if (foundElidedTextTag) {
1684 return;
1685 }
1686 }
1687
1688 super.addNode(n, nodeNum, endNum);
1689 }
1690 }
1691
1692 /**
1693 * During account setup in Email, we may not have an inbox yet, so the notification setting had
1694 * to be stored in {@link AccountPreferences}. If it is still there, we need to move it to the
1695 * {@link FolderPreferences} now.
1696 */
1697 public static void moveNotificationSetting(final AccountPreferences accountPreferences,
1698 final FolderPreferences folderPreferences) {
1699 if (accountPreferences.isDefaultInboxNotificationsEnabledSet()) {
1700 // If this setting has been changed some other way, don't overwrite it
1701 if (!folderPreferences.isNotificationsEnabledSet()) {
1702 final boolean notificationsEnabled =
1703 accountPreferences.getDefaultInboxNotificationsEnabled();
1704
1705 folderPreferences.setNotificationsEnabled(notificationsEnabled);
1706 }
1707
1708 accountPreferences.clearDefaultInboxNotificationsEnabled();
1709 }
1710 }
1711}