From f672590b2deeffb06435eb542cfe0d0630894e92 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 10 Apr 2025 05:37:22 -0700 Subject: [PATCH] Remember explicit notification dismissal Currently, a notification may be recreated even if a user dismissed it as long as the standard conditions for a notification are true. To avoid this effect, we plumb the dismissal event to the notification controller, so that it can override its `shouldShowNotification` decision. The plumbing sets an extra on the media key intent, which the session forwards as a custom event to the media notification controller if connected. Issue: androidx/media#2302 PiperOrigin-RevId: 745989590 --- RELEASENOTES.md | 2 + .../media3/session/DefaultActionFactory.java | 25 +++++- .../DefaultMediaNotificationProvider.java | 4 +- .../media3/session/MediaNotification.java | 23 +++++- .../session/MediaNotificationManager.java | 76 +++++++++++++------ .../media3/session/MediaSessionImpl.java | 22 +++++- 6 files changed, 116 insertions(+), 36 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 2ca50dc02b..b39f6c3fb2 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -66,6 +66,8 @@ * Lower aggregation timeout for platform `MediaSession` callbacks from 500 to 100 milliseconds and add an experimental setter to allow apps to configure this value. + * Fix issue where notifications reappear after they have been dismissed by + the user ([#2302](https://github.com/androidx/media/issues/2302)). * UI: * Enable `PlayerSurface` to work with `ExoPlayer.setVideoEffects` and `CompositionPlayer`. diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java index 0762fb4c19..c15d13e40c 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java @@ -113,10 +113,7 @@ import androidx.media3.common.util.Util; public PendingIntent createMediaActionPendingIntent( MediaSession mediaSession, @Player.Command long command) { int keyCode = toKeyCode(command); - Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); - intent.setData(mediaSession.getImpl().getUri()); - intent.setComponent(new ComponentName(service, service.getClass())); - intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + Intent intent = getMediaButtonIntent(mediaSession, keyCode); if (Util.SDK_INT >= 26 && command == COMMAND_PLAY_PAUSE && !mediaSession.getPlayer().getPlayWhenReady()) { @@ -130,6 +127,26 @@ import androidx.media3.common.util.Util; } } + @Override + public PendingIntent createNotificationDismissalIntent(MediaSession mediaSession) { + Intent intent = + getMediaButtonIntent(mediaSession, KEYCODE_MEDIA_STOP) + .putExtra(MediaNotification.NOTIFICATION_DISMISSED_EVENT_KEY, true); + return PendingIntent.getService( + service, + /* requestCode= */ KEYCODE_MEDIA_STOP, + intent, + Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); + } + + private Intent getMediaButtonIntent(MediaSession mediaSession, int mediaKeyCode) { + Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); + intent.setData(mediaSession.getImpl().getUri()); + intent.setComponent(new ComponentName(service, service.getClass())); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, mediaKeyCode)); + return intent; + } + private int toKeyCode(@Player.Command long action) { if (action == COMMAND_SEEK_TO_NEXT_MEDIA_ITEM || action == COMMAND_SEEK_TO_NEXT) { return KEYCODE_MEDIA_NEXT; diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index 6a46667874..20781eca77 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -22,7 +22,6 @@ import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT; import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS; import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM; -import static androidx.media3.common.Player.COMMAND_STOP; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.common.util.Assertions.checkStateNotNull; @@ -379,8 +378,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi Notification notification = builder .setContentIntent(mediaSession.getSessionActivity()) - .setDeleteIntent( - actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP)) + .setDeleteIntent(actionFactory.createNotificationDismissalIntent(mediaSession)) .setOnlyAlertOnce(true) .setSmallIcon(smallIconResourceId) .setStyle(mediaStyle) diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java index 847abd4400..ee0b4a0c8f 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java @@ -31,6 +31,17 @@ import com.google.common.collect.ImmutableList; /** A notification for media playbacks. */ public final class MediaNotification { + /** + * Event key to indicate a media notification was dismissed. + * + *

