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
(cherry picked from commit f672590b2deeffb06435eb542cfe0d0630894e92)
This commit is contained in:
tonihei 2025-04-10 05:37:22 -07:00
parent ce0c98c4d4
commit 157fd8a260
6 changed files with 116 additions and 36 deletions

View File

@ -65,6 +65,8 @@
* Lower aggregation timeout for platform `MediaSession` callbacks from 500 * Lower aggregation timeout for platform `MediaSession` callbacks from 500
to 100 milliseconds and add an experimental setter to allow apps to to 100 milliseconds and add an experimental setter to allow apps to
configure this value. 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: * UI:
* Enable `PlayerSurface` to work with `ExoPlayer.setVideoEffects` and * Enable `PlayerSurface` to work with `ExoPlayer.setVideoEffects` and
`CompositionPlayer`. `CompositionPlayer`.

View File

@ -113,10 +113,7 @@ import androidx.media3.common.util.Util;
public PendingIntent createMediaActionPendingIntent( public PendingIntent createMediaActionPendingIntent(
MediaSession mediaSession, @Player.Command long command) { MediaSession mediaSession, @Player.Command long command) {
int keyCode = toKeyCode(command); int keyCode = toKeyCode(command);
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); Intent intent = getMediaButtonIntent(mediaSession, keyCode);
intent.setData(mediaSession.getImpl().getUri());
intent.setComponent(new ComponentName(service, service.getClass()));
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
if (Util.SDK_INT >= 26 if (Util.SDK_INT >= 26
&& command == COMMAND_PLAY_PAUSE && command == COMMAND_PLAY_PAUSE
&& !mediaSession.getPlayer().getPlayWhenReady()) { && !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) { private int toKeyCode(@Player.Command long action) {
if (action == COMMAND_SEEK_TO_NEXT_MEDIA_ITEM || action == COMMAND_SEEK_TO_NEXT) { if (action == COMMAND_SEEK_TO_NEXT_MEDIA_ITEM || action == COMMAND_SEEK_TO_NEXT) {
return KEYCODE_MEDIA_NEXT; return KEYCODE_MEDIA_NEXT;

View File

@ -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_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS; 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_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.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull;
@ -379,8 +378,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
Notification notification = Notification notification =
builder builder
.setContentIntent(mediaSession.getSessionActivity()) .setContentIntent(mediaSession.getSessionActivity())
.setDeleteIntent( .setDeleteIntent(actionFactory.createNotificationDismissalIntent(mediaSession))
actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP))
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setSmallIcon(smallIconResourceId) .setSmallIcon(smallIconResourceId)
.setStyle(mediaStyle) .setStyle(mediaStyle)

View File

@ -31,6 +31,17 @@ import com.google.common.collect.ImmutableList;
/** A notification for media playbacks. */ /** A notification for media playbacks. */
public final class MediaNotification { public final class MediaNotification {
/**
* Event key to indicate a media notification was dismissed.
*
* <p>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 * Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
* intents} for notifications. * 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. * 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 mediaSession The media session to which the action will be sent.
* @param command The intent's command. * @param command The {@link PendingIntent}.
*/ */
PendingIntent createMediaActionPendingIntent( PendingIntent createMediaActionPendingIntent(
MediaSession mediaSession, @Player.Command long command); 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);
}
} }
/** /**

View File

@ -17,6 +17,7 @@ package androidx.media3.session;
import static android.app.Service.STOP_FOREGROUND_DETACH; import static android.app.Service.STOP_FOREGROUND_DETACH;
import static android.app.Service.STOP_FOREGROUND_REMOVE; import static android.app.Service.STOP_FOREGROUND_REMOVE;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
@ -65,7 +66,7 @@ import java.util.concurrent.TimeoutException;
private final Handler mainHandler; private final Handler mainHandler;
private final Executor mainExecutor; private final Executor mainExecutor;
private final Intent startSelfIntent; private final Intent startSelfIntent;
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap; private final Map<MediaSession, ControllerInfo> controllerMap;
private int totalNotificationCount; private int totalNotificationCount;
@Nullable private MediaNotification mediaNotification; @Nullable private MediaNotification mediaNotification;
@ -104,7 +105,7 @@ import java.util.concurrent.TimeoutException;
.setListener(listener) .setListener(listener)
.setApplicationLooper(Looper.getMainLooper()) .setApplicationLooper(Looper.getMainLooper())
.buildAsync(); .buildAsync();
controllerMap.put(session, controllerFuture); controllerMap.put(session, new ControllerInfo(controllerFuture));
controllerFuture.addListener( controllerFuture.addListener(
() -> { () -> {
try { try {
@ -123,9 +124,9 @@ import java.util.concurrent.TimeoutException;
} }
public void removeSession(MediaSession session) { public void removeSession(MediaSession session) {
@Nullable ListenableFuture<MediaController> future = controllerMap.remove(session); @Nullable ControllerInfo controllerInfo = controllerMap.remove(session);
if (future != null) { if (controllerInfo != null) {
MediaController.releaseFuture(future); MediaController.releaseFuture(controllerInfo.controllerFuture);
} }
} }
@ -158,19 +159,8 @@ import java.util.concurrent.TimeoutException;
} }
int notificationSequence = ++totalNotificationCount; int notificationSequence = ++totalNotificationCount;
MediaController mediaNotificationController = null;
ListenableFuture<MediaController> controller = controllerMap.get(session);
if (controller != null && controller.isDone()) {
try {
mediaNotificationController = Futures.getDone(controller);
} catch (ExecutionException e) {
// Ignore.
}
}
ImmutableList<CommandButton> mediaButtonPreferences = ImmutableList<CommandButton> mediaButtonPreferences =
mediaNotificationController != null checkNotNull(getConnectedControllerForSession(session)).getMediaButtonPreferences();
? mediaNotificationController.getMediaButtonPreferences()
: ImmutableList.of();
MediaNotification.Provider.Callback callback = MediaNotification.Provider.Callback callback =
notification -> notification ->
mainExecutor.execute( 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. // POST_NOTIFICATIONS permission is not required for media session related notifications.
// https://developer.android.com/develop/ui/views/notifications/notification-permission#exemptions-media-sessions // https://developer.android.com/develop/ui/views/notifications/notification-permission#exemptions-media-sessions
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@ -270,8 +267,7 @@ import java.util.concurrent.TimeoutException;
boolean startInForegroundRequired) { boolean startInForegroundRequired) {
// Call Notification.MediaStyle#setMediaSession() indirectly. // Call Notification.MediaStyle#setMediaSession() indirectly.
android.media.session.MediaSession.Token fwkToken = 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); mediaNotification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken);
this.mediaNotification = mediaNotification; this.mediaNotification = mediaNotification;
if (startInForegroundRequired) { if (startInForegroundRequired) {
@ -301,17 +297,25 @@ import java.util.concurrent.TimeoutException;
private boolean shouldShowNotification(MediaSession session) { private boolean shouldShowNotification(MediaSession session) {
MediaController controller = getConnectedControllerForSession(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 @Nullable
private MediaController getConnectedControllerForSession(MediaSession session) { private MediaController getConnectedControllerForSession(MediaSession session) {
ListenableFuture<MediaController> controller = controllerMap.get(session); @Nullable ControllerInfo controllerInfo = controllerMap.get(session);
if (controller == null || !controller.isDone()) { if (controllerInfo == null || !controllerInfo.controllerFuture.isDone()) {
return null; return null;
} }
try { try {
return Futures.getDone(controller); return Futures.getDone(controllerInfo.controllerFuture);
} catch (ExecutionException exception) { } catch (ExecutionException exception) {
// We should never reach this. // We should never reach this.
throw new IllegalStateException(exception); throw new IllegalStateException(exception);
@ -350,8 +354,7 @@ import java.util.concurrent.TimeoutException;
} }
} }
private static final class MediaControllerListener private final class MediaControllerListener implements MediaController.Listener, Player.Listener {
implements MediaController.Listener, Player.Listener {
private final MediaSessionService mediaSessionService; private final MediaSessionService mediaSessionService;
private final MediaSession session; private final MediaSession session;
@ -381,6 +384,17 @@ import java.util.concurrent.TimeoutException;
session, /* startInForegroundWhenPaused= */ false); session, /* startInForegroundWhenPaused= */ false);
} }
@Override
public ListenableFuture<SessionResult> 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 @Override
public void onDisconnected(MediaController controller) { public void onDisconnected(MediaController controller) {
if (mediaSessionService.isSessionAdded(session)) { if (mediaSessionService.isSessionAdded(session)) {
@ -427,6 +441,18 @@ import java.util.concurrent.TimeoutException;
startedInForeground = false; startedInForeground = false;
} }
private static final class ControllerInfo {
public final ListenableFuture<MediaController> controllerFuture;
/** Indicates whether the user actively dismissed the notification. */
public boolean wasNotificationDismissed;
public ControllerInfo(ListenableFuture<MediaController> controllerFuture) {
this.controllerFuture = controllerFuture;
}
}
@RequiresApi(24) @RequiresApi(24)
private static class Api24 { private static class Api24 {

View File

@ -1322,10 +1322,14 @@ import org.checkerframework.checker.initialization.qual.Initialized;
return false; return false;
} }
// Send from media notification controller. // 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()); ControllerInfo controllerInfo = checkNotNull(instance.getMediaNotificationControllerInfo());
Runnable command; Runnable command;
int keyCode = keyEvent.getKeyCode(); int keyCode = keyEvent.getKeyCode();
@ -1375,6 +1379,15 @@ import org.checkerframework.checker.initialization.qual.Initialized;
postOrRun( postOrRun(
getApplicationHandler(), getApplicationHandler(),
() -> { () -> {
if (isDismissNotificationEvent) {
ListenableFuture<SessionResult> ignored =
sendCustomCommand(
controllerInfo,
new SessionCommand(
MediaNotification.NOTIFICATION_DISMISSED_EVENT_KEY,
/* extras= */ Bundle.EMPTY),
/* args= */ Bundle.EMPTY);
}
command.run(); command.run();
sessionStub.getConnectedControllersManager().flushCommandQueue(controllerInfo); sessionStub.getConnectedControllersManager().flushCommandQueue(controllerInfo);
}); });
@ -1902,7 +1915,10 @@ import org.checkerframework.checker.initialization.qual.Initialized;
playPauseTask = playPauseTask =
() -> { () -> {
if (isMediaNotificationController(controllerInfo)) { if (isMediaNotificationController(controllerInfo)) {
applyMediaButtonKeyEvent(keyEvent, /* doubleTapCompleted= */ false); applyMediaButtonKeyEvent(
keyEvent,
/* doubleTapCompleted= */ false,
/* isDismissNotificationEvent= */ false);
} else { } else {
sessionLegacyStub.handleMediaPlayPauseOnHandler( sessionLegacyStub.handleMediaPlayPauseOnHandler(
checkNotNull(controllerInfo.getRemoteUserInfo())); checkNotNull(controllerInfo.getRemoteUserInfo()));