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
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`.

View File

@ -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;

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_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)

View File

@ -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.
*
* <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
* 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);
}
}
/**

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_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<MediaSession, ListenableFuture<MediaController>> controllerMap;
private final Map<MediaSession, ControllerInfo> 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<MediaController> 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<MediaController> controller = controllerMap.get(session);
if (controller != null && controller.isDone()) {
try {
mediaNotificationController = Futures.getDone(controller);
} catch (ExecutionException e) {
// Ignore.
}
}
ImmutableList<CommandButton> 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,7 +267,6 @@ 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();
mediaNotification.notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken);
this.mediaNotification = mediaNotification;
@ -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<MediaController> 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<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
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<MediaController> controllerFuture;
/** Indicates whether the user actively dismissed the notification. */
public boolean wasNotificationDismissed;
public ControllerInfo(ListenableFuture<MediaController> controllerFuture) {
this.controllerFuture = controllerFuture;
}
}
@RequiresApi(24)
private static class Api24 {

View File

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