From 742410d51705159c32da20bf36c1292ff796402e Mon Sep 17 00:00:00 2001 From: bachinger Date: Fri, 22 Sep 2023 06:34:20 -0700 Subject: [PATCH] Use proxy controller to maintain platform session and notification With this change, the notification controller that is connected by `MediaNotificationManager`, is used as a proxy controller of the System UI controller. An app can use the proxy at connection time and during the lifetime of the session for configuration of the platform session and the media notification on all API levels. This includes using custom layout and available player and session commands of the proxy to maintain the platform session (actions, custom actions, session extras) and the `MediaNotification.Provider`. The legacy System UI controller is hidden from the public API, instead the app interacts with the Media3 proxy: - System UI is hidden from `MediaSession.getConnectedControllers()`. - Calls from System UI to methods of `MediaSession.Callback`/ `MediaLibrarySession.Callback` are mapped to the `ControllerInfo` of the proxy controller. - When `getControllerForCurrentRequest()` is called during an operation of System UI the proxy `ControllerInfo` is returned. PiperOrigin-RevId: 567606117 --- RELEASENOTES.md | 3 + .../media3/demo/session/PlaybackService.kt | 50 ++--- .../media3/session/CommandButton.java | 1 + .../session/MediaLibrarySessionImpl.java | 35 +++- .../androidx/media3/session/MediaSession.java | 55 +++-- .../media3/session/MediaSessionImpl.java | 190 ++++++++++++++---- .../session/MediaSessionLegacyStub.java | 17 +- .../media3/session/PlayerWrapper.java | 47 ++++- .../media3/session/SessionCommand.java | 1 + .../media3/session/PlayerWrapperTest.java | 9 +- .../session/common/MediaSessionConstants.java | 3 +- libraries/test_session_current/build.gradle | 1 + ...lerCompatCallbackWithMediaSessionTest.java | 79 ++++---- ...tateCompatActionsWithMediaSessionTest.java | 135 +++++++++++-- .../session/MediaSessionServiceTest.java | 100 +++++++++ .../session/MediaSessionProviderService.java | 68 ++++++- 16 files changed, 616 insertions(+), 178 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 80ab9c83a3..4ec0650b4a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -67,6 +67,9 @@ `android.media.session.MediaSession.setMediaButtonBroadcastReceiver()` above API 31 to avoid problems with deprecated API on Samsung devices ([#167](https://github.com/androidx/media/issues/167)). + * Use the media notification controller as proxy to set available commands + and custom layout used to populate the notification and the platform + session. * UI: * Downloads: * OkHttp Extension: diff --git a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt index 1aed43de37..fa27dbe2e9 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt @@ -33,7 +33,6 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.* -import androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED import androidx.media3.session.MediaSession.ConnectionResult import androidx.media3.session.MediaSession.ControllerInfo import com.google.common.collect.ImmutableList @@ -45,7 +44,7 @@ class PlaybackService : MediaLibraryService() { private lateinit var player: ExoPlayer private lateinit var mediaLibrarySession: MediaLibrarySession - private lateinit var customCommands: List + private lateinit var customLayoutCommandButtons: List companion object { private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = @@ -60,7 +59,7 @@ class PlaybackService : MediaLibraryService() { @OptIn(UnstableApi::class) // MediaSessionService.setListener override fun onCreate() { super.onCreate() - customCommands = + customLayoutCommandButtons = listOf( getShuffleCommandButton( SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY) @@ -100,33 +99,47 @@ class PlaybackService : MediaLibraryService() { // ConnectionResult.AcceptedResultBuilder @OptIn(UnstableApi::class) override fun onConnect(session: MediaSession, controller: ControllerInfo): ConnectionResult { - val availableSessionCommands = - ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() - for (commandButton in customCommands) { - // Add custom command to available session commands. - commandButton.sessionCommand?.let { availableSessionCommands.add(it) } + if (session.isMediaNotificationController(controller)) { + // Set the required available session commands and the custom layout for the notification + // on all API levels. + val availableSessionCommands = + ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() + // Add the session commands of all command buttons. + customLayoutCommandButtons.forEach { commandButton -> + commandButton.sessionCommand?.let { availableSessionCommands.add(it) } + } + // Select the buttons to display. + val customLayout = + ImmutableList.of(customLayoutCommandButtons[if (player.shuffleModeEnabled) 1 else 0]) + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(availableSessionCommands.build()) + .setCustomLayout(customLayout) + .build() } - return ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(availableSessionCommands.build()) - .build() + // Default commands without custom layout for common controllers. + return ConnectionResult.AcceptedResultBuilder(session).build() } + @OptIn(UnstableApi::class) // MediaSession.isMediaNotificationController override fun onCustomCommand( session: MediaSession, controller: ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFuture { + if (!session.isMediaNotificationController(controller)) { + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)) + } if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON == customCommand.customAction) { // Enable shuffling. player.shuffleModeEnabled = true // Change the custom layout to contain the `Disable shuffling` command. - session.setCustomLayout(ImmutableList.of(customCommands[1])) + session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[1])) } else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) { // Disable shuffling. player.shuffleModeEnabled = false // Change the custom layout to contain the `Enable shuffling` command. - session.setCustomLayout(ImmutableList.of(customCommands[0])) + session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[0])) } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } @@ -136,12 +149,6 @@ class PlaybackService : MediaLibraryService() { browser: ControllerInfo, params: LibraryParams? ): ListenableFuture> { - if (params != null && params.isRecent) { - // The service currently does not support playback resumption. Tell System UI by returning - // an error of type 'RESULT_ERROR_NOT_SUPPORTED' for a `params.isRecent` request. See - // https://github.com/androidx/media/issues/355 - return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)) - } return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params)) } @@ -234,7 +241,6 @@ class PlaybackService : MediaLibraryService() { mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) .setSessionActivity(getSingleTopActivity()) - .setCustomLayout(ImmutableList.of(customCommands[0])) .setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this))) .build() } @@ -270,10 +276,6 @@ class PlaybackService : MediaLibraryService() { .build() } - private fun ignoreFuture(customLayout: ListenableFuture) { - /* Do nothing. */ - } - @OptIn(UnstableApi::class) // MediaSessionService.Listener private inner class MediaSessionServiceListener : Listener { diff --git a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java index e4c98ecba9..2a03eb4d4d 100644 --- a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java +++ b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java @@ -215,6 +215,7 @@ public final class CommandButton implements Bundleable { sessionCommand, playerCommand, iconResId, displayName, new Bundle(extras), isEnabled); } + /** Checks the given command button for equality while ignoring {@link #extras}. */ @Override public boolean equals(@Nullable Object obj) { if (this == obj) { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java index 5dc6271b35..d2bbba5e15 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java @@ -128,6 +128,13 @@ import java.util.concurrent.Future; public void notifyChildrenChanged( ControllerInfo browser, String parentId, int itemCount, @Nullable LibraryParams params) { + if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) { + ControllerInfo systemUiBrowser = getSystemUiControllerInfo(); + if (systemUiBrowser == null) { + return; + } + browser = systemUiBrowser; + } dispatchRemoteControllerTaskWithoutReturn( browser, (callback, seq) -> { @@ -140,6 +147,13 @@ import java.util.concurrent.Future; public void notifySearchResultChanged( ControllerInfo browser, String query, int itemCount, @Nullable LibraryParams params) { + if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) { + ControllerInfo systemUiBrowser = getSystemUiControllerInfo(); + if (systemUiBrowser == null) { + return; + } + browser = systemUiBrowser; + } dispatchRemoteControllerTaskWithoutReturn( browser, (callback, seq) -> callback.onSearchResultChanged(seq, query, itemCount, params)); } @@ -163,7 +177,7 @@ import java.util.concurrent.Future; params)); } ListenableFuture> future = - callback.onGetLibraryRoot(instance, browser, params); + callback.onGetLibraryRoot(instance, resolveControllerInfoForCallback(browser), params); future.addListener( () -> { @Nullable LibraryResult result = tryGetFutureResult(future); @@ -204,7 +218,8 @@ import java.util.concurrent.Future; params)); } ListenableFuture>> future = - callback.onGetChildren(instance, browser, parentId, page, pageSize, params); + callback.onGetChildren( + instance, resolveControllerInfoForCallback(browser), parentId, page, pageSize, params); future.addListener( () -> { @Nullable LibraryResult> result = tryGetFutureResult(future); @@ -220,7 +235,7 @@ import java.util.concurrent.Future; public ListenableFuture> onGetItemOnHandler( ControllerInfo browser, String mediaId) { ListenableFuture> future = - callback.onGetItem(instance, browser, mediaId); + callback.onGetItem(instance, resolveControllerInfoForCallback(browser), mediaId); future.addListener( () -> { @Nullable LibraryResult result = tryGetFutureResult(future); @@ -251,7 +266,8 @@ import java.util.concurrent.Future; // so we explicitly null-check the result to fail early if an app accidentally returns null. ListenableFuture> future = checkNotNull( - callback.onSubscribe(instance, browser, parentId, params), + callback.onSubscribe( + instance, resolveControllerInfoForCallback(browser), parentId, params), "onSubscribe must return non-null future"); // When error happens, remove from the subscription list. @@ -270,7 +286,7 @@ import java.util.concurrent.Future; public ListenableFuture> onUnsubscribeOnHandler( ControllerInfo browser, String parentId) { ListenableFuture> future = - callback.onUnsubscribe(instance, browser, parentId); + callback.onUnsubscribe(instance, resolveControllerInfoForCallback(browser), parentId); future.addListener( () -> removeSubscription(checkStateNotNull(browser.getControllerCb()), parentId), @@ -282,7 +298,7 @@ import java.util.concurrent.Future; public ListenableFuture> onSearchOnHandler( ControllerInfo browser, String query, @Nullable LibraryParams params) { ListenableFuture> future = - callback.onSearch(instance, browser, query, params); + callback.onSearch(instance, resolveControllerInfoForCallback(browser), query, params); future.addListener( () -> { @Nullable LibraryResult result = tryGetFutureResult(future); @@ -301,7 +317,8 @@ import java.util.concurrent.Future; int pageSize, @Nullable LibraryParams params) { ListenableFuture>> future = - callback.onGetSearchResult(instance, browser, query, page, pageSize, params); + callback.onGetSearchResult( + instance, resolveControllerInfoForCallback(browser), query, page, pageSize, params); future.addListener( () -> { @Nullable LibraryResult> result = tryGetFutureResult(future); @@ -410,6 +427,10 @@ import java.util.concurrent.Future; ControllerInfo controller, @Nullable LibraryParams params) { SettableFuture>> settableFuture = SettableFuture.create(); + controller = + isMediaNotificationControllerConnected() + ? checkNotNull(getMediaNotificationControllerInfo()) + : controller; ListenableFuture future = callback.onPlaybackResumption(instance, controller); Futures.addCallback( diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java index ad5ad4e8be..e85d47adf3 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -32,7 +32,6 @@ import android.os.Looper; import android.os.RemoteException; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; import android.view.KeyEvent; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; @@ -775,9 +774,7 @@ public class MediaSession { /** * Returns whether the given media controller info belongs to the media notification controller. * - *

Use this method for instance in {@link Callback#onConnect(MediaSession, ControllerInfo)} to - * recognize the media notification controller and provide a {@link ConnectionResult} with a - * custom layout specific for this controller. + *

See {@link #getMediaNotificationControllerInfo()}. * * @param controllerInfo The controller info. * @return Whether the controller info belongs to the media notification controller. @@ -792,8 +789,28 @@ public class MediaSession { * *

Use this controller info to set {@linkplain #setAvailableCommands(ControllerInfo, * SessionCommands, Player.Commands) available commands} and {@linkplain - * #setCustomLayout(ControllerInfo, List) custom layout} that are applied to the media - * notification. + * #setCustomLayout(ControllerInfo, List) custom layout} that are consistently applied to the + * media notification on all API levels. + * + *

Available {@linkplain SessionCommands session commands} of the media notification controller + * are used to enable or disable buttons of the custom layout before it is passed to the + * {@linkplain MediaNotification.Provider#createNotification(MediaSession, ImmutableList, + * MediaNotification.ActionFactory, MediaNotification.Provider.Callback) notification provider}. + * Disabled command buttons are not converted to notification actions when using {@link + * DefaultMediaNotificationProvider}. This affects the media notification displayed by System UI + * below API 33. + * + *

The available session commands of the media notification controller are used to maintain + * custom actions of the platform session (see {@code PlaybackStateCompat.getCustomActions()}). + * Command buttons of the custom layout are disabled or enabled according to the available session + * commands. Disabled command buttons are not converted to custom actions of the platform session. + * This affects the media notification displayed by System UI starting + * with API 33. + * + *

The available {@linkplain Player.Commands player commands} are intersected with the actual + * available commands of the underlying player to determine the playback actions of the platform + * session (see {@code PlaybackStateCompat.getActions()}). */ @UnstableApi @Nullable @@ -815,7 +832,8 @@ public class MediaSession { *

On the controller side, {@link * MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the * new custom layout is different to the custom layout the {@link - * MediaController#getCustomLayout() controller already has available}. + * MediaController#getCustomLayout() controller already has available}. Note that this comparison + * uses {@link CommandButton#equals} and therefore ignores {@link CommandButton#extras}. * *

It's up to controller's decision how to represent the layout in its own UI. * @@ -836,22 +854,17 @@ public class MediaSession { /** * Sets the custom layout that can initially be set when building the session. * - *

Calling this method broadcasts the custom layout to all connected Media3 controllers and - * converts the {@linkplain CommandButton command buttons} to {@linkplain - * PlaybackStateCompat.CustomAction custom actions of the playback state} of the platform media - * session (see {@code - * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). The {@link - * CommandButton#isEnabled} flag is set according to the available commands of the controller and - * overrides a value that has been set by the app. The platform media session won't see any - * commands that are disabled. + *

Calling this method broadcasts the custom layout to all connected Media3 controllers, + * including the {@linkplain #getMediaNotificationControllerInfo() media notification controller}. * - *

On the controller side, {@link - * MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the - * new custom layout is different to the custom layout the {@linkplain - * MediaController#getCustomLayout() controller already has available}. + *

On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is set + * according to the available commands of the controller which overrides a value that has been set + * by the session. * - *

When converting, the {@linkplain SessionCommand#customExtras custom extras of the session - * command} is used for the extras of the legacy custom action. + *

{@link MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called + * if the new custom layout is different to the custom layout the {@linkplain + * MediaController#getCustomLayout() controller already has available}. Note that {@link Bundle + * extras} are ignored when comparing {@linkplain CommandButton command buttons}. * *

Controllers that connect after calling this method will have the new custom layout available * with the initial connection result. A custom layout specific to a controller can be set when 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 41fb3126af..eb6408003d 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -130,6 +130,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // Should be only accessed on the application looper private long sessionPositionUpdateDelayMs; + private boolean isMediaNotificationControllerConnected; private ImmutableList customLayout; public MediaSessionImpl( @@ -191,10 +192,19 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; sessionLegacyStub = new MediaSessionLegacyStub(/* session= */ thisRef, sessionUri, applicationHandler); - - PlayerWrapper playerWrapper = new PlayerWrapper(player, playIfSuppressed); + // For PlayerWrapper, use the same default commands as the proxy controller gets when the app + // doesn't overrides the default commands in `onConnect`. When the default is overridden by the + // app in `onConnect`, the default set here will be overridden with these values. + MediaSession.ConnectionResult connectionResult = + new MediaSession.ConnectionResult.AcceptedResultBuilder(instance).build(); + PlayerWrapper playerWrapper = + new PlayerWrapper( + player, + playIfSuppressed, + customLayout, + connectionResult.availableSessionCommands, + connectionResult.availablePlayerCommands); this.playerWrapper = playerWrapper; - this.playerWrapper.setCustomLayout(customLayout); postOrRun( applicationHandler, () -> @@ -212,13 +222,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return; } setPlayerInternal( - /* oldPlayerWrapper= */ playerWrapper, new PlayerWrapper(player, playIfSuppressed)); + /* oldPlayerWrapper= */ playerWrapper, + new PlayerWrapper( + player, + playIfSuppressed, + playerWrapper.getCustomLayout(), + playerWrapper.getAvailableSessionCommands(), + playerWrapper.getAvailablePlayerCommands())); } private void setPlayerInternal( @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { playerWrapper = newPlayerWrapper; - playerWrapper.setCustomLayout(customLayout); if (oldPlayerWrapper != null) { oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener)); } @@ -295,14 +310,27 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public List getConnectedControllers() { List controllers = new ArrayList<>(); controllers.addAll(sessionStub.getConnectedControllersManager().getConnectedControllers()); - controllers.addAll( - sessionLegacyStub.getConnectedControllersManager().getConnectedControllers()); + if (isMediaNotificationControllerConnected) { + ImmutableList legacyControllers = + sessionLegacyStub.getConnectedControllersManager().getConnectedControllers(); + for (int i = 0; i < legacyControllers.size(); i++) { + ControllerInfo legacyController = legacyControllers.get(i); + if (!isSystemUiController(legacyController)) { + controllers.add(legacyController); + } + } + } else { + controllers.addAll( + sessionLegacyStub.getConnectedControllersManager().getConnectedControllers()); + } return controllers; } @Nullable public ControllerInfo getControllerForCurrentRequest() { - return controllerForCurrentRequest; + return controllerForCurrentRequest != null + ? resolveControllerInfoForCallback(controllerForCurrentRequest) + : null; } public boolean isConnected(ControllerInfo controller) { @@ -372,17 +400,39 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return null; } - public ListenableFuture setCustomLayout( - ControllerInfo controller, List layout) { - return dispatchRemoteControllerTask( - controller, (controller1, seq) -> controller1.setCustomLayout(seq, layout)); + /** Returns whether the media notification controller is connected. */ + protected boolean isMediaNotificationControllerConnected() { + return isMediaNotificationControllerConnected; } - public void setCustomLayout(List layout) { - customLayout = ImmutableList.copyOf(layout); + /** + * Sets the custom layout for the given {@link MediaController}. + * + * @param controller The controller. + * @param customLayout The custom layout. + * @return The session result from the controller. + */ + public ListenableFuture setCustomLayout( + ControllerInfo controller, ImmutableList customLayout) { + if (isMediaNotificationController(controller)) { + playerWrapper.setCustomLayout(customLayout); + sessionLegacyStub.updateLegacySessionPlaybackStateCompat(); + } + return dispatchRemoteControllerTask( + controller, (controller1, seq) -> controller1.setCustomLayout(seq, customLayout)); + } + + /** Sets the custom layout of the session and sends the custom layout to all controllers. */ + public void setCustomLayout(ImmutableList customLayout) { + this.customLayout = customLayout; playerWrapper.setCustomLayout(customLayout); dispatchRemoteControllerTaskWithoutReturn( - (controller, seq) -> controller.setCustomLayout(seq, layout)); + (controller, seq) -> controller.setCustomLayout(seq, customLayout)); + } + + /** Returns the custom layout. */ + public ImmutableList getCustomLayout() { + return customLayout; } public void setSessionExtras(Bundle sessionExtras) { @@ -408,6 +458,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; public void setAvailableCommands( ControllerInfo controller, SessionCommands sessionCommands, Player.Commands playerCommands) { if (sessionStub.getConnectedControllersManager().isConnected(controller)) { + if (isMediaNotificationController(controller)) { + playerWrapper.setAvailableCommands(sessionCommands, playerCommands); + sessionLegacyStub.updateLegacySessionPlaybackStateCompat(); + ControllerInfo systemUiControllerInfo = getSystemUiControllerInfo(); + if (systemUiControllerInfo != null) { + // Set the available commands of the proxy controller to the ConnectedControllerRecord of + // the hidden System UI controller. + sessionLegacyStub + .getConnectedControllersManager() + .updateCommandsFromSession(systemUiControllerInfo, sessionCommands, playerCommands); + } + } sessionStub .getConnectedControllersManager() .updateCommandsFromSession(controller, sessionCommands, playerCommands); @@ -482,45 +544,103 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } public MediaSession.ConnectionResult onConnectOnHandler(ControllerInfo controller) { - return checkNotNull( - callback.onConnect(instance, controller), "Callback.onConnect must return non-null future"); + if (isMediaNotificationControllerConnected && isSystemUiController(controller)) { + // Hide System UI and provide the connection result from the `PlayerWrapper` state. + return new MediaSession.ConnectionResult.AcceptedResultBuilder(instance) + .setAvailableSessionCommands(playerWrapper.getAvailableSessionCommands()) + .setAvailablePlayerCommands(playerWrapper.getAvailablePlayerCommands()) + .setCustomLayout(playerWrapper.getCustomLayout()) + .build(); + } + MediaSession.ConnectionResult connectionResult = + checkNotNull( + callback.onConnect(instance, controller), + "Callback.onConnect must return non-null future"); + if (isMediaNotificationController(controller)) { + isMediaNotificationControllerConnected = true; + playerWrapper.setAvailableCommands( + connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands); + playerWrapper.setCustomLayout( + connectionResult.customLayout != null + ? connectionResult.customLayout + : instance.getCustomLayout()); + sessionLegacyStub.updateLegacySessionPlaybackStateCompat(); + } + return connectionResult; } public void onPostConnectOnHandler(ControllerInfo controller) { + if (isMediaNotificationControllerConnected && isSystemUiController(controller)) { + // Hide System UI. Apps can use the media notification controller to maintain the platform + // session + return; + } callback.onPostConnect(instance, controller); } public void onDisconnectedOnHandler(ControllerInfo controller) { + if (isMediaNotificationControllerConnected) { + if (isSystemUiController(controller)) { + // Hide System UI controller. Apps can use the media notification controller to maintain the + // platform session. + return; + } else if (isMediaNotificationController(controller)) { + isMediaNotificationControllerConnected = false; + } + } callback.onDisconnected(instance, controller); } @SuppressWarnings("deprecation") // Calling deprecated callback method. public @SessionResult.Code int onPlayerCommandRequestOnHandler( ControllerInfo controller, @Player.Command int playerCommand) { - return callback.onPlayerCommandRequest(instance, controller, playerCommand); + return callback.onPlayerCommandRequest( + instance, resolveControllerInfoForCallback(controller), playerCommand); } public ListenableFuture onSetRatingOnHandler( ControllerInfo controller, String mediaId, Rating rating) { return checkNotNull( - callback.onSetRating(instance, controller, mediaId, rating), + callback.onSetRating( + instance, resolveControllerInfoForCallback(controller), mediaId, rating), "Callback.onSetRating must return non-null future"); } public ListenableFuture onSetRatingOnHandler( ControllerInfo controller, Rating rating) { return checkNotNull( - callback.onSetRating(instance, controller, rating), + callback.onSetRating(instance, resolveControllerInfoForCallback(controller), rating), "Callback.onSetRating must return non-null future"); } public ListenableFuture onCustomCommandOnHandler( - ControllerInfo browser, SessionCommand command, Bundle extras) { + ControllerInfo controller, SessionCommand command, Bundle extras) { return checkNotNull( - callback.onCustomCommand(instance, browser, command, extras), + callback.onCustomCommand( + instance, resolveControllerInfoForCallback(controller), command, extras), "Callback.onCustomCommandOnHandler must return non-null future"); } + protected ListenableFuture> onAddMediaItemsOnHandler( + ControllerInfo controller, List mediaItems) { + return checkNotNull( + callback.onAddMediaItems( + instance, resolveControllerInfoForCallback(controller), mediaItems), + "Callback.onAddMediaItems must return a non-null future"); + } + + protected ListenableFuture onSetMediaItemsOnHandler( + ControllerInfo controller, List mediaItems, int startIndex, long startPositionMs) { + return checkNotNull( + callback.onSetMediaItems( + instance, + resolveControllerInfoForCallback(controller), + mediaItems, + startIndex, + startPositionMs), + "Callback.onSetMediaItems must return a non-null future"); + } + public void connectFromService( IMediaController caller, int controllerVersion, @@ -555,20 +675,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return applicationHandler; } - protected ListenableFuture> onAddMediaItemsOnHandler( - ControllerInfo controller, List mediaItems) { - return checkNotNull( - callback.onAddMediaItems(instance, controller, mediaItems), - "Callback.onAddMediaItems must return a non-null future"); - } - - protected ListenableFuture onSetMediaItemsOnHandler( - ControllerInfo controller, List mediaItems, int startIndex, long startPositionMs) { - return checkNotNull( - callback.onSetMediaItems(instance, controller, mediaItems, startIndex, startPositionMs), - "Callback.onSetMediaItems must return a non-null future"); - } - protected boolean isReleased() { synchronized (lock) { return closed; @@ -580,10 +686,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; return sessionActivity; } - protected ImmutableList getCustomLayout() { - return customLayout; - } - @UnstableApi protected void setSessionActivity(PendingIntent sessionActivity) { if (Objects.equals(this.sessionActivity, sessionActivity)) { @@ -603,6 +705,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } } + protected ControllerInfo resolveControllerInfoForCallback(ControllerInfo controller) { + return isMediaNotificationControllerConnected && isSystemUiController(controller) + ? checkNotNull(getMediaNotificationControllerInfo()) + : controller; + } + /** * Gets the service binder from the MediaBrowserServiceCompat. Should be only called by the thread * with a Looper. @@ -693,7 +801,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Nullable ListenableFuture future = checkNotNull( - callback.onPlaybackResumption(instance, controller), + callback.onPlaybackResumption(instance, resolveControllerInfoForCallback(controller)), "Callback.onPlaybackResumption must return a non-null future"); // Use a direct executor when an immediate future is returned to execute the player setup in the // caller's looper event on the application thread. diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 9b7d913d2a..a3540f0083 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -823,6 +823,15 @@ import org.checkerframework.checker.initialization.qual.Initialized; connectionTimeoutMs = timeoutMs; } + public void updateLegacySessionPlaybackStateCompat() { + postOrRun( + sessionImpl.getApplicationHandler(), + () -> + sessionImpl + .getSessionCompat() + .setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat())); + } + private void handleMediaRequest(MediaItem mediaItem, boolean play) { dispatchSessionTaskWithPlayerCommand( COMMAND_SET_MEDIA_ITEM, @@ -1081,16 +1090,12 @@ import org.checkerframework.checker.initialization.qual.Initialized; @Override public void onPlayerError(int seq, @Nullable PlaybackException playerError) { - sessionImpl - .getSessionCompat() - .setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat()); + updateLegacySessionPlaybackStateCompat(); } @Override public void setCustomLayout(int seq, List layout) { - sessionImpl - .getSessionCompat() - .setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat()); + updateLegacySessionPlaybackStateCompat(); } @Override diff --git a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java index a2b40c4859..0b901a26c7 100644 --- a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java +++ b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java @@ -21,6 +21,7 @@ import static androidx.media3.common.util.Util.msToUs; import static androidx.media3.common.util.Util.postOrRun; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_MEDIA_ID_COMPAT; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_PLAYBACK_SPEED_COMPAT; +import static androidx.media3.session.MediaUtils.intersect; import android.media.AudioManager; import android.os.Bundle; @@ -70,12 +71,43 @@ import java.util.List; @Nullable private String legacyErrorMessage; @Nullable private Bundle legacyErrorExtras; private ImmutableList customLayout; + private SessionCommands availableSessionCommands; + private Commands availablePlayerCommands; - public PlayerWrapper(Player player, boolean playIfSuppressed) { + public PlayerWrapper( + Player player, + boolean playIfSuppressed, + ImmutableList customLayout, + SessionCommands availableSessionCommands, + Commands availablePlayerCommands) { super(player); this.playIfSuppressed = playIfSuppressed; + this.customLayout = customLayout; + this.availableSessionCommands = availableSessionCommands; + this.availablePlayerCommands = availablePlayerCommands; legacyStatusCode = STATUS_CODE_SUCCESS_COMPAT; - customLayout = ImmutableList.of(); + } + + public void setAvailableCommands( + SessionCommands availableSessionCommands, Commands availablePlayerCommands) { + this.availableSessionCommands = availableSessionCommands; + this.availablePlayerCommands = availablePlayerCommands; + } + + public SessionCommands getAvailableSessionCommands() { + return availableSessionCommands; + } + + public Commands getAvailablePlayerCommands() { + return availablePlayerCommands; + } + + public void setCustomLayout(ImmutableList customLayout) { + this.customLayout = customLayout; + } + + /* package */ ImmutableList getCustomLayout() { + return customLayout; } /** @@ -104,11 +136,6 @@ import java.util.List; return legacyStatusCode; } - /** Sets the custom layout. */ - public void setCustomLayout(ImmutableList customLayout) { - this.customLayout = customLayout; - } - /** Clears the legacy error status. */ public void clearLegacyErrorStatus() { legacyStatusCode = STATUS_CODE_SUCCESS_COMPAT; @@ -974,7 +1001,7 @@ import java.util.List; int state = MediaUtils.convertToPlaybackStateCompatState(/* player= */ this, playIfSuppressed); // Always advertise ACTION_SET_RATING. long actions = PlaybackStateCompat.ACTION_SET_RATING; - Commands availableCommands = getAvailableCommands(); + Commands availableCommands = intersect(availablePlayerCommands, getAvailableCommands()); for (int i = 0; i < availableCommands.size(); i++) { actions |= convertCommandToPlaybackStateActions(availableCommands.get(i)); } @@ -1006,7 +1033,9 @@ import java.util.List; CommandButton commandButton = customLayout.get(i); if (commandButton.sessionCommand != null) { SessionCommand sessionCommand = commandButton.sessionCommand; - if (sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) { + if (sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM + && CommandButton.isEnabled( + commandButton, availableSessionCommands, availablePlayerCommands)) { builder.addCustomAction( new PlaybackStateCompat.CustomAction.Builder( sessionCommand.customAction, diff --git a/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java b/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java index fa052605e3..a6b65e5f15 100644 --- a/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java +++ b/libraries/session/src/main/java/androidx/media3/session/SessionCommand.java @@ -157,6 +157,7 @@ public final class SessionCommand implements Bundleable { customExtras = new Bundle(checkNotNull(extras)); } + /** Checks the given session command for equality while ignoring extras. */ @Override public boolean equals(@Nullable Object obj) { if (!(obj instanceof SessionCommand)) { diff --git a/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java b/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java index fad36651e6..82ea7a4a28 100644 --- a/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/PlayerWrapperTest.java @@ -22,6 +22,7 @@ import static org.mockito.Mockito.when; import android.os.Looper; import androidx.media3.common.Player; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -42,7 +43,13 @@ public class PlayerWrapperTest { @Before public void setUp() { - playerWrapper = new PlayerWrapper(player, /* playIfSuppressed= */ true); + playerWrapper = + new PlayerWrapper( + player, + /* playIfSuppressed= */ true, + ImmutableList.of(), + SessionCommands.EMPTY, + Player.Commands.EMPTY); when(player.isCommandAvailable(anyInt())).thenReturn(true); when(player.getApplicationLooper()).thenReturn(Looper.myLooper()); } diff --git a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java index b0c2601a32..fe37189908 100644 --- a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java +++ b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaSessionConstants.java @@ -30,7 +30,8 @@ public class MediaSessionConstants { "onTracksChanged_videoToAudioTransition"; public static final String TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE = "testSetShowPlayButtonIfSuppressedToFalse"; - + public static final String TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST = + "MediaControllerCompatCallbackWithMediaSessionTest"; // Bundle keys public static final String KEY_AVAILABLE_SESSION_COMMANDS = "availableSessionCommands"; public static final String KEY_CONTROLLER = "controllerKey"; diff --git a/libraries/test_session_current/build.gradle b/libraries/test_session_current/build.gradle index 75fb36ee48..53851355c8 100644 --- a/libraries/test_session_current/build.gradle +++ b/libraries/test_session_current/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation 'androidx.test:core:' + androidxTestCoreVersion implementation project(modulePrefix + 'test-data') androidTestImplementation project(modulePrefix + 'lib-exoplayer') + androidTestImplementation project(modulePrefix + 'test-utils') androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion androidTestImplementation 'androidx.test.ext:truth:' + androidxTestTruthVersion androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java index a7c54412f5..54d1c40825 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatCallbackWithMediaSessionTest.java @@ -20,6 +20,7 @@ import static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_MEDIA_ID import static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_USER_RATING; import static androidx.media3.common.Player.STATE_ENDED; import static androidx.media3.common.Player.STATE_READY; +import static androidx.media3.test.session.common.MediaSessionConstants.TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE; import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; @@ -78,10 +79,10 @@ import org.junit.runner.RunWith; @LargeTest public class MediaControllerCompatCallbackWithMediaSessionTest { - private static final String TAG = "MCCCallbackTestWithMS2"; - private static final float EPSILON = 1e-6f; + private static final String SESSION_ID = + TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST; - @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG); + @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(SESSION_ID); private Context context; private TestHandler handler; @@ -92,7 +93,10 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { public void setUp() throws Exception { context = ApplicationProvider.getApplicationContext(); handler = threadTestRule.getHandler(); - session = new RemoteMediaSession(TAG, context, null); + Bundle tokenExtras = new Bundle(); + tokenExtras.putBoolean( + MediaSessionProviderService.KEY_ENABLE_FAKE_MEDIA_NOTIFICATION_MANAGER_CONTROLLER, true); + session = new RemoteMediaSession(SESSION_ID, context, tokenExtras); controllerCompat = new MediaControllerCompat(context, session.getCompatToken()); } @@ -181,7 +185,7 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { public void getError_withPlayerErrorAfterConnected_returnsError() throws Exception { PlaybackException testPlayerError = new PlaybackException( - /* messaage= */ "testremote", + /* message= */ "testremote", /* cause= */ null, PlaybackException.ERROR_CODE_REMOTE_ERROR); Bundle playerConfig = @@ -207,7 +211,7 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { public void playerError_notified() throws Exception { PlaybackException testPlayerError = new PlaybackException( - /* messaage= */ "player error", + /* message= */ "player error", /* cause= */ null, PlaybackException.ERROR_CODE_UNSPECIFIED); @@ -937,53 +941,46 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { @Test public void setCustomLayout_onPlaybackStateCompatChangedCalled() throws Exception { - List buttons = new ArrayList<>(); Bundle extras1 = new Bundle(); extras1.putString("key", "value-1"); - CommandButton button1 = - new CommandButton.Builder() - .setSessionCommand(new SessionCommand("action1", extras1)) - .setDisplayName("actionName1") - .setIconResId(1) - .build(); + SessionCommand command1 = new SessionCommand("command1", extras1); Bundle extras2 = new Bundle(); extras2.putString("key", "value-2"); - CommandButton button2 = - new CommandButton.Builder() - .setSessionCommand(new SessionCommand("action2", extras2)) - .setDisplayName("actionName2") - .setIconResId(2) - .build(); - buttons.add(button1); - buttons.add(button2); - List receivedActions = new ArrayList<>(); - List receivedDisplayNames = new ArrayList<>(); - List receivedBundleValues = new ArrayList<>(); - List receivedIconResIds = new ArrayList<>(); - CountDownLatch latch = new CountDownLatch(1); + SessionCommand command2 = new SessionCommand("command2", extras2); + ImmutableList customLayout = + ImmutableList.of( + new CommandButton.Builder() + .setSessionCommand(command1) + .setDisplayName("command1") + .setIconResId(1) + .build() + .copyWithIsEnabled(true), + new CommandButton.Builder() + .setSessionCommand(command2) + .setDisplayName("command2") + .setIconResId(2) + .build() + .copyWithIsEnabled(true)); + List> reportedCustomLayouts = new ArrayList<>(); + CountDownLatch latch1 = new CountDownLatch(2); MediaControllerCompat.Callback callback = new MediaControllerCompat.Callback() { @Override public void onPlaybackStateChanged(PlaybackStateCompat state) { - List layout = state.getCustomActions(); - for (PlaybackStateCompat.CustomAction action : layout) { - receivedActions.add(action.getAction()); - receivedDisplayNames.add(String.valueOf(action.getName())); - receivedBundleValues.add(action.getExtras().getString("key")); - receivedIconResIds.add(action.getIcon()); - } - latch.countDown(); + reportedCustomLayouts.add(MediaUtils.convertToCustomLayout(state)); + latch1.countDown(); } }; controllerCompat.registerCallback(callback, handler); - session.setCustomLayout(buttons); + session.setCustomLayout(customLayout); + session.setAvailableCommands( + SessionCommands.EMPTY.buildUpon().add(command1).add(command2).build(), + Player.Commands.EMPTY); - assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); - assertThat(receivedActions).containsExactly("action1", "action2").inOrder(); - assertThat(receivedDisplayNames).containsExactly("actionName1", "actionName2").inOrder(); - assertThat(receivedIconResIds).containsExactly(1, 2).inOrder(); - assertThat(receivedBundleValues).containsExactly("value-1", "value-2").inOrder(); + assertThat(latch1.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(reportedCustomLayouts.get(0)).containsExactly(customLayout.get(0)); + assertThat(reportedCustomLayouts.get(1)).isEqualTo(customLayout); } @Test @@ -1403,6 +1400,6 @@ public class MediaControllerCompatCallbackWithMediaSessionTest { PlaybackStateCompat state, PlaybackException playerError) { assertThat(state.getState()).isEqualTo(PlaybackStateCompat.STATE_ERROR); assertThat(state.getErrorCode()).isEqualTo(PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR); - assertThat(state.getErrorMessage()).isEqualTo(playerError.getMessage()); + assertThat(state.getErrorMessage().toString()).isEqualTo(playerError.getMessage()); } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java index ea18da0f88..7823b9fd11 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java @@ -43,6 +43,8 @@ import androidx.media3.common.Timeline; import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.Consumer; import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.session.MediaSession.ConnectionResult; +import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder; import androidx.media3.test.session.R; import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.test.core.app.ApplicationProvider; @@ -51,6 +53,7 @@ import androidx.test.filters.LargeTest; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -1459,18 +1462,23 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest .setIconResId(R.drawable.media3_notification_pause) .setSessionCommand(command2) .build()); - MediaSession mediaSession = createMediaSession(player, /* callback= */ null, customLayout); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build()) + .build(); + } + }; + MediaSession mediaSession = createMediaSession(player, callback, customLayout); + connectMediaNotificationController(mediaSession); MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); - // Wait until a playback state is sent to the controller. - PlaybackStateCompat firstPlaybackState = - getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()); - - assertThat(MediaUtils.convertToCustomLayout(firstPlaybackState)) - .containsExactly( - customLayout.get(0).copyWithIsEnabled(true), - customLayout.get(1).copyWithIsEnabled(true)) - .inOrder(); + assertThat(MediaUtils.convertToCustomLayout(controllerCompat.getPlaybackState())) + .containsExactly(customLayout.get(0).copyWithIsEnabled(true)); mediaSession.release(); releasePlayer(player); } @@ -1497,11 +1505,23 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest .setIconResId(R.drawable.media3_notification_pause) .setSessionCommand(command2) .build()); - MediaSession mediaSession = createMediaSession(player); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build()) + .build(); + } + }; + MediaSession mediaSession = createMediaSession(player, callback); + connectMediaNotificationController(mediaSession); MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + ImmutableList initialCustomLayout = + MediaUtils.convertToCustomLayout(controllerCompat.getPlaybackState()); AtomicReference> reportedCustomLayout = new AtomicReference<>(); - // Wait until a playback state is sent to the controller. - getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()); CountDownLatch latch = new CountDownLatch(1); controllerCompat.registerCallback( new MediaControllerCompat.Callback() { @@ -1516,14 +1536,97 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest getInstrumentation().runOnMainSync(() -> mediaSession.setCustomLayout(customLayout)); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialCustomLayout).isEmpty(); assertThat(reportedCustomLayout.get()) - .containsExactly( - customLayout.get(0).copyWithIsEnabled(true), - customLayout.get(1).copyWithIsEnabled(true)); + .containsExactly(customLayout.get(0).copyWithIsEnabled(true)); mediaSession.release(); releasePlayer(player); } + @Test + public void + playerWithCustomLayout_setCustomLayoutForMediaNotificationController_playbackStateChangedWithCustomActionsChanged() + throws Exception { + Player player = createDefaultPlayer(); + Bundle extras1 = new Bundle(); + extras1.putString("key1", "value1"); + Bundle extras2 = new Bundle(); + extras1.putString("key2", "value2"); + SessionCommand command1 = new SessionCommand("command1", extras1); + SessionCommand command2 = new SessionCommand("command2", extras2); + ImmutableList customLayout = + ImmutableList.of( + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_play) + .setSessionCommand(command1) + .build(), + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_pause) + .setSessionCommand(command2) + .build()); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public ConnectionResult onConnect( + MediaSession session, MediaSession.ControllerInfo controller) { + return new ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build()) + .build(); + } + }; + MediaSession mediaSession = createMediaSession(player, callback); + connectMediaNotificationController(mediaSession); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + ImmutableList initialCustomLayout = + MediaUtils.convertToCustomLayout(controllerCompat.getPlaybackState()); + AtomicReference> reportedCustomLayout = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + controllerCompat.registerCallback( + new MediaControllerCompat.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + reportedCustomLayout.set(MediaUtils.convertToCustomLayout(state)); + latch.countDown(); + } + }, + threadTestRule.getHandler()); + + getInstrumentation() + .runOnMainSync( + () -> + mediaSession.setCustomLayout( + mediaSession.getMediaNotificationControllerInfo(), customLayout)); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialCustomLayout).isEmpty(); + assertThat(reportedCustomLayout.get()) + .containsExactly(customLayout.get(0).copyWithIsEnabled(true)); + mediaSession.release(); + releasePlayer(player); + } + + /** + * Connect a controller that mimics the media notification controller that is connected by {@link + * MediaNotificationManager} when the session is running in the service. + */ + private void connectMediaNotificationController(MediaSession mediaSession) + throws InterruptedException { + CountDownLatch connectionLatch = new CountDownLatch(1); + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true); + ListenableFuture mediaNotificationControllerFuture = + new MediaController.Builder( + ApplicationProvider.getApplicationContext(), mediaSession.getToken()) + .setConnectionHints(connectionHints) + .buildAsync(); + mediaNotificationControllerFuture.addListener( + connectionLatch::countDown, MoreExecutors.directExecutor()); + assertThat(connectionLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + } + private PlaybackStateCompat getFirstPlaybackState( MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException { LinkedBlockingDeque playbackStateCompats = new LinkedBlockingDeque<>(); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java index 989d1cddf7..cea6c53872 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionServiceTest.java @@ -27,18 +27,24 @@ import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.PlaybackStateCompat; import androidx.media3.common.MediaItem; import androidx.media3.common.Player; import androidx.media3.common.util.ConditionVariable; import androidx.media3.exoplayer.ExoPlayer; 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; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.MediumTest; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.SettableFuture; import java.util.ArrayList; @@ -145,6 +151,100 @@ public class MediaSessionServiceTest { service.blockUntilAllControllersUnbind(TIMEOUT_MS); } + @Test + public void onCreate_mediaNotificationManagerController_correctSessionStateFromOnConnect() + throws Exception { + SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY); + SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY); + SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY); + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command1) + .build(); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command2) + .build(); + CommandButton button3 = + new CommandButton.Builder() + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(command3) + .build(); + Bundle testHints = new Bundle(); + testHints.putString("test_key", "test_value"); + List controllerInfoList = new ArrayList<>(); + CountDownLatch latch = new CountDownLatch(2); + TestHandler handler = new TestHandler(Looper.getMainLooper()); + ExoPlayer player = + handler.postAndSync( + () -> { + ExoPlayer exoPlayer = new TestExoPlayerBuilder(context).build(); + exoPlayer.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4")); + exoPlayer.prepare(); + return exoPlayer; + }); + MediaSession mediaSession = + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) + .setCustomLayout(Lists.newArrayList(button1, button2)) + .setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + controllerInfoList.add(controller); + if (session.isMediaNotificationController(controller)) { + latch.countDown(); + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands( + SessionCommands.EMPTY.buildUpon().add(command1).add(command3).build()) + .setAvailablePlayerCommands(Player.Commands.EMPTY) + .setCustomLayout(ImmutableList.of(button1, button3)) + .build(); + } + latch.countDown(); + return new MediaSession.ConnectionResult.AcceptedResultBuilder(session).build(); + } + }) + .build(); + TestServiceRegistry.getInstance().setOnGetSessionHandler(controllerInfo -> mediaSession); + MediaControllerCompat mediaControllerCompat = + new MediaControllerCompat( + ApplicationProvider.getApplicationContext(), mediaSession.getSessionCompat()); + ImmutableList initialCustomLayoutInControllerCompat = + MediaUtils.convertToCustomLayout(mediaControllerCompat.getPlaybackState()); + + // Start the service by creating a remote controller. + RemoteMediaController remoteController = + controllerTestRule.createRemoteController(token, /* waitForConnection= */ true, testHints); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat( + controllerInfoList + .get(0) + .getConnectionHints() + .getBoolean( + MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, + /* defaultValue= */ false)) + .isTrue(); + assertThat(TestUtils.equals(controllerInfoList.get(1).getConnectionHints(), testHints)) + .isTrue(); + assertThat(mediaControllerCompat.getPlaybackState().getActions()) + .isEqualTo(PlaybackStateCompat.ACTION_SET_RATING); + assertThat(remoteController.getCustomLayout()).containsExactly(button1, button2).inOrder(); + assertThat(initialCustomLayoutInControllerCompat).isEmpty(); + assertThat(MediaUtils.convertToCustomLayout(mediaControllerCompat.getPlaybackState())) + .containsExactly(button1.copyWithIsEnabled(true), button3.copyWithIsEnabled(true)) + .inOrder(); + mediaSession.release(); + ((MockMediaSessionService) TestServiceRegistry.getInstance().getServiceInstance()) + .blockUntilAllControllersUnbind(TIMEOUT_MS); + } + /** * Tests whether {@link MediaSessionService#onGetSession(ControllerInfo)} is called when * controller tries to connect, with the proper arguments. diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java index 4cc7992577..a96da7b230 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaSessionProviderService.java @@ -16,6 +16,7 @@ package androidx.media3.session; import static androidx.media3.common.Player.COMMAND_GET_TRACKS; +import static androidx.media3.session.MediaSession.ConnectionResult.accept; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION; import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES; import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS; @@ -63,6 +64,7 @@ import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CON import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_IS_SESSION_COMMAND_AVAILABLE; +import static androidx.media3.test.session.common.MediaSessionConstants.TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_TRACKS_CHANGED_VIDEO_TO_AUDIO_TRANSITION; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_VIDEO_SIZE_CHANGED; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE; @@ -101,6 +103,7 @@ import androidx.media3.test.session.common.TestHandler; import androidx.media3.test.session.common.TestHandler.TestRunnable; import androidx.media3.test.session.common.TestUtils; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.ListenableFuture; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -113,6 +116,8 @@ import java.util.concurrent.Callable; */ public class MediaSessionProviderService extends Service { + public static final String KEY_ENABLE_FAKE_MEDIA_NOTIFICATION_MANAGER_CONTROLLER = + "key_enable_fake_media_notification_manager_controller"; private static final String TAG = "MSProviderService"; private Map sessionMap = new HashMap<>(); @@ -164,15 +169,18 @@ public class MediaSessionProviderService extends Service { @Override public void create(String sessionId, Bundle tokenExtras) throws RemoteException { + if (tokenExtras == null) { + tokenExtras = Bundle.EMPTY; + } + boolean useFakeMediaNotificationManagerController = + tokenExtras.getBoolean( + KEY_ENABLE_FAKE_MEDIA_NOTIFICATION_MANAGER_CONTROLLER, /* defaultValue= */ false); MockPlayer mockPlayer = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build(); MediaSession.Builder builder = new MediaSession.Builder(MediaSessionProviderService.this, mockPlayer).setId(sessionId); - if (tokenExtras != null) { - builder.setExtras(tokenExtras); - } - + builder.setExtras(tokenExtras); switch (sessionId) { case TEST_GET_SESSION_ACTIVITY: { @@ -194,7 +202,7 @@ public class MediaSessionProviderService extends Service { @Override public MediaSession.ConnectionResult onConnect( MediaSession session, ControllerInfo controller) { - return MediaSession.ConnectionResult.accept( + return accept( new SessionCommands.Builder() .add(new SessionCommand("command1", Bundle.EMPTY)) .add(new SessionCommand("command2", Bundle.EMPTY)) @@ -216,8 +224,7 @@ public class MediaSessionProviderService extends Service { @Override public MediaSession.ConnectionResult onConnect( MediaSession session, ControllerInfo controller) { - return MediaSession.ConnectionResult.accept( - availableSessionCommands, Player.Commands.EMPTY); + return accept(availableSessionCommands, Player.Commands.EMPTY); } }); break; @@ -244,8 +251,7 @@ public class MediaSessionProviderService extends Service { @Override public MediaSession.ConnectionResult onConnect( MediaSession session, ControllerInfo controller) { - return MediaSession.ConnectionResult.accept( - availableSessionCommands, Player.Commands.EMPTY); + return accept(availableSessionCommands, Player.Commands.EMPTY); } }); break; @@ -272,8 +278,7 @@ public class MediaSessionProviderService extends Service { .getBoolean(KEY_COMMAND_GET_TASKS_UNAVAILABLE, /* defaultValue= */ false)) { commandBuilder.remove(COMMAND_GET_TRACKS); } - return MediaSession.ConnectionResult.accept( - SessionCommands.EMPTY, commandBuilder.build()); + return accept(SessionCommands.EMPTY, commandBuilder.build()); } }); break; @@ -290,6 +295,31 @@ public class MediaSessionProviderService extends Service { builder.setShowPlayButtonIfPlaybackIsSuppressed(false); break; } + case TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST: + { + builder.setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + MediaSession.ConnectionResult connectionResult = + MediaSession.Callback.super.onConnect(session, controller); + SessionCommands availableSessionCommands = + connectionResult.availableSessionCommands; + if (session.isMediaNotificationController(controller)) { + availableSessionCommands = + connectionResult + .availableSessionCommands + .buildUpon() + .add(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + } + return accept( + availableSessionCommands, connectionResult.availablePlayerCommands); + } + }); + break; + } default: // fall out } @@ -297,6 +327,22 @@ public class MediaSessionProviderService extends Service { () -> { MediaSession session = builder.build(); session.setSessionPositionUpdateDelayMs(0L); + if (useFakeMediaNotificationManagerController) { + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean("androidx.media3.session.MediaNotificationManager", true); + //noinspection unused + ListenableFuture unusedFuture = + new MediaController.Builder(getApplicationContext(), session.getToken()) + .setListener( + new MediaController.Listener() { + @Override + public void onDisconnected(MediaController controller) { + controller.release(); + } + }) + .setConnectionHints(connectionHints) + .buildAsync(); + } sessionMap.put(sessionId, session); }); }