1/*
2 * Copyright (C) 2012 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.AlarmManager;
19import android.app.Notification;
20import android.app.NotificationManager;
21import android.app.PendingIntent;
22import android.content.ContentResolver;
23import android.content.ContentValues;
24import android.content.Context;
25import android.content.Intent;
26import android.database.DataSetObserver;
27import android.net.Uri;
28import android.os.Parcel;
29import android.os.Parcelable;
30import android.os.SystemClock;
31import android.support.v4.app.NotificationCompat;
32import android.support.v4.app.RemoteInput;
33import android.support.v4.app.TaskStackBuilder;
34import android.widget.RemoteViews;
35
36import com.android.mail.MailIntentService;
37import com.android.mail.NotificationActionIntentService;
38import com.android.mail.R;
39import com.android.mail.compose.ComposeActivity;
40import com.android.mail.providers.Account;
41import com.android.mail.providers.Conversation;
42import com.android.mail.providers.Folder;
43import com.android.mail.providers.Message;
44import com.android.mail.providers.UIProvider;
45import com.android.mail.providers.UIProvider.ConversationOperations;
46import com.google.common.collect.ImmutableMap;
47import com.google.common.collect.Sets;
48
49import java.util.ArrayList;
50import java.util.Collection;
51import java.util.List;
52import java.util.Map;
53import java.util.Set;
54
55public class NotificationActionUtils {
56    private static final String LOG_TAG = "NotifActionUtils";
57
58    public static final String WEAR_REPLY_INPUT = "wear_reply";
59
60    private static long sUndoTimeoutMillis = -1;
61
62    /**
63     * If an {@link NotificationAction} exists here for a given notification key, then we should
64     * display this undo notification rather than an email notification.
65     */
66    public static final ObservableSparseArrayCompat<NotificationAction> sUndoNotifications =
67            new ObservableSparseArrayCompat<NotificationAction>();
68
69    /**
70     * If a {@link Conversation} exists in this set, then the undo notification for this
71     * {@link Conversation} was tapped by the user in the notification drawer.
72     * We need to properly handle notification actions for this case.
73     */
74    public static final Set<Conversation> sUndoneConversations = Sets.newHashSet();
75
76    /**
77     * If an undo notification is displayed, its timestamp
78     * ({@link android.app.Notification.Builder#setWhen(long)}) is stored here so we can use it for
79     * the original notification if the action is undone.
80     */
81    public static final SparseLongArray sNotificationTimestamps = new SparseLongArray();
82
83    public enum NotificationActionType {
84        ARCHIVE_REMOVE_LABEL("archive", true, R.drawable.ic_archive_wht_24dp,
85                R.drawable.ic_remove_label_wht_24dp, R.string.notification_action_archive,
86                R.string.notification_action_remove_label, new ActionToggler() {
87            @Override
88            public boolean shouldDisplayPrimary(final Folder folder,
89                    final Conversation conversation, final Message message) {
90                return folder == null || folder.isInbox();
91            }
92        }),
93        DELETE("delete", true, R.drawable.ic_delete_wht_24dp,
94                R.string.notification_action_delete),
95        REPLY("reply", false, R.drawable.ic_reply_wht_24dp, R.string.notification_action_reply),
96        REPLY_ALL("reply_all", false, R.drawable.ic_reply_all_wht_24dp,
97                R.string.notification_action_reply_all);
98
99        private final String mPersistedValue;
100        private final boolean mIsDestructive;
101
102        private final int mActionIcon;
103        private final int mActionIcon2;
104
105        private final int mDisplayString;
106        private final int mDisplayString2;
107
108        private final ActionToggler mActionToggler;
109
110        private static final Map<String, NotificationActionType> sPersistedMapping;
111
112        private interface ActionToggler {
113            /**
114             * Determines if we should display the primary or secondary text/icon.
115             *
116             * @return <code>true</code> to display primary, <code>false</code> to display secondary
117             */
118            boolean shouldDisplayPrimary(Folder folder, Conversation conversation, Message message);
119        }
120
121        static {
122            final NotificationActionType[] values = values();
123            final ImmutableMap.Builder<String, NotificationActionType> mapBuilder =
124                    new ImmutableMap.Builder<String, NotificationActionType>();
125
126            for (int i = 0; i < values.length; i++) {
127                mapBuilder.put(values[i].getPersistedValue(), values[i]);
128            }
129
130            sPersistedMapping = mapBuilder.build();
131        }
132
133        private NotificationActionType(final String persistedValue, final boolean isDestructive,
134                final int actionIcon, final int displayString) {
135            mPersistedValue = persistedValue;
136            mIsDestructive = isDestructive;
137            mActionIcon = actionIcon;
138            mActionIcon2 = -1;
139            mDisplayString = displayString;
140            mDisplayString2 = -1;
141            mActionToggler = null;
142        }
143
144        private NotificationActionType(final String persistedValue, final boolean isDestructive,
145                final int actionIcon, final int actionIcon2, final int displayString,
146                final int displayString2, final ActionToggler actionToggler) {
147            mPersistedValue = persistedValue;
148            mIsDestructive = isDestructive;
149            mActionIcon = actionIcon;
150            mActionIcon2 = actionIcon2;
151            mDisplayString = displayString;
152            mDisplayString2 = displayString2;
153            mActionToggler = actionToggler;
154        }
155
156        public static NotificationActionType getActionType(final String persistedValue) {
157            return sPersistedMapping.get(persistedValue);
158        }
159
160        public String getPersistedValue() {
161            return mPersistedValue;
162        }
163
164        public boolean getIsDestructive() {
165            return mIsDestructive;
166        }
167
168        public int getActionIconResId(final Folder folder, final Conversation conversation,
169                final Message message) {
170            if (mActionToggler == null || mActionToggler.shouldDisplayPrimary(folder, conversation,
171                    message)) {
172                return mActionIcon;
173            }
174
175            return mActionIcon2;
176        }
177
178        public int getDisplayStringResId(final Folder folder, final Conversation conversation,
179                final Message message) {
180            if (mActionToggler == null || mActionToggler.shouldDisplayPrimary(folder, conversation,
181                    message)) {
182                return mDisplayString;
183            }
184
185            return mDisplayString2;
186        }
187    }
188
189    /**
190     * Adds the appropriate notification actions to the specified
191     * {@link android.support.v4.app.NotificationCompat.Builder}
192     *
193     * @param notificationIntent The {@link Intent} used when the notification is clicked
194     * @param when The value passed into {@link android.app.Notification.Builder#setWhen(long)}.
195     *        This is used for maintaining notification ordering with the undo bar
196     * @param notificationActions A {@link Set} set of the actions to display
197     */
198    public static void addNotificationActions(final Context context,
199            final Intent notificationIntent, final NotificationCompat.Builder notification,
200            NotificationCompat.WearableExtender wearExtender, final Account account,
201            final Conversation conversation, final Message message,
202            final Folder folder, final int notificationId, final long when,
203            final Set<String> notificationActions) {
204        final List<NotificationActionType> sortedActions =
205                getSortedNotificationActions(folder, notificationActions);
206
207        for (final NotificationActionType notificationAction : sortedActions) {
208            final PendingIntent pendingIntent = getNotificationActionPendingIntent(
209                    context, account, conversation, message,
210                    folder, notificationIntent, notificationAction, notificationId, when);
211            final int actionIconResId = notificationAction.getActionIconResId(folder, conversation,
212                    message);
213            final String title = context.getString(notificationAction.getDisplayStringResId(
214                    folder, conversation, message));
215
216            // Always add all actions to both standard and wearable notifications.
217            notification.addAction(actionIconResId, title, pendingIntent);
218
219            // Use a different intent for wear because it triggers different set of behavior:
220            // no undo for archive/delete, and mark conversation as read after reply.
221            final PendingIntent wearPendingIntent = getWearNotificationActionPendingIntent(
222                    context, account, conversation, message,
223                    folder, notificationIntent, notificationAction, notificationId, when);
224
225            final NotificationCompat.Action.Builder wearableActionBuilder =
226                    new NotificationCompat.Action.Builder(
227                            mapWearActionResId(notificationAction, actionIconResId), title,
228                            wearPendingIntent);
229
230            if (notificationAction == NotificationActionType.REPLY
231                    || notificationAction == NotificationActionType.REPLY_ALL) {
232                final String[] choices = context.getResources().getStringArray(
233                        R.array.reply_choices);
234                wearableActionBuilder.addRemoteInput(
235                        new RemoteInput.Builder(WEAR_REPLY_INPUT)
236                                .setLabel(title)
237                                .setChoices(choices)
238                                .build());
239            }
240
241            wearExtender.addAction(wearableActionBuilder.build());
242            LogUtils.d(LOG_TAG, "Adding wearable action!!");
243        }
244    }
245
246    private static int mapWearActionResId(NotificationActionType notificationAction,
247            int defActionIconResId) {
248        switch (notificationAction) {
249            case REPLY:
250                return R.drawable.ic_wear_full_reply;
251            case REPLY_ALL:
252                return R.drawable.ic_wear_full_reply_all;
253            case ARCHIVE_REMOVE_LABEL:
254                return R.drawable.ic_wear_full_archive;
255            case DELETE:
256                return R.drawable.ic_wear_full_delete;
257            default:
258                return defActionIconResId;
259        }
260    }
261
262    /**
263     * Sorts the notification actions into the appropriate order, based on current label
264     *
265     * @param folder The {@link Folder} being notified
266     * @param notificationActionStrings The action strings to sort
267     */
268    private static List<NotificationActionType> getSortedNotificationActions(
269            final Folder folder, final Collection<String> notificationActionStrings) {
270        final List<NotificationActionType> unsortedActions =
271                new ArrayList<NotificationActionType>(notificationActionStrings.size());
272        for (final String action : notificationActionStrings) {
273            unsortedActions.add(NotificationActionType.getActionType(action));
274        }
275
276        final List<NotificationActionType> sortedActions =
277                new ArrayList<NotificationActionType>(unsortedActions.size());
278
279        if (folder.isInbox()) {
280            // Inbox
281            /*
282             * Action 1: Archive, Delete, Mute, Mark read, Add star, Mark important, Reply, Reply
283             * all, Forward
284             */
285            /*
286             * Action 2: Reply, Reply all, Forward, Mark important, Add star, Mark read, Mute,
287             * Delete, Archive
288             */
289            if (unsortedActions.contains(NotificationActionType.ARCHIVE_REMOVE_LABEL)) {
290                sortedActions.add(NotificationActionType.ARCHIVE_REMOVE_LABEL);
291            }
292            if (unsortedActions.contains(NotificationActionType.DELETE)) {
293                sortedActions.add(NotificationActionType.DELETE);
294            }
295            if (unsortedActions.contains(NotificationActionType.REPLY)) {
296                sortedActions.add(NotificationActionType.REPLY);
297            }
298            if (unsortedActions.contains(NotificationActionType.REPLY_ALL)) {
299                sortedActions.add(NotificationActionType.REPLY_ALL);
300            }
301        } else if (folder.isProviderFolder()) {
302            // Gmail system labels
303            /*
304             * Action 1: Delete, Mute, Mark read, Add star, Mark important, Reply, Reply all,
305             * Forward
306             */
307            /*
308             * Action 2: Reply, Reply all, Forward, Mark important, Add star, Mark read, Mute,
309             * Delete
310             */
311            if (unsortedActions.contains(NotificationActionType.DELETE)) {
312                sortedActions.add(NotificationActionType.DELETE);
313            }
314            if (unsortedActions.contains(NotificationActionType.REPLY)) {
315                sortedActions.add(NotificationActionType.REPLY);
316            }
317            if (unsortedActions.contains(NotificationActionType.REPLY_ALL)) {
318                sortedActions.add(NotificationActionType.REPLY_ALL);
319            }
320        } else {
321            // Gmail user created labels
322            /*
323             * Action 1: Remove label, Delete, Mark read, Add star, Mark important, Reply, Reply
324             * all, Forward
325             */
326            /*
327             * Action 2: Reply, Reply all, Forward, Mark important, Add star, Mark read, Delete
328             */
329            if (unsortedActions.contains(NotificationActionType.ARCHIVE_REMOVE_LABEL)) {
330                sortedActions.add(NotificationActionType.ARCHIVE_REMOVE_LABEL);
331            }
332            if (unsortedActions.contains(NotificationActionType.DELETE)) {
333                sortedActions.add(NotificationActionType.DELETE);
334            }
335            if (unsortedActions.contains(NotificationActionType.REPLY)) {
336                sortedActions.add(NotificationActionType.REPLY);
337            }
338            if (unsortedActions.contains(NotificationActionType.REPLY_ALL)) {
339                sortedActions.add(NotificationActionType.REPLY_ALL);
340            }
341        }
342
343        return sortedActions;
344    }
345
346    /**
347     * Creates a {@link PendingIntent} for the specified notification action.
348     */
349    private static PendingIntent getNotificationActionPendingIntent(final Context context,
350            final Account account, final Conversation conversation, final Message message,
351            final Folder folder, final Intent notificationIntent,
352            final NotificationActionType action, final int notificationId, final long when) {
353        final Uri messageUri = message.uri;
354
355        final NotificationAction notificationAction = new NotificationAction(action, account,
356                conversation, message, folder, conversation.id, message.serverId, message.id, when,
357                NotificationAction.SOURCE_LOCAL, notificationId);
358
359        switch (action) {
360            case REPLY: {
361                // Build a task stack that forces the conversation view on the stack before the
362                // reply activity.
363                final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context);
364
365                final Intent intent = createReplyIntent(context, account, messageUri, false);
366                intent.setPackage(context.getPackageName());
367                intent.setData(conversation.uri);
368                intent.putExtra(ComposeActivity.EXTRA_NOTIFICATION_FOLDER, folder);
369
370                taskStackBuilder.addNextIntent(notificationIntent).addNextIntent(intent);
371
372                return taskStackBuilder.getPendingIntent(
373                        notificationId, PendingIntent.FLAG_UPDATE_CURRENT);
374            } case REPLY_ALL: {
375                // Build a task stack that forces the conversation view on the stack before the
376                // reply activity.
377                final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context);
378
379                final Intent intent = createReplyIntent(context, account, messageUri, true);
380                intent.setPackage(context.getPackageName());
381                intent.setData(conversation.uri);
382                intent.putExtra(ComposeActivity.EXTRA_NOTIFICATION_FOLDER, folder);
383
384                taskStackBuilder.addNextIntent(notificationIntent).addNextIntent(intent);
385
386                return taskStackBuilder.getPendingIntent(
387                        notificationId, PendingIntent.FLAG_UPDATE_CURRENT);
388            } case ARCHIVE_REMOVE_LABEL: {
389                final String intentAction =
390                        NotificationActionIntentService.ACTION_ARCHIVE_REMOVE_LABEL;
391
392                final Intent intent = new Intent(intentAction);
393                intent.setPackage(context.getPackageName());
394                intent.setData(conversation.uri);
395                putNotificationActionExtra(intent, notificationAction);
396
397                return PendingIntent.getService(
398                        context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
399            } case DELETE: {
400                final String intentAction = NotificationActionIntentService.ACTION_DELETE;
401
402                final Intent intent = new Intent(intentAction);
403                intent.setPackage(context.getPackageName());
404                intent.setData(conversation.uri);
405                putNotificationActionExtra(intent, notificationAction);
406
407                return PendingIntent.getService(
408                        context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
409            }
410        }
411
412        throw new IllegalArgumentException("Invalid NotificationActionType");
413    }
414
415    /**
416     * Creates a {@link PendingIntent} for the specified Wear notification action.
417     */
418    private static PendingIntent getWearNotificationActionPendingIntent(final Context context,
419            final Account account, final Conversation conversation, final Message message,
420            final Folder folder, final Intent notificationIntent,
421            final NotificationActionType action, final int notificationId, final long when) {
422        final Uri messageUri = message.uri;
423
424        final NotificationAction notificationAction = new NotificationAction(action, account,
425                conversation, message, folder, conversation.id, message.serverId, message.id, when,
426                NotificationAction.SOURCE_REMOTE, notificationId);
427
428        switch (action) {
429            case REPLY:
430            case REPLY_ALL: {
431                // Build a task stack that forces the conversation view on the stack before the
432                // reply activity.
433                final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context);
434
435                final Intent intent = createReplyIntent(context, account, messageUri,
436                        (action == NotificationActionType.REPLY_ALL));
437                intent.setPackage(context.getPackageName());
438                intent.setData(buildWearUri(conversation.uri));
439                intent.putExtra(ComposeActivity.EXTRA_NOTIFICATION_FOLDER, folder);
440                intent.putExtra(ComposeActivity.EXTRA_NOTIFICATION_CONVERSATION, conversation.uri);
441
442                taskStackBuilder.addNextIntent(notificationIntent).addNextIntent(intent);
443
444                return taskStackBuilder.getPendingIntent(notificationId,
445                        PendingIntent.FLAG_UPDATE_CURRENT);
446            }
447            case ARCHIVE_REMOVE_LABEL:
448            case DELETE: {
449                final String intentAction = (action == NotificationActionType.ARCHIVE_REMOVE_LABEL)
450                        ? NotificationActionIntentService.ACTION_ARCHIVE_REMOVE_LABEL
451                        : NotificationActionIntentService.ACTION_DELETE;
452
453                final Intent intent = new Intent(intentAction);
454                intent.setPackage(context.getPackageName());
455                intent.setData(buildWearUri(conversation.uri));
456                putNotificationActionExtra(intent, notificationAction);
457
458                return PendingIntent.getService(context, notificationId, intent,
459                        PendingIntent.FLAG_UPDATE_CURRENT);
460            }
461        }
462
463        throw new IllegalArgumentException("Invalid NotificationActionType");
464    }
465
466    private static Uri buildWearUri(Uri uri) {
467        return uri.buildUpon().appendQueryParameter("type", "wear").build();
468    }
469
470    /**
471     * @return an intent which, if launched, will reply to the conversation
472     */
473    public static Intent createReplyIntent(final Context context, final Account account,
474            final Uri messageUri, final boolean isReplyAll) {
475        final Intent intent = ComposeActivity.createReplyIntent(context, account, messageUri,
476                isReplyAll);
477        intent.putExtra(Utils.EXTRA_FROM_NOTIFICATION, true);
478        return intent;
479    }
480
481    public static class NotificationAction implements Parcelable {
482        public static final int SOURCE_LOCAL = 0;
483        public static final int SOURCE_REMOTE = 1;
484
485        private final NotificationActionType mNotificationActionType;
486        private final Account mAccount;
487        private final Conversation mConversation;
488        private final Message mMessage;
489        private final Folder mFolder;
490        private final long mConversationId;
491        private final String mMessageId;
492        private final long mLocalMessageId;
493        private final long mWhen;
494        private final int mSource;
495        private final int mNotificationId;
496
497        public NotificationAction(final NotificationActionType notificationActionType,
498                final Account account, final Conversation conversation, final Message message,
499                final Folder folder, final long conversationId, final String messageId,
500                final long localMessageId, final long when, final int source,
501                final int notificationId) {
502            mNotificationActionType = notificationActionType;
503            mAccount = account;
504            mConversation = conversation;
505            mMessage = message;
506            mFolder = folder;
507            mConversationId = conversationId;
508            mMessageId = messageId;
509            mLocalMessageId = localMessageId;
510            mWhen = when;
511            mSource = source;
512            mNotificationId = notificationId;
513        }
514
515        public NotificationActionType getNotificationActionType() {
516            return mNotificationActionType;
517        }
518
519        public Account getAccount() {
520            return mAccount;
521        }
522
523        public Conversation getConversation() {
524            return mConversation;
525        }
526
527        public Message getMessage() {
528            return mMessage;
529        }
530
531        public Folder getFolder() {
532            return mFolder;
533        }
534
535        public long getConversationId() {
536            return mConversationId;
537        }
538
539        public String getMessageId() {
540            return mMessageId;
541        }
542
543        public long getLocalMessageId() {
544            return mLocalMessageId;
545        }
546
547        public long getWhen() {
548            return mWhen;
549        }
550
551        public int getSource() {
552            return mSource;
553        }
554
555        public int getNotificationId() {
556            return mNotificationId;
557        }
558
559        public int getActionTextResId() {
560            switch (mNotificationActionType) {
561                case ARCHIVE_REMOVE_LABEL:
562                    if (mFolder.isInbox()) {
563                        return R.string.notification_action_undo_archive;
564                    } else {
565                        return R.string.notification_action_undo_remove_label;
566                    }
567                case DELETE:
568                    return R.string.notification_action_undo_delete;
569                default:
570                    throw new IllegalStateException(
571                            "There is no action text for this NotificationActionType.");
572            }
573        }
574
575        @Override
576        public int describeContents() {
577            return 0;
578        }
579
580        @Override
581        public void writeToParcel(final Parcel out, final int flags) {
582            out.writeInt(mNotificationActionType.ordinal());
583            out.writeParcelable(mAccount, 0);
584            out.writeParcelable(mConversation, 0);
585            out.writeParcelable(mMessage, 0);
586            out.writeParcelable(mFolder, 0);
587            out.writeLong(mConversationId);
588            out.writeString(mMessageId);
589            out.writeLong(mLocalMessageId);
590            out.writeLong(mWhen);
591            out.writeInt(mSource);
592            out.writeInt(mNotificationId);
593        }
594
595        public static final Parcelable.ClassLoaderCreator<NotificationAction> CREATOR =
596                new Parcelable.ClassLoaderCreator<NotificationAction>() {
597                    @Override
598                    public NotificationAction createFromParcel(final Parcel in) {
599                        return new NotificationAction(in, null);
600                    }
601
602                    @Override
603                    public NotificationAction[] newArray(final int size) {
604                        return new NotificationAction[size];
605                    }
606
607                    @Override
608                    public NotificationAction createFromParcel(
609                            final Parcel in, final ClassLoader loader) {
610                        return new NotificationAction(in, loader);
611                    }
612                };
613
614        private NotificationAction(final Parcel in, final ClassLoader loader) {
615            mNotificationActionType = NotificationActionType.values()[in.readInt()];
616            mAccount = in.readParcelable(loader);
617            mConversation = in.readParcelable(loader);
618            mMessage = in.readParcelable(loader);
619            mFolder = in.readParcelable(loader);
620            mConversationId = in.readLong();
621            mMessageId = in.readString();
622            mLocalMessageId = in.readLong();
623            mWhen = in.readLong();
624            mSource = in.readInt();
625            mNotificationId = in.readInt();
626        }
627    }
628
629    public static Notification createUndoNotification(final Context context,
630            final NotificationAction notificationAction, final int notificationId) {
631        LogUtils.i(LOG_TAG, "createUndoNotification for %s",
632                notificationAction.getNotificationActionType());
633
634        final NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
635
636        builder.setSmallIcon(R.drawable.ic_notification_mail_24dp);
637        builder.setWhen(notificationAction.getWhen());
638        builder.setCategory(NotificationCompat.CATEGORY_EMAIL);
639
640        final RemoteViews undoView =
641                new RemoteViews(context.getPackageName(), R.layout.undo_notification);
642        undoView.setTextViewText(
643                R.id.description_text, context.getString(notificationAction.getActionTextResId()));
644
645        final String packageName = context.getPackageName();
646
647        final Intent clickIntent = new Intent(NotificationActionIntentService.ACTION_UNDO);
648        clickIntent.setPackage(packageName);
649        clickIntent.setData(notificationAction.mConversation.uri);
650        putNotificationActionExtra(clickIntent, notificationAction);
651        final PendingIntent clickPendingIntent = PendingIntent.getService(context, notificationId,
652                clickIntent, PendingIntent.FLAG_CANCEL_CURRENT);
653
654        undoView.setOnClickPendingIntent(R.id.status_bar_latest_event_content, clickPendingIntent);
655
656        builder.setContent(undoView);
657
658        // When the notification is cleared, we perform the destructive action
659        final Intent deleteIntent = new Intent(NotificationActionIntentService.ACTION_DESTRUCT);
660        deleteIntent.setPackage(packageName);
661        deleteIntent.setData(notificationAction.mConversation.uri);
662        putNotificationActionExtra(deleteIntent, notificationAction);
663        final PendingIntent deletePendingIntent = PendingIntent.getService(context,
664                notificationId, deleteIntent, PendingIntent.FLAG_CANCEL_CURRENT);
665        builder.setDeleteIntent(deletePendingIntent);
666
667        final Notification notification = builder.build();
668
669        return notification;
670    }
671
672    /**
673     * Registers a timeout for the undo notification such that when it expires, the undo bar will
674     * disappear, and the action will be performed.
675     */
676    public static void registerUndoTimeout(
677            final Context context, final NotificationAction notificationAction) {
678        LogUtils.i(LOG_TAG, "registerUndoTimeout for %s",
679                notificationAction.getNotificationActionType());
680
681        if (sUndoTimeoutMillis == -1) {
682            sUndoTimeoutMillis =
683                    context.getResources().getInteger(R.integer.undo_notification_timeout);
684        }
685
686        final AlarmManager alarmManager =
687                (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
688
689        final long triggerAtMills = SystemClock.elapsedRealtime() + sUndoTimeoutMillis;
690
691        final PendingIntent pendingIntent =
692                createUndoTimeoutPendingIntent(context, notificationAction);
693
694        alarmManager.set(AlarmManager.ELAPSED_REALTIME, triggerAtMills, pendingIntent);
695    }
696
697    /**
698     * Cancels the undo timeout for a notification action. This should be called if the undo
699     * notification is clicked (to prevent the action from being performed anyway) or cleared (since
700     * we have already performed the action).
701     */
702    public static void cancelUndoTimeout(
703            final Context context, final NotificationAction notificationAction) {
704        LogUtils.i(LOG_TAG, "cancelUndoTimeout for %s",
705                notificationAction.getNotificationActionType());
706
707        final AlarmManager alarmManager =
708                (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
709
710        final PendingIntent pendingIntent =
711                createUndoTimeoutPendingIntent(context, notificationAction);
712
713        alarmManager.cancel(pendingIntent);
714    }
715
716    /**
717     * Creates a {@link PendingIntent} to be used for creating and canceling the undo timeout
718     * alarm.
719     */
720    private static PendingIntent createUndoTimeoutPendingIntent(
721            final Context context, final NotificationAction notificationAction) {
722        final Intent intent = new Intent(NotificationActionIntentService.ACTION_UNDO_TIMEOUT);
723        intent.setPackage(context.getPackageName());
724        intent.setData(notificationAction.mConversation.uri);
725        putNotificationActionExtra(intent, notificationAction);
726
727        final int requestCode = notificationAction.getAccount().hashCode()
728                ^ notificationAction.getFolder().hashCode();
729        final PendingIntent pendingIntent =
730                PendingIntent.getService(context, requestCode, intent, 0);
731
732        return pendingIntent;
733    }
734
735    /**
736     * Processes the specified destructive action (archive, delete, mute) on the message.
737     */
738    public static void processDestructiveAction(
739            final Context context, final NotificationAction notificationAction) {
740        LogUtils.i(LOG_TAG, "processDestructiveAction: %s",
741                notificationAction.getNotificationActionType());
742
743        final NotificationActionType destructAction =
744                notificationAction.getNotificationActionType();
745        final Conversation conversation = notificationAction.getConversation();
746        final Folder folder = notificationAction.getFolder();
747
748        final ContentResolver contentResolver = context.getContentResolver();
749        final Uri uri = conversation.uri.buildUpon().appendQueryParameter(
750                UIProvider.FORCE_UI_NOTIFICATIONS_QUERY_PARAMETER, Boolean.TRUE.toString()).build();
751
752        switch (destructAction) {
753            case ARCHIVE_REMOVE_LABEL: {
754                if (folder.isInbox()) {
755                    // Inbox, so archive
756                    final ContentValues values = new ContentValues(1);
757                    values.put(UIProvider.ConversationOperations.OPERATION_KEY,
758                            UIProvider.ConversationOperations.ARCHIVE);
759
760                    contentResolver.update(uri, values, null, null);
761                } else {
762                    // Not inbox, so remove label
763                    final ContentValues values = new ContentValues(1);
764
765                    final String removeFolderUri = folder.folderUri.fullUri.buildUpon()
766                            .appendPath(Boolean.FALSE.toString()).toString();
767                    values.put(ConversationOperations.FOLDERS_UPDATED, removeFolderUri);
768
769                    contentResolver.update(uri, values, null, null);
770                }
771                break;
772            }
773            case DELETE: {
774                contentResolver.delete(uri, null, null);
775                break;
776            }
777            default:
778                throw new IllegalArgumentException(
779                        "The specified NotificationActionType is not a destructive action.");
780        }
781    }
782
783    /**
784     * Creates and displays an Undo notification for the specified {@link NotificationAction}.
785     */
786    public static void createUndoNotification(final Context context,
787            final NotificationAction notificationAction) {
788        LogUtils.i(LOG_TAG, "createUndoNotification for %s",
789                notificationAction.getNotificationActionType());
790
791        final int notificationId = NotificationUtils.getNotificationId(
792                notificationAction.getAccount().getAccountManagerAccount(),
793                notificationAction.getFolder());
794
795        final Notification notification =
796                createUndoNotification(context, notificationAction, notificationId);
797
798        final NotificationManager notificationManager =
799                (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
800        notificationManager.notify(notificationId, notification);
801
802        sUndoNotifications.put(notificationId, notificationAction);
803        sNotificationTimestamps.put(notificationId, notificationAction.getWhen());
804    }
805
806    /**
807     * Called when an Undo notification has been tapped.
808     */
809    public static void cancelUndoNotification(final Context context,
810            final NotificationAction notificationAction) {
811        LogUtils.i(LOG_TAG, "cancelUndoNotification for %s",
812                notificationAction.getNotificationActionType());
813
814        final Account account = notificationAction.getAccount();
815        final Folder folder = notificationAction.getFolder();
816        final Conversation conversation = notificationAction.getConversation();
817        final int notificationId =
818                NotificationUtils.getNotificationId(account.getAccountManagerAccount(), folder);
819
820        // Note: we must add the conversation before removing the undo notification
821        // Otherwise, the observer for sUndoNotifications gets called, which calls
822        // handleNotificationActions before the undone conversation has been added to the set.
823        sUndoneConversations.add(conversation);
824        removeUndoNotification(context, notificationId, false);
825        resendNotifications(context, account, folder);
826    }
827
828    /**
829     * If an undo notification is left alone for a long enough time, it will disappear, this method
830     * will be called, and the action will be finalized.
831     */
832    public static void processUndoNotification(final Context context,
833            final NotificationAction notificationAction) {
834        LogUtils.i(LOG_TAG, "processUndoNotification, %s",
835                notificationAction.getNotificationActionType());
836
837        final Account account = notificationAction.getAccount();
838        final Folder folder = notificationAction.getFolder();
839        final int notificationId = NotificationUtils.getNotificationId(
840                account.getAccountManagerAccount(), folder);
841        removeUndoNotification(context, notificationId, true);
842        sNotificationTimestamps.delete(notificationId);
843        processDestructiveAction(context, notificationAction);
844    }
845
846    /**
847     * Removes the undo notification.
848     *
849     * @param removeNow <code>true</code> to remove it from the drawer right away,
850     *        <code>false</code> to just remove the reference to it
851     */
852    private static void removeUndoNotification(
853            final Context context, final int notificationId, final boolean removeNow) {
854        sUndoNotifications.delete(notificationId);
855
856        if (removeNow) {
857            final NotificationManager notificationManager =
858                    (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
859            notificationManager.cancel(notificationId);
860        }
861    }
862
863    /**
864     * Broadcasts an {@link Intent} to inform the app to resend its notifications.
865     */
866    public static void resendNotifications(final Context context, final Account account,
867            final Folder folder) {
868        LogUtils.i(LOG_TAG, "resendNotifications account: %s, folder: %s",
869                account == null ? null : LogUtils.sanitizeName(LOG_TAG, account.getEmailAddress()),
870                folder == null ? null : LogUtils.sanitizeName(LOG_TAG, folder.name));
871
872        final Intent intent = new Intent(MailIntentService.ACTION_RESEND_NOTIFICATIONS);
873        intent.setPackage(context.getPackageName()); // Make sure we only deliver this to ourselves
874        if (account != null) {
875            intent.putExtra(Utils.EXTRA_ACCOUNT_URI, account.uri);
876        }
877        if (folder != null) {
878            intent.putExtra(Utils.EXTRA_FOLDER_URI, folder.folderUri.fullUri);
879        }
880        context.startService(intent);
881    }
882
883    public static void registerUndoNotificationObserver(final DataSetObserver observer) {
884        sUndoNotifications.getDataSetObservable().registerObserver(observer);
885    }
886
887    public static void unregisterUndoNotificationObserver(final DataSetObserver observer) {
888        sUndoNotifications.getDataSetObservable().unregisterObserver(observer);
889    }
890
891    /**
892     * <p>
893     * This is a slight hack to avoid an exception in the remote AlarmManagerService process. The
894     * AlarmManager adds extra data to this Intent which causes it to inflate. Since the remote
895     * process does not know about the NotificationAction class, it throws a ClassNotFoundException.
896     * </p>
897     * <p>
898     * To avoid this, we marshall the data ourselves and then parcel a plain byte[] array. The
899     * NotificationActionIntentService class knows to build the NotificationAction object from the
900     * byte[] array.
901     * </p>
902     */
903    private static void putNotificationActionExtra(final Intent intent,
904            final NotificationAction notificationAction) {
905        final Parcel out = Parcel.obtain();
906        notificationAction.writeToParcel(out, 0);
907        out.setDataPosition(0);
908        intent.putExtra(NotificationActionIntentService.EXTRA_NOTIFICATION_ACTION, out.marshall());
909    }
910}
911