mirror of
https://github.com/androidx/media.git
synced 2025-04-29 22:36:54 +08:00
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:
parent
ce0c98c4d4
commit
157fd8a260
@ -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`.
|
||||||
|
@ -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;
|
||||||
|
@ -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)
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 {
|
||||||
|
|
||||||
|
@ -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()));
|
||||||
|
Loading…
x
Reference in New Issue
Block a user