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