From ffd7bb5639f3c25b877f3689eadf07bdbfdee2b0 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 25 Sep 2023 08:16:45 -0700 Subject: [PATCH] Resolve and dispatch media button events within Media3 Before this change, media button events are routed from `onStartCommand` of the `MediaSessionService` to the `MediaSessionCompat`, resolved by the legacy library to a session command called on `MediaSessionCompat.Callback` from where the command is delegated back to the Media3 session. With this change the keycode is resolved directly to a Media3 command that is sent to the session through the media notification controller of the session. After this change, a playback or custom command sent to the session from a notification, either as a pending intent (before API 33) or as a legacy session command, look the same and the caller is the media notification controller on all API levels. PiperOrigin-RevId: 568224123 --- RELEASENOTES.md | 6 + .../session/MediaControllerImplBase.java | 12 + .../session/MediaNotificationManager.java | 152 ++++++++--- .../media3/session/MediaSessionImpl.java | 30 +++ .../session/MediaSessionLegacyStub.java | 9 + .../media3/session/MediaSessionService.java | 2 +- .../session/MediaSessionServiceTest.java | 236 +++++++++++++++++- 7 files changed, 403 insertions(+), 44 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 95cef53cd9..53f37796c0 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -70,6 +70,12 @@ * Use the media notification controller as proxy to set available commands and custom layout used to populate the notification and the platform session. + * Convert media button events that are received by + `MediaSessionService.onStartCommand()` within Media3 instead of routing + them to the platform session and back to Media3. With this, the caller + controller is always the media notification controller and apps can + easily recognize calls coming from the notification in the same way on + all supported API levels. * UI: * Downloads: * OkHttp Extension: diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java index 9ae7af2484..376ece00f1 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -388,6 +388,11 @@ import org.checkerframework.checker.nullness.qual.NonNull; @Override public void play() { if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { + Log.w( + TAG, + "Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this play" + + " command has started the service for instance for playback resumption, this may" + + " prevent the service from being started into the foreground."); return; } @@ -522,6 +527,13 @@ import org.checkerframework.checker.nullness.qual.NonNull; @Override public void setPlayWhenReady(boolean playWhenReady) { if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { + if (playWhenReady) { + Log.w( + TAG, + "Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this play" + + " command has started the service for instance for playback resumption, this may" + + " prevent the service from being started into the foreground."); + } return; } 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 3c771d8875..b6c3d56280 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,15 @@ package androidx.media3.session; import static android.app.Service.STOP_FOREGROUND_DETACH; import static android.app.Service.STOP_FOREGROUND_REMOVE; +import static android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD; +import static android.view.KeyEvent.KEYCODE_MEDIA_NEXT; +import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE; +import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY; +import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE; +import static android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS; +import static android.view.KeyEvent.KEYCODE_MEDIA_REWIND; +import static android.view.KeyEvent.KEYCODE_MEDIA_STOP; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.annotation.SuppressLint; import android.app.Notification; @@ -25,6 +34,7 @@ import android.content.pm.ServiceInfo; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.view.KeyEvent; import androidx.annotation.DoNotInline; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -44,7 +54,6 @@ import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** @@ -65,7 +74,7 @@ import java.util.concurrent.TimeoutException; private final NotificationManagerCompat notificationManagerCompat; private final Executor mainExecutor; private final Intent startSelfIntent; - private final Map> controllerMap; + private final Map controllerAndListenerMap; private int totalNotificationCount; @Nullable private MediaNotification mediaNotification; @@ -82,30 +91,34 @@ import java.util.concurrent.TimeoutException; Handler mainHandler = new Handler(Looper.getMainLooper()); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); - controllerMap = new HashMap<>(); + controllerAndListenerMap = new HashMap<>(); startedInForeground = false; } public void addSession(MediaSession session) { - if (controllerMap.containsKey(session)) { + if (controllerAndListenerMap.containsKey(session)) { return; } - MediaControllerListener listener = new MediaControllerListener(mediaSessionService, session); + MediaControllerListener controllerListener = + new MediaControllerListener(mediaSessionService, session); + PlayerListener playerListener = new PlayerListener(mediaSessionService, session); Bundle connectionHints = new Bundle(); connectionHints.putBoolean(KEY_MEDIA_NOTIFICATION_MANAGER, true); ListenableFuture controllerFuture = new MediaController.Builder(mediaSessionService, session.getToken()) .setConnectionHints(connectionHints) - .setListener(listener) + .setListener(controllerListener) .setApplicationLooper(Looper.getMainLooper()) .buildAsync(); - controllerMap.put(session, controllerFuture); + controllerAndListenerMap.put( + session, new ControllerAndListener(controllerFuture, playerListener)); controllerFuture.addListener( () -> { try { - MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); - listener.onConnected(shouldShowNotification(session)); - controller.addListener(listener); + // Assert connection success. + controllerFuture.get(/* time= */ 0, MILLISECONDS); + controllerListener.onConnected(shouldShowNotification(session)); + session.getImpl().addPlayerListener(playerListener); } catch (CancellationException | ExecutionException | InterruptedException @@ -118,9 +131,52 @@ import java.util.concurrent.TimeoutException; } public void removeSession(MediaSession session) { - @Nullable ListenableFuture controllerFuture = controllerMap.remove(session); - if (controllerFuture != null) { - MediaController.releaseFuture(controllerFuture); + ControllerAndListener controllerAndListener = controllerAndListenerMap.remove(session); + if (controllerAndListener != null) { + session.getImpl().removePlayerListener(controllerAndListener.listener); + MediaController.releaseFuture(controllerAndListener.controller); + } + } + + public void onMediaButtonEvent(MediaSession session, KeyEvent keyEvent) { + int keyCode = keyEvent.getKeyCode(); + @Nullable MediaController mediaController = getConnectedControllerForSession(session); + if (mediaController == null) { + session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); + return; + } + switch (keyCode) { + case KEYCODE_MEDIA_PLAY_PAUSE: + if (mediaController.getPlayWhenReady()) { + mediaController.pause(); + } else { + mediaController.play(); + } + break; + case KEYCODE_MEDIA_PLAY: + mediaController.play(); + break; + case KEYCODE_MEDIA_PAUSE: + mediaController.pause(); + break; + case KEYCODE_MEDIA_NEXT: + mediaController.seekToNext(); + break; + case KEYCODE_MEDIA_PREVIOUS: + mediaController.seekToPrevious(); + break; + case KEYCODE_MEDIA_FAST_FORWARD: + mediaController.seekForward(); + break; + case KEYCODE_MEDIA_REWIND: + mediaController.seekBack(); + break; + case KEYCODE_MEDIA_STOP: + mediaController.stop(); + break; + default: + Log.w(TAG, "Received media button event with unsupported key code: " + keyCode); + break; } } @@ -135,7 +191,7 @@ import java.util.concurrent.TimeoutException; () -> { if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { mainExecutor.execute( - () -> sendCustomCommandIfCommandIsAvailable(mediaController, action)); + () -> sendCustomCommandIfCommandIsAvailable(mediaController, action, extras)); } }); } @@ -154,10 +210,10 @@ import java.util.concurrent.TimeoutException; int notificationSequence = ++totalNotificationCount; MediaController mediaNotificationController = null; - @Nullable ListenableFuture controllerFuture = controllerMap.get(session); - if (controllerFuture != null && controllerFuture.isDone()) { + ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session); + if (controllerAndListener != null && controllerAndListener.controller.isDone()) { try { - mediaNotificationController = Futures.getDone(controllerFuture); + mediaNotificationController = Futures.getDone(controllerAndListener.controller); } catch (ExecutionException e) { // Ignore. } @@ -261,12 +317,12 @@ import java.util.concurrent.TimeoutException; @Nullable private MediaController getConnectedControllerForSession(MediaSession session) { - @Nullable ListenableFuture controllerFuture = controllerMap.get(session); - if (controllerFuture == null) { + ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session); + if (controllerAndListener == null) { return null; } try { - return Futures.getDone(controllerFuture); + return Futures.getDone(controllerAndListener.controller); } catch (ExecutionException exception) { // We should never reach this. throw new IllegalStateException(exception); @@ -274,7 +330,7 @@ import java.util.concurrent.TimeoutException; } private void sendCustomCommandIfCommandIsAvailable( - MediaController mediaController, String action) { + MediaController mediaController, String action, Bundle extras) { @Nullable SessionCommand customCommand = null; for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM @@ -286,7 +342,8 @@ import java.util.concurrent.TimeoutException; if (customCommand != null && mediaController.getAvailableSessionCommands().contains(customCommand)) { ListenableFuture future = - mediaController.sendCustomCommand(customCommand, Bundle.EMPTY); + mediaController.sendCustomCommand( + new SessionCommand(action, extras), /* args= */ Bundle.EMPTY); Futures.addCallback( future, new FutureCallback() { @@ -304,8 +361,7 @@ import java.util.concurrent.TimeoutException; } } - private static final class MediaControllerListener - implements MediaController.Listener, Player.Listener { + private static final class MediaControllerListener implements MediaController.Listener { private final MediaSessionService mediaSessionService; private final MediaSession session; @@ -334,6 +390,26 @@ import java.util.concurrent.TimeoutException; session, /* startInForegroundWhenPaused= */ false); } + @Override + public void onDisconnected(MediaController controller) { + mediaSessionService.removeSession(session); + // We may need to hide the notification. + mediaSessionService.onUpdateNotificationInternal( + session, /* startInForegroundWhenPaused= */ false); + } + } + + private static class PlayerListener implements Player.Listener { + private final MediaSessionService mediaSessionService; + private final MediaSession session; + private final Handler mainHandler; + + public PlayerListener(MediaSessionService mediaSessionService, MediaSession session) { + this.mediaSessionService = mediaSessionService; + this.session = session; + mainHandler = new Handler(Looper.getMainLooper()); + } + @Override public void onEvents(Player player, Player.Events events) { // We must limit the frequency of notification updates, otherwise the system may suppress @@ -343,18 +419,15 @@ import java.util.concurrent.TimeoutException; Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED, Player.EVENT_TIMELINE_CHANGED)) { - mediaSessionService.onUpdateNotificationInternal( - session, /* startInForegroundWhenPaused= */ false); + // onUpdateNotificationInternal is required to be called on the main thread and the + // application thread of the player may be a different thread. + Util.postOrRun( + mainHandler, + () -> + mediaSessionService.onUpdateNotificationInternal( + session, /* startInForegroundWhenPaused= */ false)); } } - - @Override - public void onDisconnected(MediaController controller) { - mediaSessionService.removeSession(session); - // We may need to hide the notification. - mediaSessionService.onUpdateNotificationInternal( - session, /* startInForegroundWhenPaused= */ false); - } } @SuppressLint("InlinedApi") // Using compile time constant FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK @@ -382,6 +455,17 @@ import java.util.concurrent.TimeoutException; startedInForeground = false; } + private static class ControllerAndListener { + public final ListenableFuture controller; + public final Player.Listener listener; + + private ControllerAndListener( + ListenableFuture controller, Player.Listener listener) { + this.controller = controller; + this.listener = listener; + } + } + @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 1016f53980..ded22c8e88 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -129,6 +129,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private boolean closed; // Should be only accessed on the application looper + private final List wrapperListeners; private long sessionPositionUpdateDelayMs; private boolean isMediaNotificationControllerConnected; private ImmutableList customLayout; @@ -154,6 +155,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; sessionStub = new MediaSessionStub(thisRef); this.sessionActivity = sessionActivity; this.customLayout = customLayout; + wrapperListeners = new ArrayList<>(); mainHandler = new Handler(Looper.getMainLooper()); applicationHandler = new Handler(player.getApplicationLooper()); @@ -231,14 +233,38 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; playerWrapper.getAvailablePlayerCommands())); } + public void addPlayerListener(Player.Listener listener) { + postOrRun( + applicationHandler, + () -> { + wrapperListeners.add(listener); + playerWrapper.addListener(listener); + }); + } + + public void removePlayerListener(Player.Listener listener) { + postOrRun( + applicationHandler, + () -> { + playerWrapper.removeListener(listener); + wrapperListeners.remove(listener); + }); + } + private void setPlayerInternal( @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { playerWrapper = newPlayerWrapper; if (oldPlayerWrapper != null) { oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener)); + for (int i = 0; i < wrapperListeners.size(); i++) { + oldPlayerWrapper.removeListener(wrapperListeners.get(i)); + } } PlayerListener playerListener = new PlayerListener(this, newPlayerWrapper); newPlayerWrapper.addListener(playerListener); + for (int i = 0; i < wrapperListeners.size(); i++) { + newPlayerWrapper.addListener(wrapperListeners.get(i)); + } this.playerListener = playerListener; dispatchRemoteControllerTaskToLegacyStub( @@ -270,6 +296,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; if (playerListener != null) { playerWrapper.removeListener(playerListener); } + for (int i = 0; i < wrapperListeners.size(); i++) { + playerWrapper.removeListener(wrapperListeners.get(i)); + } + wrapperListeners.clear(); }); } catch (Exception e) { // Catch all exceptions to ensure the rest of this method to be executed as exceptions may be diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index a3540f0083..7d5e4d76b2 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -690,6 +690,15 @@ import org.checkerframework.checker.initialization.qual.Initialized; return; } if (!connectedControllersManager.isPlayerCommandAvailable(controller, command)) { + if (command == COMMAND_PLAY_PAUSE + && !sessionImpl.getPlayerWrapper().getPlayWhenReady()) { + Log.w( + TAG, + "Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this" + + " play command has started the service for instance for playback" + + " resumption, this may prevent the service from being started into the" + + " foreground."); + } return; } int resultCode = sessionImpl.onPlayerCommandRequestOnHandler(controller, command); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java index b3963c57b1..9e87bb2462 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -428,7 +428,7 @@ public abstract class MediaSessionService extends Service { } @Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent); if (keyEvent != null) { - session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); + getMediaNotificationManager().onMediaButtonEvent(session, keyEvent); } } else if (session != null && actionFactory.isCustomAction(intent)) { @Nullable String customAction = actionFactory.getCustomAction(intent); diff --git a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java index 5965fb4053..38a3e5b4a4 100644 --- a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java @@ -17,22 +17,33 @@ package androidx.media3.session; import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.app.NotificationManager; import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; import android.service.notification.StatusBarNotification; +import android.view.KeyEvent; import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.ForwardingPlayer; import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.test.utils.TestExoPlayerBuilder; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; -import org.junit.After; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,21 +54,16 @@ import org.robolectric.shadows.ShadowLooper; @RunWith(AndroidJUnit4.class) public class MediaSessionServiceTest { + private static final int TIMEOUT_MS = 500; + private Context context; private NotificationManager notificationManager; - private ServiceController serviceController; @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - serviceController = Robolectric.buildService(TestService.class); - } - - @After - public void tearDown() { - serviceController.destroy(); } @Test @@ -66,6 +72,7 @@ public class MediaSessionServiceTest { ExoPlayer player2 = new TestExoPlayerBuilder(context).build(); MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build(); MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); TestService service = serviceController.create().get(); service.setMediaNotificationProvider( new DefaultMediaNotificationProvider( @@ -92,6 +99,7 @@ public class MediaSessionServiceTest { session2.release(); player1.release(); player2.release(); + serviceController.destroy(); } @Test @@ -105,6 +113,7 @@ public class MediaSessionServiceTest { ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build(); MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build(); MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); TestService service = serviceController.create().get(); service.setMediaNotificationProvider( new DefaultMediaNotificationProvider( @@ -112,7 +121,6 @@ public class MediaSessionServiceTest { session -> 2000 + Integer.parseInt(session.getId()), DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID, DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID)); - service.addSession(session1); service.addSession(session2); // Start the players so that we also create notifications for them. @@ -141,6 +149,7 @@ public class MediaSessionServiceTest { new Handler(thread2.getLooper()).post(player2::release); thread1.quit(); thread2.quit(); + serviceController.destroy(); } @Test @@ -183,6 +192,7 @@ public class MediaSessionServiceTest { } }) .build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); TestService service = serviceController.create().get(); service.setMediaNotificationProvider( new DefaultMediaNotificationProvider( @@ -229,6 +239,7 @@ public class MediaSessionServiceTest { .isEqualTo("customAction2"); session.release(); player.release(); + serviceController.destroy(); } @Test @@ -272,6 +283,7 @@ public class MediaSessionServiceTest { } }) .build(); + ServiceController serviceController = Robolectric.buildService(TestService.class); TestService service = serviceController.create().get(); service.setMediaNotificationProvider( new DefaultMediaNotificationProvider( @@ -317,6 +329,156 @@ public class MediaSessionServiceTest { session.release(); player.release(); + serviceController.destroy(); + } + + @Test + public void onStartCommand_mediaButtonEvent_pausedByMediaNotificationController() + throws InterruptedException { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + AtomicReference session = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + ForwardingPlayer forwardingPlayer = + new ForwardingPlayer(player) { + @Override + public void pause() { + super.pause(); + if (session + .get() + .isMediaNotificationController(session.get().getControllerForCurrentRequest())) { + latch.countDown(); + } + } + }; + session.set(new MediaSession.Builder(context, forwardingPlayer).build()); + Intent playIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + playIntent.setData(session.get().getUri()); + playIntent.putExtra( + Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)); + ServiceController serviceController = + Robolectric.buildService(TestService.class, playIntent); + TestService service = serviceController.create().get(); + service.addSession(session.get()); + player.setMediaItems(ImmutableList.of(MediaItem.fromUri("asset:///media/mp4/sample.mp4"))); + player.play(); + player.prepare(); + + serviceController.startCommand(/* flags= */ 0, /* startId= */ 0); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(player.getPlayWhenReady()).isFalse(); + session.get().release(); + player.release(); + serviceController.destroy(); + } + + @Test + public void onStartCommand_playbackResumption_calledByMediaNotificationController() + throws InterruptedException, ExecutionException, TimeoutException { + Intent playIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + playIntent.putExtra( + Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)); + ServiceController serviceController = + Robolectric.buildService(TestServiceWithPlaybackResumption.class, playIntent); + TestServiceWithPlaybackResumption service = serviceController.create().get(); + service.setMediaItems( + ImmutableList.of( + new MediaItem.Builder() + .setMediaId("media-id-0") + .setUri("asset:///media/mp4/sample.mp4") + .build())); + MediaController controller = + new MediaController.Builder(context, service.session.getToken()) + .buildAsync() + .get(TIMEOUT_MS, MILLISECONDS); + CountDownLatch latch = new CountDownLatch(1); + controller.addListener( + new Player.Listener() { + @Override + public void onEvents(Player player, Player.Events events) { + if (events.contains(Player.EVENT_TIMELINE_CHANGED) + && player.getMediaItemCount() == 1 + && player.getCurrentMediaItem().mediaId.equals("media-id-0") + && events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED) + && player.getPlayWhenReady() + && events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) + && player.getPlaybackState() == Player.STATE_BUFFERING) { + latch.countDown(); + } + } + }); + + serviceController.startCommand(/* flags= */ 0, /* startId= */ 0); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + serviceController.destroy(); + } + + @Test + public void onStartCommand_customCommands_deliveredByMediaNotificationController() + throws InterruptedException { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + AtomicReference sessionRef = new AtomicReference<>(); + SessionCommand expectedCustomCommand = new SessionCommand("enable_shuffle", Bundle.EMPTY); + CountDownLatch latch = new CountDownLatch(1); + sessionRef.set( + new MediaSession.Builder(context, player) + .setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + if (session.getUri().equals(sessionRef.get().getUri()) + && session.isMediaNotificationController(controller)) { + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + new SessionCommands.Builder().add(expectedCustomCommand).build()) + .build(); + } else { + return MediaSession.ConnectionResult.reject(); + } + } + + @Override + public ListenableFuture onCustomCommand( + MediaSession session, + MediaSession.ControllerInfo controller, + SessionCommand customCommand, + Bundle args) { + if (session.getUri().equals(sessionRef.get().getUri()) + && session.isMediaNotificationController(controller) + && customCommand.equals(expectedCustomCommand) + && customCommand + .customExtras + .getString("expectedKey", /* defaultValue= */ "") + .equals("expectedValue") + && args.isEmpty()) { + latch.countDown(); + } + return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS)); + } + }) + .build()); + MediaSession session = sessionRef.get(); + Intent customCommandIntent = new Intent("androidx.media3.session.CUSTOM_NOTIFICATION_ACTION"); + customCommandIntent.setData(session.getUri()); + customCommandIntent.putExtra( + "androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION", "enable_shuffle"); + Bundle extras = new Bundle(); + extras.putString("expectedKey", "expectedValue"); + customCommandIntent.putExtra( + "androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION_EXTRAS", extras); + ServiceController serviceController = + Robolectric.buildService(TestService.class, customCommandIntent); + TestService service = serviceController.create().get(); + service.addSession(session); + + serviceController.startCommand(/* flags= */ 0, /* startId= */ 0); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + session.release(); + player.release(); + serviceController.destroy(); } @Nullable @@ -336,4 +498,60 @@ public class MediaSessionServiceTest { return null; // No need to support binding or pending intents for this test. } } + + private static final class TestServiceWithPlaybackResumption extends MediaSessionService { + + private List mediaItems = ImmutableList.of(); + + public void setMediaItems(List mediaItems) { + this.mediaItems = mediaItems; + } + + @Nullable private MediaSession session; + + @Override + public void onCreate() { + super.onCreate(); + Context context = ApplicationProvider.getApplicationContext(); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + session = + new MediaSession.Builder(context, player) + .setCallback( + new MediaSession.Callback() { + @Override + public ListenableFuture + onPlaybackResumption( + MediaSession mediaSession, MediaSession.ControllerInfo controller) { + // Automatic playback resumption is expected to be called only from the media + // notification controller. So we call it here only if the callback is + // actually called from the media notification controller (or a fake of it). + if (mediaSession.isMediaNotificationController(controller)) { + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + mediaItems, + /* startIndex= */ 0, + /* startPositionMs= */ C.TIME_UNSET)); + } + return Futures.immediateFailedFuture(new UnsupportedOperationException()); + } + }) + .build(); + } + + @Nullable + @Override + public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) { + return session; + } + + @Override + public void onDestroy() { + session.getPlayer().stop(); + session.getPlayer().clearMediaItems(); + session.getPlayer().release(); + session.release(); + session = null; + super.onDestroy(); + } + } }