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  */
16 package com.android.mail.utils;
17 
18 import android.app.AlarmManager;
19 import android.app.Notification;
20 import android.app.NotificationManager;
21 import android.app.PendingIntent;
22 import android.content.ContentResolver;
23 import android.content.ContentValues;
24 import android.content.Context;
25 import android.content.Intent;
26 import android.database.DataSetObserver;
27 import android.net.Uri;
28 import android.os.Parcel;
29 import android.os.Parcelable;
30 import android.os.SystemClock;
31 import android.support.v4.app.NotificationCompat;
32 import android.support.v4.app.RemoteInput;
33 import android.support.v4.app.TaskStackBuilder;
34 import android.widget.RemoteViews;
35 
36 import com.android.mail.MailIntentService;
37 import com.android.mail.NotificationActionIntentService;
38 import com.android.mail.R;
39 import com.android.mail.compose.ComposeActivity;
40 import com.android.mail.providers.Account;
41 import com.android.mail.providers.Conversation;
42 import com.android.mail.providers.Folder;
43 import com.android.mail.providers.Message;
44 import com.android.mail.providers.UIProvider;
45 import com.android.mail.providers.UIProvider.ConversationOperations;
46 import com.google.common.collect.ImmutableMap;
47 import com.google.common.collect.Sets;
48 
49 import java.util.ArrayList;
50 import java.util.Collection;
51 import java.util.List;
52 import java.util.Map;
53 import java.util.Set;
54 
55 public 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              */
shouldDisplayPrimary(Folder folder, Conversation conversation, Message message)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++) {
mapBuilder.put(values[i].getPersistedValue(), values[i])127                 mapBuilder.put(values[i].getPersistedValue(), values[i]);
128             }
129 
130             sPersistedMapping = mapBuilder.build();
131         }
132 
NotificationActionType(final String persistedValue, final boolean isDestructive, final int actionIcon, final int displayString)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 
NotificationActionType(final String persistedValue, final boolean isDestructive, final int actionIcon, final int actionIcon2, final int displayString, final int displayString2, final ActionToggler actionToggler)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 
getActionType(final String persistedValue)156         public static NotificationActionType getActionType(final String persistedValue) {
157             return sPersistedMapping.get(persistedValue);
158         }
159 
getPersistedValue()160         public String getPersistedValue() {
161             return mPersistedValue;
162         }
163 
getIsDestructive()164         public boolean getIsDestructive() {
165             return mIsDestructive;
166         }
167 
getActionIconResId(final Folder folder, final Conversation conversation, final Message message)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 
getDisplayStringResId(final Folder folder, final Conversation conversation, final Message message)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      */
addNotificationActions(final Context context, final Intent notificationIntent, final NotificationCompat.Builder notification, NotificationCompat.WearableExtender wearExtender, final Account account, final Conversation conversation, final Message message, final Folder folder, final int notificationId, final long when, final Set<String> notificationActions)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 
mapWearActionResId(NotificationActionType notificationAction, int defActionIconResId)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      */
getSortedNotificationActions( final Folder folder, final Collection<String> notificationActionStrings)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      */
getNotificationActionPendingIntent(final Context context, final Account account, final Conversation conversation, final Message message, final Folder folder, final Intent notificationIntent, final NotificationActionType action, final int notificationId, final long when)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      */
getWearNotificationActionPendingIntent(final Context context, final Account account, final Conversation conversation, final Message message, final Folder folder, final Intent notificationIntent, final NotificationActionType action, final int notificationId, final long when)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 
buildWearUri(Uri uri)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      */
createReplyIntent(final Context context, final Account account, final Uri messageUri, final boolean isReplyAll)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 
NotificationAction(final NotificationActionType notificationActionType, final Account account, final Conversation conversation, final Message message, final Folder folder, final long conversationId, final String messageId, final long localMessageId, final long when, final int source, final int notificationId)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 
getNotificationActionType()515         public NotificationActionType getNotificationActionType() {
516             return mNotificationActionType;
517         }
518 
getAccount()519         public Account getAccount() {
520             return mAccount;
521         }
522 
getConversation()523         public Conversation getConversation() {
524             return mConversation;
525         }
526 
getMessage()527         public Message getMessage() {
528             return mMessage;
529         }
530 
getFolder()531         public Folder getFolder() {
532             return mFolder;
533         }
534 
getConversationId()535         public long getConversationId() {
536             return mConversationId;
537         }
538 
getMessageId()539         public String getMessageId() {
540             return mMessageId;
541         }
542 
getLocalMessageId()543         public long getLocalMessageId() {
544             return mLocalMessageId;
545         }
546 
getWhen()547         public long getWhen() {
548             return mWhen;
549         }
550 
getSource()551         public int getSource() {
552             return mSource;
553         }
554 
getNotificationId()555         public int getNotificationId() {
556             return mNotificationId;
557         }
558 
getActionTextResId()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
describeContents()576         public int describeContents() {
577             return 0;
578         }
579 
580         @Override
writeToParcel(final Parcel out, final int flags)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 
NotificationAction(final Parcel in, final ClassLoader loader)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 
createUndoNotification(final Context context, final NotificationAction notificationAction, final int notificationId)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      */
registerUndoTimeout( final Context context, final NotificationAction notificationAction)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      */
cancelUndoTimeout( final Context context, final NotificationAction notificationAction)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      */
createUndoTimeoutPendingIntent( final Context context, final NotificationAction notificationAction)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      */
processDestructiveAction( final Context context, final NotificationAction notificationAction)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      */
createUndoNotification(final Context context, final NotificationAction notificationAction)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      */
cancelUndoNotification(final Context context, final NotificationAction notificationAction)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      */
processUndoNotification(final Context context, final NotificationAction notificationAction)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      */
removeUndoNotification( final Context context, final int notificationId, final boolean removeNow)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      */
resendNotifications(final Context context, final Account account, final Folder folder)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 
registerUndoNotificationObserver(final DataSetObserver observer)883     public static void registerUndoNotificationObserver(final DataSetObserver observer) {
884         sUndoNotifications.getDataSetObservable().registerObserver(observer);
885     }
886 
unregisterUndoNotificationObserver(final DataSetObserver observer)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      */
putNotificationActionExtra(final Intent intent, final NotificationAction notificationAction)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