From 64bd3bcad3fa4b0e433b16d583456920afad3ce2 Mon Sep 17 00:00:00 2001 From: bachinger Date: Mon, 16 Oct 2023 13:50:12 -0700 Subject: [PATCH] Send media button events from service directly using `MediaSessionImpl` Media button event coming from the `MediaSessionService` are delegated to the `MediaSessionImpl` and then sent to the session by using the `MediaSessionStub` directly instead of using the `MediaController` API. Splitting the `MediaController.Listener` and `Player.Listener` in `MediaNotificationManager` got reverted, and both listener are set to the controller as before. This reverts the change that introduced a different timing behaviour. It still holds, that a listener registered on a `MediaController` that calls a method like `play()` is called immediately and before the call has arrived at the player. This change works around this behaviour from the library side by calling `MediaSessionStub` directly with a `ControllerInfo`. #minor-release PiperOrigin-RevId: 573918850 --- .../media3/session/DefaultActionFactory.java | 29 +- .../session/MediaNotificationManager.java | 131 ++----- .../media3/session/MediaSessionImpl.java | 113 ++++-- .../media3/session/MediaSessionService.java | 8 +- .../media3/session/MediaSessionStub.java | 127 +++++-- .../session/MediaSessionServiceTest.java | 41 ++- libraries/test_session_current/build.gradle | 3 +- .../session/MediaSessionCallbackTest.java | 284 +++++++++++---- .../media3/session/MediaSessionTest.java | 332 +++++++++++++++++- 9 files changed, 791 insertions(+), 277 deletions(-) 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 ed65296b88..646571cfd0 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 @@ import androidx.media3.common.util.Util; 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 @@ import androidx.media3.common.util.Util; 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 @@ import androidx.media3.common.util.Util; 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 @@ import androidx.media3.common.util.Util; 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 @@ import androidx.media3.common.util.Util; 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 8093161bd8..4eaa932d9d 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 @@ 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; @@ -34,7 +26,6 @@ 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; @@ -74,7 +65,7 @@ import java.util.concurrent.TimeoutException; 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 @@ import java.util.concurrent.TimeoutException; 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 @@ import java.util.concurrent.TimeoutException; } 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 @@ import java.util.concurrent.TimeoutException; 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 @@ import java.util.concurrent.TimeoutException; @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 @@ import java.util.concurrent.TimeoutException; } } - 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 @@ import java.util.concurrent.TimeoutException; 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 @@ import java.util.concurrent.TimeoutException; 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 @@ 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 29efaaa2ab..bb5dedbcff 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.Process; 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 @@ 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; @@ -161,7 +174,6 @@ 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()); @@ -240,38 +252,14 @@ 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( @@ -303,10 +291,6 @@ 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 @@ -1089,6 +1073,75 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; (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 9e87bb2462..7c30eebfaa 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.Handler; 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 @@ public abstract class MediaSessionService extends Service { /** 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 abstract class MediaSessionService extends Service { } 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/main/java/androidx/media3/session/MediaSessionStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java index f2c2c3797e..0e4210cc03 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -117,6 +117,12 @@ import java.util.concurrent.ExecutionException; /** The version of the IMediaSession interface. */ public static final int VERSION_INT = 2; + /** + * Sequence number used when a controller method is triggered on the sesison side that wasn't + * initiated by the controller itself. + */ + public static final int UNKNOWN_SEQUENCE_NUMBER = Integer.MIN_VALUE; + private final WeakReference sessionImpl; private final MediaSessionManager sessionManager; private final ConnectedControllersManager connectedControllersManager; @@ -285,6 +291,18 @@ import java.util.concurrent.ExecutionException; int sequenceNumber, @Player.Command int command, SessionTask, K> task) { + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controllerInfo, sequenceNumber, command, task); + } + } + + private void queueSessionTaskWithPlayerCommandForControllerInfo( + ControllerInfo controller, + int sequenceNumber, + @Player.Command int command, + SessionTask, K> task) { long token = Binder.clearCallingIdentity(); try { @SuppressWarnings({"unchecked", "cast.unsafe"}) @@ -293,11 +311,6 @@ import java.util.concurrent.ExecutionException; if (sessionImpl == null || sessionImpl.isReleased()) { return; } - @Nullable - ControllerInfo controller = connectedControllersManager.getController(caller.asBinder()); - if (controller == null) { - return; - } postOrRun( sessionImpl.getApplicationHandler(), () -> { @@ -621,8 +634,19 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } - queueSessionTaskWithPlayerCommand( - caller, sequenceNumber, COMMAND_STOP, sendSessionResultSuccess(player -> player.stop())); + @Nullable + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + stopForControllerInfo(controllerInfo, sequenceNumber); + } + } + + public void stopForControllerInfo(ControllerInfo controllerInfo, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controllerInfo, + sequenceNumber, + COMMAND_STOP, + sendSessionResultSuccess(PlayerWrapper::stop)); } @Override @@ -677,27 +701,30 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } + @Nullable ControllerInfo controller = connectedControllersManager.getController(caller.asBinder()); - if (controller == null) { - return; + if (controller != null) { + playForControllerInfo(controller, sequenceNumber); } - queueSessionTaskWithPlayerCommand( - caller, + } + + public void playForControllerInfo(ControllerInfo controller, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controller, sequenceNumber, COMMAND_PLAY_PAUSE, sendSessionResultSuccess( player -> { - @Nullable MediaSessionImpl sessionImpl = this.sessionImpl.get(); - if (sessionImpl == null || sessionImpl.isReleased()) { + @Nullable MediaSessionImpl impl = sessionImpl.get(); + if (impl == null || impl.isReleased()) { return; } - if (sessionImpl.onPlayRequested()) { + if (impl.onPlayRequested()) { if (player.getMediaItemCount() == 0) { // The player is in IDLE or ENDED state and has no media items in the playlist - // yet. - // Handle the play command as a playback resumption command to try resume + // yet. Handle the play command as a playback resumption command to try resume // playback. - sessionImpl.prepareAndPlayForPlaybackResumption(controller, player); + impl.prepareAndPlayForPlaybackResumption(controller, player); } else { Util.handlePlayButtonAction(player); } @@ -710,8 +737,16 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } - queueSessionTaskWithPlayerCommand( - caller, sequenceNumber, COMMAND_PLAY_PAUSE, sendSessionResultSuccess(Player::pause)); + @Nullable + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + pauseForControllerInfo(controllerInfo, sequenceNumber); + } + } + + public void pauseForControllerInfo(ControllerInfo controller, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controller, sequenceNumber, COMMAND_PLAY_PAUSE, sendSessionResultSuccess(Player::pause)); } @Override @@ -787,8 +822,19 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } - queueSessionTaskWithPlayerCommand( - caller, sequenceNumber, COMMAND_SEEK_BACK, sendSessionResultSuccess(Player::seekBack)); + @Nullable + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + seekBackForControllerInfo(controllerInfo, sequenceNumber); + } + } + + public void seekBackForControllerInfo(ControllerInfo controllerInfo, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controllerInfo, + sequenceNumber, + COMMAND_SEEK_BACK, + sendSessionResultSuccess(Player::seekBack)); } @Override @@ -796,8 +842,16 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } - queueSessionTaskWithPlayerCommand( - caller, + @Nullable + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + seekForwardForControllerInfo(controllerInfo, sequenceNumber); + } + } + + public void seekForwardForControllerInfo(ControllerInfo controllerInfo, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controllerInfo, sequenceNumber, COMMAND_SEEK_FORWARD, sendSessionResultSuccess(Player::seekForward)); @@ -1365,8 +1419,16 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } - queueSessionTaskWithPlayerCommand( - caller, + @Nullable + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + seekToPreviousForControllerInfo(controllerInfo, sequenceNumber); + } + } + + public void seekToPreviousForControllerInfo(ControllerInfo controllerInfo, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controllerInfo, sequenceNumber, COMMAND_SEEK_TO_PREVIOUS, sendSessionResultSuccess(Player::seekToPrevious)); @@ -1377,8 +1439,19 @@ import java.util.concurrent.ExecutionException; if (caller == null) { return; } - queueSessionTaskWithPlayerCommand( - caller, sequenceNumber, COMMAND_SEEK_TO_NEXT, sendSessionResultSuccess(Player::seekToNext)); + @Nullable + ControllerInfo controllerInfo = connectedControllersManager.getController(caller.asBinder()); + if (controllerInfo != null) { + seekToNextForControllerInfo(controllerInfo, sequenceNumber); + } + } + + public void seekToNextForControllerInfo(ControllerInfo controllerInfo, int sequenceNumber) { + queueSessionTaskWithPlayerCommandForControllerInfo( + controllerInfo, + sequenceNumber, + COMMAND_SEEK_TO_NEXT, + sendSessionResultSuccess(Player::seekToNext)); } @Override 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 38a3e5b4a4..f6bb52666f 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 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.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -411,6 +412,9 @@ public class MediaSessionServiceTest { 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 class MediaSessionServiceTest { 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 class MediaSessionServiceTest { @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 1d4a1bd5c1..43bdb61753 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 7a013f98b9..192ea93ba6 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.NO_RESPONSE_TIMEOUT_ 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.session.MediaSession.ControllerInfo; 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.Executors; 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 @@ import org.junit.runner.RunWith; @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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { "onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied") .build()); RemoteMediaController remoteController = - controllerTestRule.createRemoteController(session.getToken()); + remoteControllerTestRule.createRemoteController(session.getToken()); ImmutableList layout = remoteController.getCustomLayout(); @@ -215,7 +228,7 @@ public class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { .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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { @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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { 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 class MediaSessionCallbackTest { }) .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 class MediaSessionCallbackTest { 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); - MediaSession session = - sessionTestRule.ensureReleaseAfterTest( - new MediaSession.Builder(context, testPlayer) - .setId("seekToNextMediaItem_inProcessController_correctMediaItemTransitionsEvents") - .build()); - MediaController controller = - new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken()) - .setApplicationLooper(threadTestRule.getHandler().getLooper()) - .buildAsync() - .get(); - controller.addListener( + 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) { - capturedMediaItemIds.add(controller.getCurrentMediaItem().mediaId); - eventOrder.add("onMediaItemTransition"); + 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)) { - capturedMediaItemIds.add(controller.getCurrentMediaItem().mediaId); - capturedEvents.add(events); - eventOrder.add("onEvents"); + // 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( + "listener_controllerListenerTriggeredByMasking_commandNotYetArrivedAtSession") + .build()); + 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())); + } - threadTestRule.getHandler().postAndSync(testPlayer::seekToNextMediaItem); + @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(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(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) { + 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)) { + postToControllerAndSync( + () -> + currentMediaIdsOfController.add( + controller.get().getCurrentMediaItem().mediaId)); + eventOrder.add("player.onEvents"); + latch.countDown(); + } + } + }); + 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(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 b8f4ff04c4..71a355df51 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 com.google.common.truth.Truth.assertWithMessage; 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.os.SystemClock; 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.ExecutionException; 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 class MediaSessionTest { 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 class MediaSessionTest { 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 class MediaSessionTest { // 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 class MediaSessionTest { 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 class MediaSessionTest { } @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 class MediaSessionTest { /** 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 class MediaSessionTest { 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 class MediaSessionTest { 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 class MediaSessionTest { 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(); + } + } }