This event key can be used as an extras key for a boolean extra on a media button pending + * intent, and as as custom session command action to inform the media notification controller + * that a notification was dismissed. + */ + @UnstableApi + public static final String NOTIFICATION_DISMISSED_EVENT_KEY = + "androidx.media3.session.NOTIFICATION_DISMISSED_EVENT_KEY"; + /** * Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending * intents} for notifications. @@ -99,10 +110,20 @@ public final class MediaNotification { * Creates a {@link PendingIntent} for a media action that will be handled by the library. * * @param mediaSession The media session to which the action will be sent. - * @param command The intent's command. + * @param command The {@link PendingIntent}. */ PendingIntent createMediaActionPendingIntent( MediaSession mediaSession, @Player.Command long command); + + /** + * Creates a {@link PendingIntent} triggered when the notification is dismissed. + * + * @param mediaSession The media session for which the intent is created. + * @return The {@link PendingIntent}. + */ + default PendingIntent createNotificationDismissalIntent(MediaSession mediaSession) { + return createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP); + } } /** diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java index 0845d662cd..f624700f68 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -17,6 +17,7 @@ package androidx.media3.session; import static android.app.Service.STOP_FOREGROUND_DETACH; import static android.app.Service.STOP_FOREGROUND_REMOVE; +import static androidx.media3.common.util.Assertions.checkNotNull; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.annotation.SuppressLint; @@ -65,7 +66,7 @@ import java.util.concurrent.TimeoutException; private final Handler mainHandler; private final Executor mainExecutor; private final Intent startSelfIntent; - private final Map> controllerMap; + private final Map controllerMap; private int totalNotificationCount; @Nullable private MediaNotification mediaNotification; @@ -104,7 +105,7 @@ import java.util.concurrent.TimeoutException; .setListener(listener) .setApplicationLooper(Looper.getMainLooper()) .buildAsync(); - controllerMap.put(session, controllerFuture); + controllerMap.put(session, new ControllerInfo(controllerFuture)); controllerFuture.addListener( () -> { try { @@ -123,9 +124,9 @@ import java.util.concurrent.TimeoutException; } public void removeSession(MediaSession session) { - @Nullable ListenableFuture future = controllerMap.remove(session); - if (future != null) { - MediaController.releaseFuture(future); + @Nullable ControllerInfo controllerInfo = controllerMap.remove(session); + if (controllerInfo != null) { + MediaController.releaseFuture(controllerInfo.controllerFuture); } } @@ -158,19 +159,8 @@ import java.util.concurrent.TimeoutException; } int notificationSequence = ++totalNotificationCount; - MediaController mediaNotificationController = null; - ListenableFuture controller = controllerMap.get(session); - if (controller != null && controller.isDone()) { - try { - mediaNotificationController = Futures.getDone(controller); - } catch (ExecutionException e) { - // Ignore. - } - } ImmutableList mediaButtonPreferences = - mediaNotificationController != null - ? mediaNotificationController.getMediaButtonPreferences() - : ImmutableList.of(); + checkNotNull(getConnectedControllerForSession(session)).getMediaButtonPreferences(); MediaNotification.Provider.Callback callback = notification -> mainExecutor.execute( @@ -261,6 +251,13 @@ import java.util.concurrent.TimeoutException; } } + private void onNotificationDismissed(MediaSession session) { + @Nullable ControllerInfo controllerInfo = controllerMap.get(session); + if (controllerInfo != null) { + controllerInfo.wasNotificationDismissed = true; + } + } + // POST_NOTIFICATIONS permission is not required for media session related notifications. // https://developer.android.com/develop/ui/views/notifications/notification-permission#exemptions-media-sessions @SuppressLint("MissingPermission") @@ -270,8 +267,7 @@ import java.util.concurrent.TimeoutException; boolean startInForegroundRequired) { // Call Notification.MediaStyle#setMediaSession() indirectly. android.media.session.MediaSession.Token fwkToken = - (android.media.session.MediaSession.Token) - session.getSessionCompat().getSessionToken().getToken(); + session.getSessionCompat().getSessionToken().getToken(); mediaNotification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken); this.mediaNotification = mediaNotification; if (startInForegroundRequired) { @@ -301,17 +297,25 @@ import java.util.concurrent.TimeoutException; private boolean shouldShowNotification(MediaSession session) { MediaController controller = getConnectedControllerForSession(session); - return controller != null && !controller.getCurrentTimeline().isEmpty(); + if (controller == null || controller.getCurrentTimeline().isEmpty()) { + return false; + } + ControllerInfo controllerInfo = checkNotNull(controllerMap.get(session)); + if (controller.getPlaybackState() != Player.STATE_IDLE) { + // Playback restarted, reset previous notification dismissed flag. + controllerInfo.wasNotificationDismissed = false; + } + return !controllerInfo.wasNotificationDismissed; } @Nullable private MediaController getConnectedControllerForSession(MediaSession session) { - ListenableFuture controller = controllerMap.get(session); - if (controller == null || !controller.isDone()) { + @Nullable ControllerInfo controllerInfo = controllerMap.get(session); + if (controllerInfo == null || !controllerInfo.controllerFuture.isDone()) { return null; } try { - return Futures.getDone(controller); + return Futures.getDone(controllerInfo.controllerFuture); } catch (ExecutionException exception) { // We should never reach this. throw new IllegalStateException(exception); @@ -350,8 +354,7 @@ import java.util.concurrent.TimeoutException; } } - private static final class MediaControllerListener - implements MediaController.Listener, Player.Listener { + private final class MediaControllerListener implements MediaController.Listener, Player.Listener { private final MediaSessionService mediaSessionService; private final MediaSession session; @@ -381,6 +384,17 @@ import java.util.concurrent.TimeoutException; session, /* startInForegroundWhenPaused= */ false); } + @Override + public ListenableFuture onCustomCommand( + MediaController controller, SessionCommand command, Bundle args) { + @SessionResult.Code int resultCode = SessionError.ERROR_NOT_SUPPORTED; + if (command.customAction.equals(MediaNotification.NOTIFICATION_DISMISSED_EVENT_KEY)) { + onNotificationDismissed(session); + resultCode = SessionResult.RESULT_SUCCESS; + } + return Futures.immediateFuture(new SessionResult(resultCode)); + } + @Override public void onDisconnected(MediaController controller) { if (mediaSessionService.isSessionAdded(session)) { @@ -427,6 +441,18 @@ import java.util.concurrent.TimeoutException; startedInForeground = false; } + private static final class ControllerInfo { + + public final ListenableFuture controllerFuture; + + /** Indicates whether the user actively dismissed the notification. */ + public boolean wasNotificationDismissed; + + public ControllerInfo(ListenableFuture controllerFuture) { + this.controllerFuture = controllerFuture; + } + } + @RequiresApi(24) private static class Api24 { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java index 1dae1d41f3..bb1ef3afd9 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -1322,10 +1322,14 @@ import org.checkerframework.checker.initialization.qual.Initialized; return false; } // Send from media notification controller. - return applyMediaButtonKeyEvent(keyEvent, doubleTapCompleted); + boolean isDismissNotificationEvent = + intent.getBooleanExtra( + MediaNotification.NOTIFICATION_DISMISSED_EVENT_KEY, /* defaultValue= */ false); + return applyMediaButtonKeyEvent(keyEvent, doubleTapCompleted, isDismissNotificationEvent); } - private boolean applyMediaButtonKeyEvent(KeyEvent keyEvent, boolean doubleTapCompleted) { + private boolean applyMediaButtonKeyEvent( + KeyEvent keyEvent, boolean doubleTapCompleted, boolean isDismissNotificationEvent) { ControllerInfo controllerInfo = checkNotNull(instance.getMediaNotificationControllerInfo()); Runnable command; int keyCode = keyEvent.getKeyCode(); @@ -1375,6 +1379,15 @@ import org.checkerframework.checker.initialization.qual.Initialized; postOrRun( getApplicationHandler(), () -> { + if (isDismissNotificationEvent) { + ListenableFuture ignored = + sendCustomCommand( + controllerInfo, + new SessionCommand( + MediaNotification.NOTIFICATION_DISMISSED_EVENT_KEY, + /* extras= */ Bundle.EMPTY), + /* args= */ Bundle.EMPTY); + } command.run(); sessionStub.getConnectedControllersManager().flushCommandQueue(controllerInfo); }); @@ -1902,7 +1915,10 @@ import org.checkerframework.checker.initialization.qual.Initialized; playPauseTask = () -> { if (isMediaNotificationController(controllerInfo)) { - applyMediaButtonKeyEvent(keyEvent, /* doubleTapCompleted= */ false); + applyMediaButtonKeyEvent( + keyEvent, + /* doubleTapCompleted= */ false, + /* isDismissNotificationEvent= */ false); } else { sessionLegacyStub.handleMediaPlayPauseOnHandler( checkNotNull(controllerInfo.getRemoteUserInfo()));