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