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