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 androidx.core.app.NotificationCompat; 32 import androidx.core.app.RemoteInput; 33 import androidx.core.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 androidx.core.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