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 ed65296b88d..646571cfd06 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java @@ -56,6 +56,19 @@ public static final String EXTRAS_KEY_ACTION_CUSTOM_EXTRAS = "androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION_EXTRAS"; + /** + * Returns the {@link KeyEvent} that was included in the media action, or {@code null} if no + * {@link KeyEvent} is found in the {@code intent}. + */ + @Nullable + public static KeyEvent getKeyEvent(Intent intent) { + @Nullable Bundle extras = intent.getExtras(); + if (extras != null && extras.containsKey(Intent.EXTRA_KEY_EVENT)) { + return extras.getParcelable(Intent.EXTRA_KEY_EVENT); + } + return null; + } + private final Service service; private int customActionPendingIntentRequestCode = 0; @@ -97,6 +110,7 @@ public NotificationCompat.Action createCustomActionFromCustomCommandButton( mediaSession, customCommand.customAction, customCommand.customExtras)); } + @SuppressWarnings("PendingIntentMutability") // We can't use SaferPendingIntent @Override public PendingIntent createMediaActionPendingIntent( MediaSession mediaSession, @Player.Command long command) { @@ -136,6 +150,7 @@ private int toKeyCode(@Player.Command long action) { return KEYCODE_UNKNOWN; } + @SuppressWarnings("PendingIntentMutability") // We can't use SaferPendingIntent private PendingIntent createCustomActionPendingIntent( MediaSession mediaSession, String action, Bundle extras) { Intent intent = new Intent(ACTION_CUSTOM); @@ -162,19 +177,6 @@ public boolean isCustomAction(Intent intent) { return ACTION_CUSTOM.equals(intent.getAction()); } - /** - * Returns the {@link KeyEvent} that was included in the media action, or {@code null} if no - * {@link KeyEvent} is found in the {@code intent}. - */ - @Nullable - public KeyEvent getKeyEvent(Intent intent) { - @Nullable Bundle extras = intent.getExtras(); - if (extras != null && extras.containsKey(Intent.EXTRA_KEY_EVENT)) { - return extras.getParcelable(Intent.EXTRA_KEY_EVENT); - } - return null; - } - /** * Returns the custom action that was included in the {@link #createCustomAction custom action}, * or {@code null} if no custom action is found in the {@code intent}. @@ -201,6 +203,7 @@ public Bundle getCustomActionExtras(Intent intent) { private static final class Api26 { private Api26() {} + @SuppressWarnings("PendingIntentMutability") // We can't use SaferPendingIntent public static PendingIntent createForegroundServicePendingIntent( Service service, int keyCode, Intent intent) { return PendingIntent.getForegroundService( 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 8093161bd84..4eaa932d9dd 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -17,14 +17,6 @@ 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; @@ -34,7 +26,6 @@ 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; @@ -74,7 +65,7 @@ private final NotificationManagerCompat notificationManagerCompat; private final Executor mainExecutor; private final Intent startSelfIntent; - private final Map controllerAndListenerMap; + private final Map> controllerMap; private int totalNotificationCount; @Nullable private MediaNotification mediaNotification; @@ -91,34 +82,30 @@ public MediaNotificationManager( Handler mainHandler = new Handler(Looper.getMainLooper()); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); - controllerAndListenerMap = new HashMap<>(); + controllerMap = new HashMap<>(); startedInForeground = false; } public void addSession(MediaSession session) { - if (controllerAndListenerMap.containsKey(session)) { + if (controllerMap.containsKey(session)) { return; } - MediaControllerListener controllerListener = - new MediaControllerListener(mediaSessionService, session); - PlayerListener playerListener = new PlayerListener(mediaSessionService, session); + MediaControllerListener listener = new MediaControllerListener(mediaSessionService, session); Bundle connectionHints = new Bundle(); connectionHints.putBoolean(KEY_MEDIA_NOTIFICATION_MANAGER, true); ListenableFuture controllerFuture = new MediaController.Builder(mediaSessionService, session.getToken()) .setConnectionHints(connectionHints) - .setListener(controllerListener) + .setListener(listener) .setApplicationLooper(Looper.getMainLooper()) .buildAsync(); - controllerAndListenerMap.put( - session, new ControllerAndListener(controllerFuture, playerListener)); + controllerMap.put(session, controllerFuture); controllerFuture.addListener( () -> { try { - // Assert connection success. - controllerFuture.get(/* time= */ 0, MILLISECONDS); - controllerListener.onConnected(shouldShowNotification(session)); - session.getImpl().addPlayerListener(playerListener); + MediaController controller = controllerFuture.get(/* time= */ 0, MILLISECONDS); + listener.onConnected(shouldShowNotification(session)); + controller.addListener(listener); } catch (CancellationException | ExecutionException | InterruptedException @@ -131,52 +118,9 @@ public void addSession(MediaSession session) { } public void removeSession(MediaSession session) { - 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; + @Nullable ListenableFuture future = controllerMap.remove(session); + if (future != null) { + MediaController.releaseFuture(future); } } @@ -210,11 +154,11 @@ public void updateNotification(MediaSession session, boolean startInForegroundRe int notificationSequence = ++totalNotificationCount; MediaController mediaNotificationController = null; - ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session); - if (controllerAndListener != null && controllerAndListener.controller.isDone()) { + ListenableFuture controller = controllerMap.get(session); + if (controller != null && controller.isDone()) { try { - mediaNotificationController = Futures.getDone(controllerAndListener.controller); - } catch (CancellationException | ExecutionException e) { + mediaNotificationController = Futures.getDone(controller); + } catch (ExecutionException e) { // Ignore. } } @@ -317,13 +261,13 @@ private boolean shouldShowNotification(MediaSession session) { @Nullable private MediaController getConnectedControllerForSession(MediaSession session) { - ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session); - if (controllerAndListener == null) { + ListenableFuture controller = controllerMap.get(session); + if (controller == null) { return null; } try { - return Futures.getDone(controllerAndListener.controller); - } catch (CancellationException | ExecutionException exception) { + return Futures.getDone(controller); + } catch (ExecutionException exception) { // We should never reach this. throw new IllegalStateException(exception); } @@ -361,7 +305,8 @@ public void onFailure(Throwable t) { } } - private static final class MediaControllerListener implements MediaController.Listener { + private static final class MediaControllerListener + implements MediaController.Listener, Player.Listener { private final MediaSessionService mediaSessionService; private final MediaSession session; @@ -399,18 +344,6 @@ public void onDisconnected(MediaController controller) { 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) { @@ -421,13 +354,8 @@ public void onEvents(Player player, Player.Events events) { Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED, Player.EVENT_TIMELINE_CHANGED)) { - // 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)); + mediaSessionService.onUpdateNotificationInternal( + session, /* startInForegroundWhenPaused= */ false); } } } @@ -457,17 +385,6 @@ private void stopForeground(boolean removeNotifications) { 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 5a9939bdc6d..c9cb5626af5 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -15,15 +15,28 @@ */ package androidx.media3.session; +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_SKIP_BACKWARD; +import static android.view.KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD; +import static android.view.KeyEvent.KEYCODE_MEDIA_STOP; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull; +import static androidx.media3.common.util.Util.SDK_INT; import static androidx.media3.common.util.Util.postOrRun; +import static androidx.media3.session.MediaSessionStub.UNKNOWN_SEQUENCE_NUMBER; import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED; import static androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN; import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED; import static java.lang.Math.min; import android.app.PendingIntent; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -37,6 +50,7 @@ import android.os.RemoteException; import android.os.SystemClock; import android.support.v4.media.session.MediaSessionCompat; +import android.view.KeyEvent; import androidx.annotation.CheckResult; import androidx.annotation.FloatRange; import androidx.annotation.GuardedBy; @@ -134,7 +148,6 @@ private boolean closed; // Should be only accessed on the application looper - private final List wrapperListeners; private long sessionPositionUpdateDelayMs; private boolean isMediaNotificationControllerConnected; private ImmutableList customLayout; @@ -161,7 +174,6 @@ public MediaSessionImpl( sessionStub = new MediaSessionStub(thisRef); this.sessionActivity = sessionActivity; this.customLayout = customLayout; - wrapperListeners = new ArrayList<>(); mainHandler = new Handler(Looper.getMainLooper()); applicationHandler = new Handler(player.getApplicationLooper()); @@ -240,38 +252,14 @@ public void setPlayer(Player player) { 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( @@ -303,10 +291,6 @@ public void release() { 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 @@ -1094,6 +1078,75 @@ private void handleAvailablePlayerCommandsChanged(Player.Commands availableComma (callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo)); } + /* package */ boolean onMediaButtonEvent(Intent intent) { + KeyEvent keyEvent = DefaultActionFactory.getKeyEvent(intent); + ComponentName intentComponent = intent.getComponent(); + if (!Objects.equals(intent.getAction(), Intent.ACTION_MEDIA_BUTTON) + || (intentComponent != null + && !Objects.equals(intentComponent.getPackageName(), context.getPackageName())) + || keyEvent == null + || keyEvent.getAction() != KeyEvent.ACTION_DOWN) { + return false; + } + ControllerInfo controllerInfo = getMediaNotificationControllerInfo(); + if (controllerInfo == null) { + if (intentComponent != null) { + // Fallback to legacy if this is a media button event sent to one of our components. + return getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent) + || SDK_INT < 21; + } + return false; + } + + Runnable command; + switch (keyEvent.getKeyCode()) { + case KEYCODE_MEDIA_PLAY_PAUSE: + command = + getPlayerWrapper().getPlayWhenReady() + ? () -> sessionStub.pauseForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER) + : () -> sessionStub.playForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_PLAY: + command = () -> sessionStub.playForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_PAUSE: + command = () -> sessionStub.pauseForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_NEXT: // Fall through. + case KEYCODE_MEDIA_SKIP_FORWARD: + command = + () -> sessionStub.seekToNextForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_PREVIOUS: // Fall through. + case KEYCODE_MEDIA_SKIP_BACKWARD: + command = + () -> + sessionStub.seekToPreviousForControllerInfo( + controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_FAST_FORWARD: + command = + () -> sessionStub.seekForwardForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_REWIND: + command = + () -> sessionStub.seekBackForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + case KEYCODE_MEDIA_STOP: + command = () -> sessionStub.stopForControllerInfo(controllerInfo, UNKNOWN_SEQUENCE_NUMBER); + break; + default: + return false; + } + postOrRun( + getApplicationHandler(), + () -> { + command.run(); + sessionStub.getConnectedControllersManager().flushCommandQueue(controllerInfo); + }); + return true; + } + /* @FunctionalInterface */ interface RemoteControllerTask { 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 9e87bb24625..7c30eebfaa4 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java @@ -31,7 +31,6 @@ import android.os.IBinder; import android.os.Looper; import android.os.RemoteException; -import android.view.KeyEvent; import androidx.annotation.CallSuper; import androidx.annotation.DoNotInline; import androidx.annotation.GuardedBy; @@ -157,7 +156,7 @@ default void onForegroundServiceStartNotAllowedException() {} /** The action for {@link Intent} filter that must be declared by the service. */ public static final String SERVICE_INTERFACE = "androidx.media3.session.MediaSessionService"; - private static final String TAG = "MSSImpl"; + private static final String TAG = "MSessionService"; private final Object lock; private final Handler mainHandler; @@ -426,9 +425,8 @@ public int onStartCommand(@Nullable Intent intent, int flags, int startId) { } addSession(session); } - @Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent); - if (keyEvent != null) { - getMediaNotificationManager().onMediaButtonEvent(session, keyEvent); + if (!session.getImpl().onMediaButtonEvent(intent)) { + Log.w(TAG, "Ignoring unrecognized media button intent."); } } 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 38a3e5b4a47..f6bb52666f5 100644 --- a/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/MediaSessionServiceTest.java @@ -39,6 +39,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -411,6 +412,9 @@ public void onEvents(Player player, Player.Events events) { serviceController.startCommand(/* flags= */ 0, /* startId= */ 0); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(service.callers).hasSize(1); + assertThat(service.session.isMediaNotificationController(service.callers.get(0))).isTrue(); + controller.release(); serviceController.destroy(); } @@ -501,21 +505,35 @@ public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) { private static final class TestServiceWithPlaybackResumption extends MediaSessionService { - private List mediaItems = ImmutableList.of(); + private final List callers; - public void setMediaItems(List mediaItems) { - this.mediaItems = mediaItems; + private ImmutableList mediaItems; + @Nullable private MediaSession session; + + public TestServiceWithPlaybackResumption() { + callers = new ArrayList<>(); + mediaItems = ImmutableList.of(); } - @Nullable private MediaSession session; + public void setMediaItems(List mediaItems) { + this.mediaItems = ImmutableList.copyOf(mediaItems); + } @Override public void onCreate() { super.onCreate(); Context context = ApplicationProvider.getApplicationContext(); ExoPlayer player = new TestExoPlayerBuilder(context).build(); + ForwardingPlayer forwardingPlayer = + new ForwardingPlayer(player) { + @Override + public void play() { + callers.add(session.getControllerForCurrentRequest()); + super.play(); + } + }; session = - new MediaSession.Builder(context, player) + new MediaSession.Builder(context, forwardingPlayer) .setCallback( new MediaSession.Callback() { @Override @@ -546,11 +564,14 @@ public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) { @Override public void onDestroy() { - session.getPlayer().stop(); - session.getPlayer().clearMediaItems(); - session.getPlayer().release(); - session.release(); - session = null; + if (session != null) { + session.getPlayer().stop(); + session.getPlayer().clearMediaItems(); + session.getPlayer().release(); + session.release(); + callers.clear(); + session = null; + } super.onDestroy(); } } diff --git a/libraries/test_session_current/build.gradle b/libraries/test_session_current/build.gradle index 1d4a1bd5c13..43bdb61753a 100644 --- a/libraries/test_session_current/build.gradle +++ b/libraries/test_session_current/build.gradle @@ -41,9 +41,10 @@ android { dependencies { implementation project(modulePrefix + 'lib-session') implementation project(modulePrefix + 'test-session-common') + implementation project(modulePrefix + 'test-data') implementation 'androidx.media:media:' + androidxMediaVersion - implementation 'androidx.test:core:' + androidxTestCoreVersion implementation 'androidx.multidex:multidex:' + androidxMultidexVersion + implementation 'androidx.test:core:' + androidxTestCoreVersion implementation project(modulePrefix + 'test-data') androidTestImplementation project(modulePrefix + 'lib-exoplayer') androidTestImplementation project(modulePrefix + 'test-utils') diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java index 7a013f98b9f..192ea93ba61 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java @@ -26,6 +26,7 @@ import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assert.fail; import android.content.Context; import android.os.Bundle; @@ -43,6 +44,7 @@ import androidx.media3.test.session.R; import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.MainLooperTestRule; +import androidx.media3.test.session.common.TestHandler; import androidx.media3.test.session.common.TestUtils; import androidx.media3.test.utils.TestExoPlayerBuilder; import androidx.test.core.app.ApplicationProvider; @@ -61,7 +63,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.After; -import org.junit.Assert; import org.junit.Before; import org.junit.ClassRule; import org.junit.Rule; @@ -73,16 +74,28 @@ @LargeTest public class MediaSessionCallbackTest { - private static final String TAG = "MSessionCallbackTest"; - + // Prepares the main looper. @ClassRule public static MainLooperTestRule mainLooperTestRule = new MainLooperTestRule(); - @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG); + @Rule + public final HandlerThreadTestRule playerThreadTestRule = + new HandlerThreadTestRule("MSessionCallbackTest:player"); - @Rule public final RemoteControllerTestRule controllerTestRule = new RemoteControllerTestRule(); + @Rule + public final HandlerThreadTestRule controllerThreadTestRule = + new HandlerThreadTestRule("MSessionCallbackTest:controller"); @Rule public final MediaSessionTestRule sessionTestRule = new MediaSessionTestRule(); + // Used to create controllers in the service running in a different process. + @Rule + public final RemoteControllerTestRule remoteControllerTestRule = new RemoteControllerTestRule(); + + // Used to create controllers on a different thread in the local process. + @Rule + public final MediaControllerTestRule controllerTestRule = + new MediaControllerTestRule(controllerThreadTestRule); + private Context context; private MockPlayer player; private ListeningExecutorService executorService; @@ -92,7 +105,7 @@ public void setUp() { context = ApplicationProvider.getApplicationContext(); player = new MockPlayer.Builder() - .setApplicationLooper(threadTestRule.getHandler().getLooper()) + .setApplicationLooper(playerThreadTestRule.getHandler().getLooper()) .build(); // Intentionally use an Executor with another thread to test asynchronous workflows involving // background tasks. @@ -129,7 +142,7 @@ public MediaSession.ConnectionResult onConnect( .setId("testOnConnect_correctControllerVersions") .build()); - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(controllerVersion.get()).isEqualTo(MediaLibraryInfo.VERSION_INT); @@ -185,7 +198,7 @@ public ListenableFuture onCustomCommand( "onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied") .build()); RemoteMediaController remoteController = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); ImmutableList layout = remoteController.getCustomLayout(); @@ -215,7 +228,7 @@ public MediaSession.ConnectionResult onConnect( .setId("onConnect_emptyPlayerCommands_commandReleaseAlwaysIncluded") .build()); RemoteMediaController remoteController = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); assertThat(remoteController.getAvailableCommands().size()).isEqualTo(1); assertThat(remoteController.getAvailableCommands().contains(Player.COMMAND_RELEASE)).isTrue(); @@ -237,7 +250,7 @@ public void onPostConnect(MediaSession session, ControllerInfo controller) { .setCallback(callback) .setId("testOnPostConnect_afterConnected") .build()); - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); } @@ -263,7 +276,7 @@ public void onPostConnect(MediaSession session, ControllerInfo controller) { .setCallback(callback) .setId("testOnPostConnect_afterConnectionRejected") .build()); - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); assertThat(latch.await(NO_RESPONSE_TIMEOUT_MS, MILLISECONDS)).isFalse(); } @@ -296,7 +309,7 @@ public int onPlayerCommandRequest( .setId("testOnCommandRequest") .build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.prepare(); Thread.sleep(NO_RESPONSE_TIMEOUT_MS); @@ -358,7 +371,7 @@ public ListenableFuture onCustomCommand( .setId("testOnCustomCommand") .build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); SessionResult result = controller.sendCustomCommand(testCommand, testArgs); assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); @@ -398,7 +411,7 @@ public ListenableFuture onSetRating( .setId("testOnSetRating") .build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); SessionResult result = controller.setRating(testMediaId, testRating); assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); @@ -434,7 +447,7 @@ public ListenableFuture onSetRating( .setId("testOnSetRating") .build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); SessionResult result = controller.setRating(testRating); assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); @@ -459,7 +472,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItem(mediaItem); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION, TIMEOUT_MS); @@ -476,7 +489,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.setMediaItemIncludeLocalConfiguration(mediaItemWithoutLocalConfiguration); @@ -498,7 +511,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.setMediaItemsIncludeLocalConfiguration(mediaItemsWithoutLocalConfiguration); @@ -518,7 +531,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.setMediaItemIncludeLocalConfiguration(mediaItemWithLocalConfiguration); @@ -538,7 +551,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.setMediaItemsIncludeLocalConfiguration(fullMediaItems); @@ -565,7 +578,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItem(mediaItem, /* startPositionMs= */ 1234); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX, TIMEOUT_MS); @@ -594,7 +607,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItem(mediaItem, /* resetPosition= */ true); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION, TIMEOUT_MS); @@ -622,7 +635,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItems(mediaItems); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION, TIMEOUT_MS); @@ -651,7 +664,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItems(mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 1234); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX, TIMEOUT_MS); @@ -682,7 +695,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItems(mediaItems, /* resetPosition= */ true); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION, TIMEOUT_MS); @@ -712,7 +725,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.addMediaItem(mediaItem); player.awaitMethodCalled(MockPlayer.METHOD_ADD_MEDIA_ITEMS, TIMEOUT_MS); @@ -729,7 +742,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.addMediaItemIncludeLocalConfiguration(mediaItemWithoutLocalConfiguration); @@ -750,7 +763,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.addMediaItemsIncludeLocalConfiguration(mediaItemsWithoutLocalConfiguration); @@ -769,7 +782,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.addMediaItemIncludeLocalConfiguration(mediaItemWithLocalConfiguration); @@ -789,7 +802,7 @@ public ListenableFuture> onAddMediaItems( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); // Default MediaSession.Callback.onAddMediaItems will be called controller.addMediaItemsIncludeLocalConfiguration(fullMediaItems); @@ -817,7 +830,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItem(existingItem); controller.addMediaItem(/* index= */ 1, mediaItem); @@ -849,7 +862,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.addMediaItems(mediaItems); player.awaitMethodCalled(MockPlayer.METHOD_ADD_MEDIA_ITEMS, TIMEOUT_MS); @@ -879,7 +892,7 @@ public ListenableFuture> onAddMediaItems( sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItem(existingItem); controller.addMediaItems(/* index= */ 1, mediaItems); @@ -922,7 +935,7 @@ public ListenableFuture onSetMediaItem sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItem(mediaItem, /* startPositionMs= */ 100); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX, TIMEOUT_MS); @@ -960,7 +973,7 @@ public ListenableFuture onSetMediaItem sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItems(mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 100); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX, TIMEOUT_MS); @@ -1000,7 +1013,7 @@ public ListenableFuture onSetMediaItem sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItems(mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 100); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION, TIMEOUT_MS); @@ -1039,7 +1052,7 @@ public ListenableFuture onSetMediaItem sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.setMediaItems(mediaItems, true); player.awaitMethodCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_RESET_POSITION, TIMEOUT_MS); @@ -1077,7 +1090,7 @@ public ListenableFuture onPlaybackResu sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.play(); @@ -1098,7 +1111,7 @@ public void onPlay_withEmptyTimelineCallbackFailure_callsHandlePlayButtonAction( MediaSession session = sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.play(); @@ -1122,7 +1135,7 @@ public void onPlay_withNonEmptyTimeline_callsHandlePlayButtonAction() throws Exc @Override public ListenableFuture onPlaybackResumption( MediaSession mediaSession, ControllerInfo controller) { - Assert.fail(); + fail(); return Futures.immediateFuture( new MediaSession.MediaItemsWithStartPosition( MediaTestUtils.createMediaItems(/* size= */ 10), @@ -1134,7 +1147,7 @@ public ListenableFuture onPlaybackResu sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player).setCallback(callback).build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.play(); @@ -1173,7 +1186,7 @@ public MediaSession.ConnectionResult onConnect( Bundle testConnectionHints = new Bundle(); testConnectionHints.putString("test_key", "test_value"); - controllerTestRule.createRemoteController( + remoteControllerTestRule.createRemoteController( session.getToken(), /* waitForConnection= */ false, testConnectionHints); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(TestUtils.equals(testConnectionHints, connectionHints.get())).isTrue(); @@ -1199,20 +1212,21 @@ public void onDisconnected(MediaSession session, ControllerInfo controller) { }) .build()); RemoteMediaController controller = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); controller.release(); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); } @Test - public void seekToNextMediaItem_inProcessController_correctMediaItemTransitionsEvents() - throws Exception { + public void + seekToNextMediaItem_controllerListenerTriggeredByMasking_commandNotYetArrivedAtSession() + throws Exception { MediaItem mediaItem1 = new MediaItem.Builder().setMediaId("id1").setUri("http://www.example.com/1").build(); MediaItem mediaItem2 = new MediaItem.Builder().setMediaId("id2").setUri("http://www.example.com/2").build(); ExoPlayer testPlayer = - threadTestRule + playerThreadTestRule .getHandler() .postAndSync( () -> { @@ -1220,48 +1234,174 @@ public void seekToNextMediaItem_inProcessController_correctMediaItemTransitionsE exoPlayer.setMediaItems(ImmutableList.of(mediaItem1, mediaItem2)); return exoPlayer; }); - List capturedMediaItemIds = new ArrayList<>(); - List capturedEvents = new ArrayList<>(); + List currentMediaItemsOfPlayer = new ArrayList<>(); + AtomicReference controller = new AtomicReference<>(); List eventOrder = new ArrayList<>(); - CountDownLatch latch = new CountDownLatch(1); + CountDownLatch latch = new CountDownLatch(2); + // Listener added to player before the the session is built and the session adds a listener. + testPlayer.addListener( + new Player.Listener() { + @Override + public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { + currentMediaItemsOfPlayer.add(testPlayer.getCurrentMediaItem()); + eventOrder.add("player.onMediaItemTransition"); + } + + @Override + public void onEvents(Player player, Player.Events events) { + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + // Player still has the first item. Command has not yet arrived at the session. + currentMediaItemsOfPlayer.add(testPlayer.getCurrentMediaItem()); + eventOrder.add("player.onEvents"); + latch.countDown(); + } + } + }); MediaSession session = sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, testPlayer) - .setId("seekToNextMediaItem_inProcessController_correctMediaItemTransitionsEvents") + .setId( + "listener_controllerListenerTriggeredByMasking_commandNotYetArrivedAtSession") .build()); - MediaController controller = - new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken()) - .setApplicationLooper(threadTestRule.getHandler().getLooper()) - .buildAsync() - .get(); - controller.addListener( + controller.set(controllerTestRule.createController(session.getToken())); + controller + .get() + .addListener( + /* listener= */ new Player.Listener() { + @Override + public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { + eventOrder.add("controller.onMediaItemTransition"); + postToPlayerAndSync( + () -> currentMediaItemsOfPlayer.add(testPlayer.getCurrentMediaItem())); + } + + @Override + public void onEvents(Player player, Player.Events events) { + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + // Triggered by masking in the same looper iteration as where + // controller.seekToNextMediaItem() is called. + eventOrder.add("controller.onEvents"); + postToPlayerAndSync( + () -> currentMediaItemsOfPlayer.add(testPlayer.getCurrentMediaItem())); + latch.countDown(); + } + } + }); + + postToControllerAndSync(controller.get()::seekToNextMediaItem); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(currentMediaItemsOfPlayer) + .containsExactly(mediaItem1, mediaItem1, mediaItem2, mediaItem2) + .inOrder(); + assertThat(eventOrder) + .containsExactly( + "controller.onMediaItemTransition", + "controller.onEvents", + "player.onMediaItemTransition", + "player.onEvents") + .inOrder(); + postToControllerAndSync(() -> controller.get().release()); + } + + @Test + public void seekToNextMediaItem_playerListenerTriggeredByMasking_immediateCallHasStaleController() + throws Exception { + MediaItem mediaItem1 = + new MediaItem.Builder().setMediaId("id1").setUri("http://www.example.com/1").build(); + MediaItem mediaItem2 = + new MediaItem.Builder().setMediaId("id2").setUri("http://www.example.com/2").build(); + ExoPlayer testPlayer = + playerThreadTestRule + .getHandler() + .postAndSync( + () -> { + ExoPlayer exoPlayer = new TestExoPlayerBuilder(context).build(); + exoPlayer.setMediaItems(ImmutableList.of(mediaItem1, mediaItem2)); + return exoPlayer; + }); + List currentMediaIdsOfController = new ArrayList<>(); + List eventOrder = new ArrayList<>(); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference controller = new AtomicReference<>(); + // Listener added to player before the the session is built and the session adds a listener. + testPlayer.addListener( new Player.Listener() { @Override public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { - capturedMediaItemIds.add(controller.getCurrentMediaItem().mediaId); - eventOrder.add("onMediaItemTransition"); + postToControllerAndSync( + () -> + currentMediaIdsOfController.add( + controller.get().getCurrentMediaItem().mediaId)); + eventOrder.add("player.onMediaItemTransition"); } @Override public void onEvents(Player player, Player.Events events) { if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { - capturedMediaItemIds.add(controller.getCurrentMediaItem().mediaId); - capturedEvents.add(events); - eventOrder.add("onEvents"); + postToControllerAndSync( + () -> + currentMediaIdsOfController.add( + controller.get().getCurrentMediaItem().mediaId)); + eventOrder.add("player.onEvents"); latch.countDown(); } } }); - - threadTestRule.getHandler().postAndSync(testPlayer::seekToNextMediaItem); + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, testPlayer) + .setId( + "listener_playerListenerTriggeredByMasking_statusUpdateArrivedAtSameProcessController") + .build()); + controller.set(controllerTestRule.createController(session.getToken())); + controller + .get() + .addListener( + new Player.Listener() { + @Override + public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) { + currentMediaIdsOfController.add(controller.get().getCurrentMediaItem().mediaId); + eventOrder.add("controller.onMediaItemTransition"); + } + + @Override + public void onEvents(Player player, Player.Events events) { + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + currentMediaIdsOfController.add(controller.get().getCurrentMediaItem().mediaId); + eventOrder.add("controller.onEvents"); + latch.countDown(); + } + } + }); + + postToPlayerAndSync(testPlayer::seekToNextMediaItem); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); - assertThat(capturedMediaItemIds).containsExactly("id2", "id2").inOrder(); - assertThat(eventOrder).containsExactly("onMediaItemTransition", "onEvents").inOrder(); - assertThat(capturedEvents).hasSize(1); - assertThat(capturedEvents.get(0).size()).isEqualTo(2); - assertThat(capturedEvents.get(0).contains(Player.EVENT_MEDIA_ITEM_TRANSITION)).isTrue(); - assertThat(capturedEvents.get(0).contains(Player.EVENT_POSITION_DISCONTINUITY)).isTrue(); + assertThat(currentMediaIdsOfController).containsExactly("id1", "id2", "id2", "id2").inOrder(); + assertThat(eventOrder) + .containsExactly( + "player.onMediaItemTransition", + "controller.onMediaItemTransition", + "controller.onEvents", + "player.onEvents") + .inOrder(); + } + + private void postToPlayerAndSync(TestHandler.TestRunnable r) { + try { + playerThreadTestRule.getHandler().postAndSync(r); + } catch (Exception e) { + fail(e.getMessage()); + } + } + + private void postToControllerAndSync(TestHandler.TestRunnable r) { + try { + controllerThreadTestRule.getHandler().postAndSync(r); + } catch (Exception e) { + fail(e.getMessage()); + } } private static MediaItem updateMediaItemWithLocalConfiguration(MediaItem mediaItem) { diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java index b8f4ff04c46..71a355df510 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java @@ -15,6 +15,13 @@ */ package androidx.media3.session; +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_PREVIOUS; +import static android.view.KeyEvent.KEYCODE_MEDIA_REWIND; +import static android.view.KeyEvent.KEYCODE_MEDIA_STOP; import static androidx.media3.common.Player.STATE_IDLE; import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; @@ -23,7 +30,9 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.Assert.assertThrows; +import android.content.ComponentName; import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.os.HandlerThread; import android.os.Looper; @@ -31,11 +40,14 @@ import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.text.TextUtils; +import android.view.KeyEvent; import androidx.media.MediaSessionManager; +import androidx.media3.common.ForwardingPlayer; import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.Player; import androidx.media3.common.util.Log; import androidx.media3.common.util.Util; +import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.MainLooperTestRule; import androidx.media3.test.session.common.TestHandler; @@ -49,6 +61,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.After; import org.junit.Before; import org.junit.ClassRule; @@ -82,7 +95,6 @@ public void setUp() throws Exception { context = ApplicationProvider.getApplicationContext(); handler = threadTestRule.getHandler(); player = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build(); - session = sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player) @@ -91,7 +103,7 @@ public void setUp() throws Exception { new MediaSession.Callback() { @Override public MediaSession.ConnectionResult onConnect( - MediaSession session, MediaSession.ControllerInfo controller) { + MediaSession session, ControllerInfo controller) { if (TextUtils.equals( context.getPackageName(), controller.getPackageName())) { return MediaSession.Callback.super.onConnect(session, controller); @@ -149,7 +161,8 @@ public void builder() { // expected. pass-through } // Empty string as ID is allowed. - new MediaSession.Builder(context, player).setId("").build().release(); + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player).setId("").build()); } @Test @@ -328,7 +341,7 @@ public void sendCustomCommand_onConnect() throws Exception { new MediaSession.Callback() { @Override public MediaSession.ConnectionResult onConnect( - MediaSession session, MediaSession.ControllerInfo controller) { + MediaSession session, ControllerInfo controller) { Future result = session.sendCustomCommand(controller, testCommand, /* args= */ Bundle.EMPTY); try { @@ -342,7 +355,7 @@ public MediaSession.ConnectionResult onConnect( } @Override - public void onPostConnect(MediaSession session, MediaSession.ControllerInfo controller) { + public void onPostConnect(MediaSession session, ControllerInfo controller) { Future result = session.sendCustomCommand(controller, testCommand, /* args= */ Bundle.EMPTY); try { @@ -365,10 +378,6 @@ public void onPostConnect(MediaSession session, MediaSession.ControllerInfo cont /** Test {@link MediaSession#getSessionCompatToken()}. */ @Test public void getSessionCompatToken_returnsCompatibleWithMediaControllerCompat() throws Exception { - String expectedControllerCompatPackageName = - (21 <= Util.SDK_INT && Util.SDK_INT < 24) - ? MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER - : context.getPackageName(); MediaSession session = sessionTestRule.ensureReleaseAfterTest( new MediaSession.Builder(context, player) @@ -377,9 +386,10 @@ public void getSessionCompatToken_returnsCompatibleWithMediaControllerCompat() t new MediaSession.Callback() { @Override public MediaSession.ConnectionResult onConnect( - MediaSession session, MediaSession.ControllerInfo controller) { + MediaSession session, ControllerInfo controller) { if (TextUtils.equals( - expectedControllerCompatPackageName, controller.getPackageName())) { + getControllerCallerPackageName(controller), + controller.getPackageName())) { return MediaSession.Callback.super.onConnect(session, controller); } return MediaSession.ConnectionResult.reject(); @@ -416,7 +426,7 @@ public void getControllerVersion() throws Exception { new MediaSession.Callback() { @Override public MediaSession.ConnectionResult onConnect( - MediaSession session, MediaSession.ControllerInfo controller) { + MediaSession session, ControllerInfo controller) { controllerVersionRef.set(controller.getControllerVersion()); connectedLatch.countDown(); return MediaSession.Callback.super.onConnect(session, controller); @@ -494,4 +504,302 @@ public void setPeriodicPositionUpdateEnabled_periodicUpdatesDisabled_bufferedPos assertThat(bufferedPositionsMs).containsExactly(0L, 0L, 0L, 0L, 0L).inOrder(); } + + @Test + public void onMediaButtonEvent_allSupportedKeys_notificationControllerConnected_dispatchesEvent() + throws Exception { + AtomicReference session = new AtomicReference<>(); + CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(player, session); + session.set( + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, callerCollectorPlayer) + .setId("getSessionCompatToken_returnsCompatibleWithMediaControllerCompat") + .setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + if (TextUtils.equals( + context.getPackageName(), controller.getPackageName())) { + return MediaSession.Callback.super.onConnect(session, controller); + } + return MediaSession.ConnectionResult.reject(); + } + }) + .build())); + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true); + new MediaController.Builder( + ApplicationProvider.getApplicationContext(), session.get().getToken()) + .setConnectionHints(connectionHints) + .buildAsync() + .get(); + + MediaSessionImpl impl = session.get().getImpl(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PLAY))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_REWIND))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_NEXT))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_STOP))).isTrue(); + + player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS); + assertThat(callerCollectorPlayer.callingControllers).hasSize(7); + for (ControllerInfo controllerInfo : callerCollectorPlayer.callingControllers) { + assertThat(session.get().isMediaNotificationController(controllerInfo)).isTrue(); + } + } + + @Test + public void + onMediaButtonEvent_allSupportedKeys_notificationControllerNotConnected_dispatchesEventThroughFrameworkFallback() + throws Exception { + AtomicReference session = new AtomicReference<>(); + CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(player, session); + session.set( + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, callerCollectorPlayer) + .setId("getSessionCompatToken_returnsCompatibleWithMediaControllerCompat") + .setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + if (TextUtils.equals( + getControllerCallerPackageName(controller), + controller.getPackageName())) { + return MediaSession.Callback.super.onConnect(session, controller); + } + return MediaSession.ConnectionResult.reject(); + } + }) + .build())); + MediaSessionImpl impl = session.get().getImpl(); + + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PLAY))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_REWIND))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_NEXT))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))).isTrue(); + assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_STOP))).isTrue(); + + // Fallback code path through platform session when MediaSessionImpl doesn't handle the event. + player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS); + player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS); + assertThat(callerCollectorPlayer.callingControllers).hasSize(7); + for (ControllerInfo controllerInfo : callerCollectorPlayer.callingControllers) { + assertThat(session.get().isMediaNotificationController(controllerInfo)).isFalse(); + assertThat(controllerInfo.getControllerVersion()) + .isEqualTo(ControllerInfo.LEGACY_CONTROLLER_VERSION); + assertThat(controllerInfo.getPackageName()) + .isEqualTo(getControllerCallerPackageName(controllerInfo)); + } + } + + @Test + public void onMediaButtonEvent_noKeyEvent_returnsFalse() { + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.removeExtra(Intent.EXTRA_KEY_EVENT); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void onMediaButtonEvent_noKeyEvent_mediaNotificationControllerConnected_returnsFalse() + throws Exception { + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true); + new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken()) + .setConnectionHints(connectionHints) + .buildAsync() + .get(); + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.removeExtra(Intent.EXTRA_KEY_EVENT); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void onMediaButtonEvent_invalidKeyEvent_returnsFalse() { + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.removeExtra(Intent.EXTRA_KEY_EVENT); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_UP, KEYCODE_MEDIA_PAUSE)); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void onMediaButtonEvent_invalidKeyEvent_mediaNotificationControllerConnected_returnsFalse() + throws Exception { + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true); + new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken()) + .setConnectionHints(connectionHints) + .buildAsync() + .get(); + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.removeExtra(Intent.EXTRA_KEY_EVENT); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_UP, KEYCODE_MEDIA_PAUSE)); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void onMediaButtonEvent_invalidAction_returnsFalse() { + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.setAction("notAMediaButtonAction"); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void onMediaButtonEvent_invalidAction_mediaNotificationControllerConnected_returnsFalse() + throws Exception { + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true); + new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken()) + .setConnectionHints(connectionHints) + .buildAsync() + .get(); + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.setAction("notAMediaButtonAction"); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void onMediaButtonEvent_invalidComponent_returnsFalse() { + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.setComponent(new ComponentName("a.package", "a.class")); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + @Test + public void + onMediaButtonEvent_invalidComponent_mediaNotificationControllerConnected_returnsFalse() + throws Exception { + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true); + new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken()) + .setConnectionHints(connectionHints) + .buildAsync() + .get(); + Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); + intent.setComponent(new ComponentName("a.package", "a.class")); + + boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); + + assertThat(isEventHandled).isFalse(); + } + + private static Intent getMediaButtonIntent(int keyCode) { + Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); + intent.setComponent( + new ComponentName(ApplicationProvider.getApplicationContext(), Object.class)); + intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); + return intent; + } + + /** + * Returns the expected {@link MediaSessionManager.RemoteUserInfo#getPackageName()} of a + * controller hosted in the test companion app. + * + *

