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()));