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