Before API 21 and after API 23 the package name is {@link Context#getPackageName()} of the + * {@link ApplicationProvider#getApplicationContext() application under test}. + * + *

The early implementations (API 21 - 23), the platform MediaSession doesn't report the caller + * package name. Instead the package of the RemoteUserInfo is set for all external controllers to + * the same {@code MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER} (see + * MediaSessionCompat.MediaSessionCallbackApi21.setCurrentControllerInfo()). + * + *

Calling this method should only be required to test legacy behaviour. + */ + private static String getControllerCallerPackageName(ControllerInfo controllerInfo) { + return (Util.SDK_INT < 21 + || Util.SDK_INT > 23 + || controllerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION) + ? ApplicationProvider.getApplicationContext().getPackageName() + : MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER; + } + + private static class CallerCollectorPlayer extends ForwardingPlayer { + private final List callingControllers; + private final AtomicReference session; + + public CallerCollectorPlayer(Player player, AtomicReference mediaSession) { + super(player); + this.session = mediaSession; + callingControllers = new ArrayList<>(); + } + + @Override + public void play() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.play(); + } + + @Override + public void pause() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.pause(); + } + + @Override + public void seekBack() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.seekBack(); + } + + @Override + public void seekForward() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.seekForward(); + } + + @Override + public void seekToNext() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.seekToNext(); + } + + @Override + public void seekToPrevious() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.seekToPrevious(); + } + + @Override + public void stop() { + callingControllers.add(session.get().getControllerForCurrentRequest()); + super.stop(); + } + } }