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