diff --git a/RELEASENOTES.md b/RELEASENOTES.md index bc04f4302d..9ee75604ab 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -91,6 +91,14 @@ * Muxers: * IMA extension: * Session: + * Add custom layout to the state of the controller and provide a getter to + access it. When the custom layout changes, + `MediaController.Listener.onCustomLayoutChanged` is called. The callback + `MediaController.Listener.onSetCustomLayout()` is deprecated. Apps that + want to send different custom layouts to different Media3 controller can + do this in `MediaSession.Callback.onConnect` by using an + `AcceptedResultBuilder` to make sure the custom layout is available to + the controller when connection completes. * UI: * Add a `Player.Listener` implementation for Wear OS devices that handles playback suppression due to 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 0a281e7b46..3bb86e6cdb 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,6 +33,7 @@ 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 import com.google.common.util.concurrent.Futures @@ -45,8 +46,6 @@ class PlaybackService : MediaLibraryService() { private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var customCommands: List - private var customLayout = ImmutableList.of() - companion object { private const val SEARCH_QUERY_PREFIX_COMPAT = "androidx://media3-session/playFromSearch" private const val SEARCH_QUERY_PREFIX = "androidx://media3-session/setMediaUri" @@ -70,7 +69,6 @@ class PlaybackService : MediaLibraryService() { SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY) ) ) - customLayout = ImmutableList.of(customCommands[0]) initializeSessionAndPlayer() setListener(MediaSessionServiceListener()) } @@ -95,28 +93,16 @@ class PlaybackService : MediaLibraryService() { private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback { - override fun onConnect( - session: MediaSession, - controller: ControllerInfo - ): MediaSession.ConnectionResult { - val connectionResult = super.onConnect(session, controller) - val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() + 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) } } - return MediaSession.ConnectionResult.accept( - availableSessionCommands.build(), - connectionResult.availablePlayerCommands - ) - } - - override fun onPostConnect(session: MediaSession, controller: ControllerInfo) { - if (!customLayout.isEmpty() && controller.controllerVersion != 0) { - // Let Media3 controller (for instance the MediaNotificationProvider) know about the custom - // layout right after it connected. - ignoreFuture(mediaLibrarySession.setCustomLayout(controller, customLayout)) - } + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(availableSessionCommands.build()) + .build() } override fun onCustomCommand( @@ -129,16 +115,12 @@ class PlaybackService : MediaLibraryService() { // Enable shuffling. player.shuffleModeEnabled = true // Change the custom layout to contain the `Disable shuffling` command. - customLayout = ImmutableList.of(customCommands[1]) - // Send the updated custom layout to controllers. - session.setCustomLayout(customLayout) + session.setCustomLayout(ImmutableList.of(customCommands[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. - customLayout = ImmutableList.of(customCommands[0]) - // Send the updated custom layout to controllers. - session.setCustomLayout(customLayout) + session.setCustomLayout(ImmutableList.of(customCommands[0])) } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } @@ -241,12 +223,9 @@ class PlaybackService : MediaLibraryService() { mediaLibrarySession = MediaLibrarySession.Builder(this, player, librarySessionCallback) .setSessionActivity(getSingleTopActivity()) + .setCustomLayout(ImmutableList.of(customCommands[0])) .setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this))) .build() - if (!customLayout.isEmpty()) { - // Send custom layout to legacy session. - mediaLibrarySession.setCustomLayout(customLayout) - } } private fun getSingleTopActivity(): PendingIntent { 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 197330a6e3..86d9c176cb 100644 --- a/libraries/session/src/main/java/androidx/media3/session/CommandButton.java +++ b/libraries/session/src/main/java/androidx/media3/session/CommandButton.java @@ -28,6 +28,7 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.base.Objects; import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.errorprone.annotations.CheckReturnValue; import java.util.List; /** @@ -35,7 +36,7 @@ import java.util.List; * controllers. * * @see MediaSession#setCustomLayout(MediaSession.ControllerInfo, List) - * @see MediaController.Listener#onSetCustomLayout(MediaController, List) + * @see MediaController.Listener#onCustomLayoutChanged(MediaController, List) */ public final class CommandButton implements Bundleable { @@ -196,6 +197,19 @@ public final class CommandButton implements Bundleable { this.isEnabled = enabled; } + /** Returns a copy with the new {@link #isEnabled} flag. */ + @CheckReturnValue + /* package */ CommandButton copyWithIsEnabled(boolean isEnabled) { + // Because this method is supposed to be used by the library only, this method has been chosen + // over the conventional `buildUpon` approach. This aims for keeping this separate from the + // public Builder-API used by apps. + if (this.isEnabled == isEnabled) { + return this; + } + return new CommandButton( + sessionCommand, playerCommand, iconResId, displayName, new Bundle(extras), isEnabled); + } + @Override public boolean equals(@Nullable Object obj) { if (this == obj) { diff --git a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java index b4c808373f..ec631513fb 100644 --- a/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java +++ b/libraries/session/src/main/java/androidx/media3/session/ConnectionState.java @@ -24,7 +24,10 @@ import androidx.annotation.Nullable; import androidx.core.app.BundleCompat; import androidx.media3.common.Bundleable; import androidx.media3.common.Player; +import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.Util; +import com.google.common.collect.ImmutableList; +import java.util.List; /** * Created by {@link MediaSession} to send its state to the {@link MediaController} when the @@ -50,11 +53,14 @@ import androidx.media3.common.util.Util; public final PlayerInfo playerInfo; + public final ImmutableList customLayout; + public ConnectionState( int libraryVersion, int sessionInterfaceVersion, IMediaSession sessionBinder, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, SessionCommands sessionCommands, Player.Commands playerCommandsFromSession, Player.Commands playerCommandsFromPlayer, @@ -69,6 +75,7 @@ import androidx.media3.common.util.Util; this.sessionActivity = sessionActivity; this.tokenExtras = tokenExtras; this.playerInfo = playerInfo; + this.customLayout = customLayout; } // Bundleable implementation. @@ -76,6 +83,7 @@ import androidx.media3.common.util.Util; private static final String FIELD_LIBRARY_VERSION = Util.intToStringMaxRadix(0); private static final String FIELD_SESSION_BINDER = Util.intToStringMaxRadix(1); private static final String FIELD_SESSION_ACTIVITY = Util.intToStringMaxRadix(2); + private static final String FIELD_CUSTOM_LAYOUT = Util.intToStringMaxRadix(9); private static final String FIELD_SESSION_COMMANDS = Util.intToStringMaxRadix(3); private static final String FIELD_PLAYER_COMMANDS_FROM_SESSION = Util.intToStringMaxRadix(4); private static final String FIELD_PLAYER_COMMANDS_FROM_PLAYER = Util.intToStringMaxRadix(5); @@ -83,7 +91,7 @@ import androidx.media3.common.util.Util; private static final String FIELD_PLAYER_INFO = Util.intToStringMaxRadix(7); private static final String FIELD_SESSION_INTERFACE_VERSION = Util.intToStringMaxRadix(8); - // Next field key = 9 + // Next field key = 10 @Override public Bundle toBundle() { @@ -91,6 +99,10 @@ import androidx.media3.common.util.Util; bundle.putInt(FIELD_LIBRARY_VERSION, libraryVersion); BundleCompat.putBinder(bundle, FIELD_SESSION_BINDER, sessionBinder.asBinder()); bundle.putParcelable(FIELD_SESSION_ACTIVITY, sessionActivity); + if (!customLayout.isEmpty()) { + bundle.putParcelableArrayList( + FIELD_CUSTOM_LAYOUT, BundleableUtil.toBundleArrayList(customLayout)); + } bundle.putBundle(FIELD_SESSION_COMMANDS, sessionCommands.toBundle()); bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_SESSION, playerCommandsFromSession.toBundle()); bundle.putBundle(FIELD_PLAYER_COMMANDS_FROM_PLAYER, playerCommandsFromPlayer.toBundle()); @@ -114,6 +126,12 @@ import androidx.media3.common.util.Util; bundle.getInt(FIELD_SESSION_INTERFACE_VERSION, /* defaultValue= */ 0); IBinder sessionBinder = checkNotNull(BundleCompat.getBinder(bundle, FIELD_SESSION_BINDER)); @Nullable PendingIntent sessionActivity = bundle.getParcelable(FIELD_SESSION_ACTIVITY); + @Nullable + List commandButtonArrayList = bundle.getParcelableArrayList(FIELD_CUSTOM_LAYOUT); + ImmutableList customLayout = + commandButtonArrayList != null + ? BundleableUtil.fromBundleList(CommandButton.CREATOR, commandButtonArrayList) + : ImmutableList.of(); @Nullable Bundle sessionCommandsBundle = bundle.getBundle(FIELD_SESSION_COMMANDS); SessionCommands sessionCommands = sessionCommandsBundle == null @@ -142,6 +160,7 @@ import androidx.media3.common.util.Util; sessionInterfaceVersion, IMediaSession.Stub.asInterface(sessionBinder), sessionActivity, + customLayout, sessionCommands, playerCommandsFromSession, playerCommandsFromPlayer, diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaController.java b/libraries/session/src/main/java/androidx/media3/session/MediaController.java index 4286e2229f..26329e2f6b 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaController.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaController.java @@ -58,6 +58,7 @@ import androidx.media3.common.util.Log; import androidx.media3.common.util.Size; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; +import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -343,22 +344,38 @@ public class MediaController implements Player { /** * Called when the session sets the custom layout through {@link MediaSession#setCustomLayout}. * - *

Return a {@link ListenableFuture} to reply with a {@link SessionResult} to the session - * asynchronously. You can also return a {@link SessionResult} directly by using Guava's {@link - * Futures#immediateFuture(Object)}. + *

This method will be deprecated. Use {@link #onCustomLayoutChanged(MediaController, List)} + * instead. * - *

The default implementation returns a {@link ListenableFuture} of {@link - * SessionResult#RESULT_ERROR_NOT_SUPPORTED}. + *

There is a slight difference in behaviour. This to be deprecated method may be + * consecutively called with an unchanged custom layout passed into it, in which case the new + * {@link #onCustomLayoutChanged(MediaController, List)} isn't called again for equal arguments. * - * @param controller The controller. - * @param layout The ordered list of {@link CommandButton}. - * @return The result of handling the custom layout. + *

Further, when the available commands of a controller change in a way that affect whether + * buttons of the custom layout are enabled or disabled, the new callback {@link + * #onCustomLayoutChanged(MediaController, List)} is called, in which case the deprecated + * callback isn't called. */ default ListenableFuture onSetCustomLayout( MediaController controller, List layout) { return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED)); } + /** + * Called when the {@linkplain #getCustomLayout() custom layout} changed. + * + *

The custom layout can change when either the session {@linkplain + * MediaSession#setCustomLayout changes the custom layout}, or when the session {@linkplain + * MediaSession#setAvailableCommands(MediaSession.ControllerInfo, SessionCommands, Commands) + * changes the available commands} for a controller that affect whether buttons of the custom + * layout are enabled or disabled. + * + * @param controller The controller. + * @param layout The ordered list of {@linkplain CommandButton command buttons}. + */ + @UnstableApi + default void onCustomLayoutChanged(MediaController controller, List layout) {} + /** * Called when the available session commands are changed by session. * @@ -935,6 +952,20 @@ public class MediaController implements Player { return createDisconnectedFuture(); } + /** + * Returns the custom layout. + * + *

After being connected, a change of the custom layout is reported with {@link + * Listener#onCustomLayoutChanged(MediaController, List)}. + * + * @return The custom layout. + */ + @UnstableApi + public final ImmutableList getCustomLayout() { + verifyApplicationThread(); + return isConnected() ? impl.getCustomLayout() : ImmutableList.of(); + } + /** Returns {@code null}. */ @UnstableApi @Override @@ -1985,6 +2016,8 @@ public class MediaController implements Player { ListenableFuture sendCustomCommand(SessionCommand command, Bundle args); + ImmutableList getCustomLayout(); + Timeline getCurrentTimeline(); void setMediaItem(MediaItem mediaItem); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java index 55cbb846c4..e14a695b11 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -88,6 +88,7 @@ import com.google.common.util.concurrent.MoreExecutors; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; @@ -119,6 +120,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; private boolean released; private PlayerInfo playerInfo; @Nullable private PendingIntent sessionActivity; + private ImmutableList customLayout; private SessionCommands sessionCommands; private Commands playerCommandsFromSession; private Commands playerCommandsFromPlayer; @@ -143,6 +145,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; playerInfo = PlayerInfo.DEFAULT; surfaceSize = Size.UNKNOWN; sessionCommands = SessionCommands.EMPTY; + customLayout = ImmutableList.of(); playerCommandsFromSession = Commands.EMPTY; playerCommandsFromPlayer = Commands.EMPTY; intersectedPlayerCommands = @@ -521,11 +524,6 @@ import org.checkerframework.checker.nullness.qual.NonNull; seekToInternalByOffset(getSeekForwardIncrement()); } - @Override - public PendingIntent getSessionActivity() { - return sessionActivity; - } - @Override public void setPlayWhenReady(boolean playWhenReady) { if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { @@ -710,6 +708,16 @@ import org.checkerframework.checker.nullness.qual.NonNull; (iSession, seq) -> iSession.onCustomCommand(controllerStub, seq, command.toBundle(), args)); } + @Override + public PendingIntent getSessionActivity() { + return sessionActivity; + } + + @Override + public ImmutableList getCustomLayout() { + return customLayout; + } + @Override public Timeline getCurrentTimeline() { return playerInfo.timeline; @@ -2477,6 +2485,8 @@ import org.checkerframework.checker.nullness.qual.NonNull; playerCommandsFromPlayer = result.playerCommandsFromPlayer; intersectedPlayerCommands = createIntersectedCommands(playerCommandsFromSession, playerCommandsFromPlayer); + customLayout = + getEnabledCustomLayout(result.customLayout, intersectedPlayerCommands, sessionCommands); playerInfo = result.playerInfo; try { // Implementation for the local binder is no-op, @@ -2636,8 +2646,13 @@ import org.checkerframework.checker.nullness.qual.NonNull; intersectedPlayerCommandsChanged = !Util.areEqual(intersectedPlayerCommands, prevIntersectedPlayerCommands); } + boolean customLayoutChanged = false; if (sessionCommandsChanged) { this.sessionCommands = sessionCommands; + ImmutableList oldCustomLayout = customLayout; + customLayout = + getEnabledCustomLayout(customLayout, intersectedPlayerCommands, sessionCommands); + customLayoutChanged = !customLayout.equals(oldCustomLayout); } if (intersectedPlayerCommandsChanged) { listeners.sendEvent( @@ -2650,6 +2665,11 @@ import org.checkerframework.checker.nullness.qual.NonNull; listener -> listener.onAvailableSessionCommandsChanged(getInstance(), sessionCommands)); } + if (customLayoutChanged) { + getInstance() + .notifyControllerListener( + listener -> listener.onCustomLayoutChanged(getInstance(), customLayout)); + } } void onAvailableCommandsChangedFromPlayer(Commands commandsFromPlayer) { @@ -2672,27 +2692,25 @@ import org.checkerframework.checker.nullness.qual.NonNull; } } + // Calling deprecated listener callback method for backwards compatibility. + @SuppressWarnings("deprecation") void onSetCustomLayout(int seq, List layout) { if (!isConnected()) { return; } - List validatedCustomLayout = new ArrayList<>(); - for (int i = 0; i < layout.size(); i++) { - CommandButton button = layout.get(i); - if (intersectedPlayerCommands.contains(button.playerCommand) - || (button.sessionCommand != null && sessionCommands.contains(button.sessionCommand)) - || (button.playerCommand != Player.COMMAND_INVALID - && sessionCommands.contains(button.playerCommand))) { - validatedCustomLayout.add(button); - } - } + ImmutableList oldCustomLayout = customLayout; + customLayout = getEnabledCustomLayout(layout, intersectedPlayerCommands, sessionCommands); + boolean hasCustomLayoutChanged = !Objects.equals(customLayout, oldCustomLayout); getInstance() .notifyControllerListener( listener -> { ListenableFuture future = checkNotNull( - listener.onSetCustomLayout(getInstance(), validatedCustomLayout), + listener.onSetCustomLayout(getInstance(), customLayout), "MediaController.Listener#onSetCustomLayout() must not return null"); + if (hasCustomLayoutChanged) { + listener.onCustomLayoutChanged(getInstance(), customLayout); + } sendControllerResultWhenReady(seq, future); }); } @@ -2705,7 +2723,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; .notifyControllerListener(listener -> listener.onExtrasChanged(getInstance(), extras)); } - void onSetSessionActivity(int seq, PendingIntent sessionActivity) { + public void onSetSessionActivity(int seq, PendingIntent sessionActivity) { if (!isConnected()) { return; } @@ -2734,6 +2752,23 @@ import org.checkerframework.checker.nullness.qual.NonNull; } } + private static ImmutableList getEnabledCustomLayout( + List customLayout, + Player.Commands playerCommands, + SessionCommands sessionCommands) { + ImmutableList.Builder availableCustomLayout = new ImmutableList.Builder<>(); + for (int i = 0; i < customLayout.size(); i++) { + CommandButton button = customLayout.get(i); + boolean isEnabled = + playerCommands.contains(button.playerCommand) + || (button.sessionCommand != null && sessionCommands.contains(button.sessionCommand)) + || (button.playerCommand != Player.COMMAND_INVALID + && sessionCommands.contains(button.playerCommand)); + availableCustomLayout.add(button.copyWithIsEnabled(isEnabled)); + } + return availableCustomLayout.build(); + } + @Player.RepeatMode private static int convertRepeatModeForNavigation(@Player.RepeatMode int repeatMode) { return repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_OFF : repeatMode; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java index 339295fca3..22659b6957 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java @@ -441,6 +441,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; return controllerCompat.getSessionActivity(); } + @Override + public ImmutableList getCustomLayout() { + return controllerInfo.customLayout; + } + @Override @Nullable public PlaybackException getPlayerError() { @@ -1512,6 +1517,8 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; mediaItemTransitionReason); } + // Calling deprecated listener callback method for backwards compatibility. + @SuppressWarnings("deprecation") private void updateControllerInfo( boolean notifyConnected, LegacyPlayerInfo newLegacyPlayerInfo, @@ -1531,9 +1538,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) { getInstance() .notifyControllerListener( - listener -> - ignoreFuture( - listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout))); + listener -> { + ignoreFuture( + listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout)); + listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout); + }); } return; } @@ -1662,9 +1671,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization; if (!oldControllerInfo.customLayout.equals(newControllerInfo.customLayout)) { getInstance() .notifyControllerListener( - listener -> - ignoreFuture( - listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout))); + listener -> { + ignoreFuture( + listener.onSetCustomLayout(getInstance(), newControllerInfo.customLayout)); + listener.onCustomLayoutChanged(getInstance(), newControllerInfo.customLayout); + }); } listeners.flushEvents(); } diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java index 1359b39dfa..e393330ec6 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java @@ -41,6 +41,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.List; /** * Superclass to be extended by services hosting {@link MediaLibrarySession media library sessions}. @@ -124,14 +125,6 @@ public abstract class MediaLibraryService extends MediaSessionService { */ public interface Callback extends MediaSession.Callback { - @Override - default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) { - SessionCommands sessionCommands = - new SessionCommands.Builder().addAllLibraryCommands().addAllSessionCommands().build(); - Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build(); - return ConnectionResult.accept(sessionCommands, playerCommands); - } - /** * Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link * MediaBrowser#getLibraryRoot(LibraryParams)}. @@ -439,6 +432,35 @@ public abstract class MediaLibraryService extends MediaSessionService { return super.setBitmapLoader(bitmapLoader); } + /** + * Sets the custom layout of the session. + * + *

The buttons are converted to custom actions in the legacy media session playback state + * for legacy controllers (see {@code + * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). When + * converting, the {@linkplain SessionCommand#customExtras custom extras of the session + * command} is used for the extras of the legacy custom action. + * + *

Controllers that connect have the custom layout of the session available with the + * initial connection result by default. A custom layout specific to a controller can be set + * when the controller {@linkplain MediaLibrarySession.Callback#onConnect connects} by using + * an {@link ConnectionResult.AcceptedResultBuilder}. + * + *

On the controller side, {@link CommandButton#isEnabled} is overridden according to the + * available commands of the controller. + * + *

Use {@link MediaSession#setCustomLayout} to update the custom layout during the lifetime + * of the session. + * + * @param customLayout The ordered list of {@link CommandButton command buttons}. + * @return The builder to allow chaining. + */ + @UnstableApi + @Override + public Builder setCustomLayout(List customLayout) { + return super.setCustomLayout(customLayout); + } + /** * Builds a {@link MediaLibrarySession}. * @@ -452,7 +474,14 @@ public abstract class MediaLibraryService extends MediaSessionService { bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader()); } return new MediaLibrarySession( - context, id, player, sessionActivity, callback, extras, checkNotNull(bitmapLoader)); + context, + id, + player, + sessionActivity, + customLayout, + callback, + extras, + checkNotNull(bitmapLoader)); } } @@ -461,10 +490,12 @@ public abstract class MediaLibraryService extends MediaSessionService { String id, Player player, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, MediaSession.Callback callback, Bundle tokenExtras, BitmapLoader bitmapLoader) { - super(context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); + super( + context, id, player, sessionActivity, customLayout, callback, tokenExtras, bitmapLoader); } @Override @@ -473,6 +504,7 @@ public abstract class MediaLibraryService extends MediaSessionService { String id, Player player, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, MediaSession.Callback callback, Bundle tokenExtras, BitmapLoader bitmapLoader) { @@ -482,6 +514,7 @@ public abstract class MediaLibraryService extends MediaSessionService { id, player, sessionActivity, + customLayout, (Callback) callback, tokenExtras, bitmapLoader); 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 9aae93f671..87d0c69ffb 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java @@ -76,10 +76,20 @@ import java.util.concurrent.Future; String id, Player player, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, MediaLibrarySession.Callback callback, Bundle tokenExtras, BitmapLoader bitmapLoader) { - super(instance, context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); + super( + instance, + context, + id, + player, + sessionActivity, + customLayout, + callback, + tokenExtras, + bitmapLoader); this.instance = instance; this.callback = callback; subscriptions = new ArrayMap<>(); 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 bba9acb2c8..fcb55c2632 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -32,6 +32,7 @@ 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; @@ -59,11 +60,13 @@ import androidx.media3.common.util.BitmapLoader; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.session.MediaLibraryService.LibraryParams; +import androidx.media3.session.MediaLibraryService.MediaLibrarySession; import com.google.common.base.Objects; import com.google.common.collect.ImmutableList; import com.google.common.primitives.Longs; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.DoNotMock; import java.util.HashMap; import java.util.List; @@ -348,6 +351,32 @@ public class MediaSession { return super.setBitmapLoader(bitmapLoader); } + /** + * Sets the custom layout of the session. + * + *

The button are converted to custom actions in the legacy media session playback state for + * legacy controllers (see {@code + * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}). When + * converting, the {@linkplain SessionCommand#customExtras custom extras of the session command} + * is used for the extras of the legacy custom action. + * + *

Controllers that connect have the custom layout of the session available with the initial + * connection result by default. A custom layout specific to a controller can be set when the + * controller {@linkplain MediaSession.Callback#onConnect connects} by using an {@link + * ConnectionResult.AcceptedResultBuilder}. + * + *

Use {@code MediaSession.setCustomLayout(..)} to update the custom layout during the life + * time of the session. + * + * @param customLayout The ordered list of {@link CommandButton command buttons}. + * @return The builder to allow chaining. + */ + @UnstableApi + @Override + public Builder setCustomLayout(List customLayout) { + return super.setCustomLayout(customLayout); + } + /** * Builds a {@link MediaSession}. * @@ -361,7 +390,14 @@ public class MediaSession { bitmapLoader = new CacheBitmapLoader(new SimpleBitmapLoader()); } return new MediaSession( - context, id, player, sessionActivity, callback, extras, checkNotNull(bitmapLoader)); + context, + id, + player, + sessionActivity, + customLayout, + callback, + extras, + checkNotNull(bitmapLoader)); } } @@ -550,6 +586,7 @@ public class MediaSession { String id, Player player, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, Callback callback, Bundle tokenExtras, BitmapLoader bitmapLoader) { @@ -559,7 +596,16 @@ public class MediaSession { } SESSION_ID_TO_SESSION_MAP.put(id, this); } - impl = createImpl(context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); + impl = + createImpl( + context, + id, + player, + sessionActivity, + customLayout, + callback, + tokenExtras, + bitmapLoader); } /* package */ MediaSessionImpl createImpl( @@ -567,11 +613,20 @@ public class MediaSession { String id, Player player, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, Callback callback, Bundle tokenExtras, BitmapLoader bitmapLoader) { return new MediaSessionImpl( - this, context, id, player, sessionActivity, callback, tokenExtras, bitmapLoader); + this, + context, + id, + player, + sessionActivity, + customLayout, + callback, + tokenExtras, + bitmapLoader); } /* package */ MediaSessionImpl getImpl() { @@ -697,54 +752,28 @@ public class MediaSession { } /** - * Requests that controllers set the ordered list of {@link CommandButton} to build UI with it. + * Sets the custom layout for the given Media3 controller. * - *

It's up to controller's decision how to represent the layout in its own UI. Here are some - * examples. Note: {@code layout[i]} means a {@link CommandButton} at index {@code i} in the given - * list. + *

Make sure to have the session commands of all command buttons of the custom layout + * {@linkplain MediaController#getAvailableSessionCommands() available for controllers}. Include + * the custom session commands a controller should be able to send in the available commands of + * the connection result {@linkplain MediaSession.Callback#onConnect(MediaSession, ControllerInfo) + * that your app returns when the controller connects}. The {@link CommandButton#isEnabled} flag + * is set according to the available commands of the controller and overrides a value that may + * have been set by the app. * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - * - *
Examples of controller's UI layout
Controller UI layoutLayout example
- * Row with 3 icons - * - * {@code layout[1]} {@code layout[0]} {@code layout[2]} - *
- * Row with 5 icons - * - * {@code layout[3]} {@code layout[1]} {@code layout[0]} {@code layout[2]} {@code layout[4]} - *
- * Row with 5 icons and an overflow icon, and another expandable row with 5 extra icons - * - * {@code layout[5]} {@code layout[6]} {@code layout[7]} {@code layout[8]} {@code layout[9]} - *
- * {@code layout[3]} {@code layout[1]} {@code layout[0]} {@code layout[2]} {@code layout[4]} - *
+ *

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}. + * + *

It's up to controller's decision how to represent the layout in its own UI. * *

Interoperability: This call has no effect when called for a {@linkplain * ControllerInfo#LEGACY_CONTROLLER_VERSION legacy controller}. * - * @param controller The controller to specify layout. - * @param layout The ordered list of {@link CommandButton}. + * @param controller The controller for which to set the custom layout. + * @param layout The ordered list of {@linkplain CommandButton command buttons}. */ public final ListenableFuture setCustomLayout( ControllerInfo controller, List layout) { @@ -754,18 +783,29 @@ public class MediaSession { } /** - * Broadcasts the custom layout to all connected Media3 controllers and converts the buttons to - * custom actions in the legacy media session playback state (see {@code - * PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}) for legacy - * controllers. + * Sets the custom layout that can initially be set when building the session. * - *

When converting, the {@link SessionCommand#customExtras custom extras of 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. + * + *

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}. + * + *

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

Media3 controllers that connect after calling this method will not receive the broadcast. - * You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link - * MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers - * aware of the custom layout. + *

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 + * the controller {@linkplain MediaSession.Callback#onConnect connects} by using an {@link + * ConnectionResult.AcceptedResultBuilder}. * * @param layout The ordered list of {@link CommandButton}. */ @@ -798,6 +838,18 @@ public class MediaSession { impl.setAvailableCommands(controller, sessionCommands, playerCommands); } + /** + * Returns the custom layout of the session. + * + *

For informational purpose only. Mutations on the {@link Bundle} of either a {@link + * CommandButton} or a {@link SessionCommand} do not have effect. To change the custom layout use + * {@link #setCustomLayout(List)} or {@link #setCustomLayout(ControllerInfo, List)}. + */ + @UnstableApi + public ImmutableList getCustomLayout() { + return impl.getCustomLayout(); + } + /** * Broadcasts a custom command to all connected controllers. * @@ -974,18 +1026,22 @@ public class MediaSession { /** * Called when a controller is about to connect to this session. Return a {@link - * ConnectionResult result} containing available commands for the controller by using {@link - * ConnectionResult#accept(SessionCommands, Player.Commands)}. By default it allows all - * connection requests and commands. + * ConnectionResult result} for the controller by using {@link + * ConnectionResult#accept(SessionCommands, Player.Commands)} or the {@link + * ConnectionResult.AcceptedResultBuilder}. + * + *

If this callback is not overridden, it allows all controllers to connect that can access + * the session. All session and player commands are made available and the {@linkplain + * MediaSession#getCustomLayout() custom layout of the session} is included. * *

Note that the player commands in {@link ConnectionResult#availablePlayerCommands} will be * intersected with the {@link Player#getAvailableCommands() available commands} of the * underlying {@link Player} and the controller will only be able to call the commonly available * commands. * - *

You can reject the connection by returning {@link ConnectionResult#reject()}}. In that - * case, the controller will get {@link SecurityException} when resolving the {@link - * ListenableFuture} returned by {@link MediaController.Builder#buildAsync()}. + *

Returning {@link ConnectionResult#reject()} rejects the connection. In that case, the + * controller will get {@link SecurityException} when resolving the {@link ListenableFuture} + * returned by {@link MediaController.Builder#buildAsync()}. * *

The controller isn't connected yet, so calls to the controller (e.g. {@link * #sendCustomCommand}, {@link #setCustomLayout}) will be ignored. Use {@link #onPostConnect} @@ -1001,10 +1057,7 @@ public class MediaSession { * @return The {@link ConnectionResult}. */ default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) { - SessionCommands sessionCommands = - new SessionCommands.Builder().addAllSessionCommands().build(); - Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build(); - return ConnectionResult.accept(sessionCommands, playerCommands); + return new ConnectionResult.AcceptedResultBuilder(session).build(); } /** @@ -1345,10 +1398,97 @@ public class MediaSession { /** * A result for {@link Callback#onConnect(MediaSession, ControllerInfo)} to denote the set of - * commands that are available for the given {@link ControllerInfo controller}. + * available commands and the custom layout for a {@link ControllerInfo controller}. */ public static final class ConnectionResult { + /** A builder for {@link ConnectionResult} instances to accept a connection. */ + @UnstableApi + public static class AcceptedResultBuilder { + private SessionCommands availableSessionCommands; + private Player.Commands availablePlayerCommands = DEFAULT_PLAYER_COMMANDS; + @Nullable private ImmutableList customLayout; + + /** + * Creates an instance. + * + * @param mediaSession The session for which to create a {@link ConnectionResult}. + */ + public AcceptedResultBuilder(MediaSession mediaSession) { + availableSessionCommands = + mediaSession instanceof MediaLibrarySession + ? DEFAULT_SESSION_AND_LIBRARY_COMMANDS + : DEFAULT_SESSION_COMMANDS; + } + + /** + * Sets the session commands that are available to the controller that gets this result + * returned when {@linkplain Callback#onConnect(MediaSession, ControllerInfo) connecting}. + * + *

The default is {@link ConnectionResult#DEFAULT_SESSION_AND_LIBRARY_COMMANDS} for a + * {@link MediaLibrarySession} and {@link ConnectionResult#DEFAULT_SESSION_COMMANDS} for a + * {@link MediaSession}. + */ + @CanIgnoreReturnValue + public AcceptedResultBuilder setAvailableSessionCommands( + SessionCommands availableSessionCommands) { + this.availableSessionCommands = checkNotNull(availableSessionCommands); + return this; + } + + /** + * Sets the player commands that are available to the controller that gets this result + * returned when {@linkplain Callback#onConnect(MediaSession, ControllerInfo) connecting}. + * + *

This set of available player commands is intersected with the actual player commands + * supported by a player. The resulting intersection is the set of commands actually being + * available to a controller. + * + *

The default is {@link ConnectionResult#DEFAULT_PLAYER_COMMANDS}. + */ + @CanIgnoreReturnValue + public AcceptedResultBuilder setAvailablePlayerCommands( + Player.Commands availablePlayerCommands) { + this.availablePlayerCommands = checkNotNull(availablePlayerCommands); + return this; + } + + /** + * Sets the custom layout, overriding the {@linkplain MediaSession#getCustomLayout() custom + * layout of the session}. + * + *

The default is null to indicate that the custom layout of the session should be used. + * + *

Make sure to have the session commands of all command buttons of the custom layout + * included in the {@linkplain #setAvailableSessionCommands(SessionCommands)} available + * session commands}. + */ + @CanIgnoreReturnValue + public AcceptedResultBuilder setCustomLayout( + @Nullable ImmutableList customLayout) { + this.customLayout = customLayout; + return this; + } + + /** Returns a new {@link ConnectionResult} instance for accepting a connection. */ + public ConnectionResult build() { + return new ConnectionResult( + /* accepted= */ true, availableSessionCommands, availablePlayerCommands, customLayout); + } + } + + @UnstableApi + public static final SessionCommands DEFAULT_SESSION_COMMANDS = + new SessionCommands.Builder().addAllSessionCommands().build(); + + @UnstableApi + public static final SessionCommands DEFAULT_SESSION_AND_LIBRARY_COMMANDS = + new SessionCommands.Builder().addAllLibraryCommands().addAllSessionCommands().build(); + + @UnstableApi + public static final Player.Commands DEFAULT_PLAYER_COMMANDS = + new Player.Commands.Builder().addAllCommands().build(); + /** Whether the connection request is accepted or not. */ public final boolean isAccepted; @@ -1358,25 +1498,44 @@ public class MediaSession { /** Available player commands. */ public final Player.Commands availablePlayerCommands; + /** The custom layout or null if the custom layout of the session should be used. */ + @UnstableApi @Nullable public final ImmutableList customLayout; + /** Creates a new instance with the given available session and player commands. */ private ConnectionResult( boolean accepted, SessionCommands availableSessionCommands, - Player.Commands availablePlayerCommands) { + Player.Commands availablePlayerCommands, + @Nullable ImmutableList customLayout) { isAccepted = accepted; - this.availableSessionCommands = checkNotNull(availableSessionCommands); - this.availablePlayerCommands = checkNotNull(availablePlayerCommands); + this.availableSessionCommands = availableSessionCommands; + this.availablePlayerCommands = availablePlayerCommands; + this.customLayout = customLayout; } + /** + * Creates a connection result with the given session and player commands. + * + *

Commands are specific to the controller receiving this connection result. + * + *

The controller receives {@linkplain MediaSession#getCustomLayout() the custom layout of + * the session}. + * + *

See {@link AcceptedResultBuilder} for a more flexible way to accept a connection. + */ public static ConnectionResult accept( SessionCommands availableSessionCommands, Player.Commands availablePlayerCommands) { return new ConnectionResult( - /* accepted= */ true, availableSessionCommands, availablePlayerCommands); + /* accepted= */ true, + availableSessionCommands, + availablePlayerCommands, + /* customLayout= */ null); } + /** Creates a {@link ConnectionResult} to reject a connection. */ public static ConnectionResult reject() { return new ConnectionResult( - /* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY); + /* accepted= */ false, SessionCommands.EMPTY, Player.Commands.EMPTY, ImmutableList.of()); } } @@ -1536,9 +1695,8 @@ public class MediaSession { } /** - * A base class for {@link MediaSession.Builder} and {@link - * MediaLibraryService.MediaLibrarySession.Builder}. Any changes to this class should be also - * applied to the subclasses. + * A base class for {@link MediaSession.Builder} and {@link MediaLibrarySession.Builder}. Any + * changes to this class should be also applied to the subclasses. */ /* package */ abstract static class BuilderBase< SessionT extends MediaSession, @@ -1553,6 +1711,8 @@ public class MediaSession { /* package */ Bundle extras; /* package */ @MonotonicNonNull BitmapLoader bitmapLoader; + /* package */ ImmutableList customLayout; + public BuilderBase(Context context, Player player, CallbackT callback) { this.context = checkNotNull(context); this.player = checkNotNull(player); @@ -1560,6 +1720,7 @@ public class MediaSession { id = ""; this.callback = callback; extras = Bundle.EMPTY; + customLayout = ImmutableList.of(); } @SuppressWarnings("unchecked") @@ -1592,6 +1753,12 @@ public class MediaSession { return (BuilderT) this; } + @SuppressWarnings("unchecked") + public BuilderT setCustomLayout(List customLayout) { + this.customLayout = ImmutableList.copyOf(customLayout); + return (BuilderT) this; + } + public abstract SessionT build(); } } 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 2f1fbd7dc3..7cbe33fdbe 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -128,6 +128,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; // Should be only accessed on the application looper private long sessionPositionUpdateDelayMs; + private ImmutableList customLayout; public MediaSessionImpl( MediaSession instance, @@ -135,6 +136,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; String id, Player player, @Nullable PendingIntent sessionActivity, + ImmutableList customLayout, MediaSession.Callback callback, Bundle tokenExtras, BitmapLoader bitmapLoader) { @@ -147,6 +149,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; sessionStub = new MediaSessionStub(thisRef); this.sessionActivity = sessionActivity; + this.customLayout = customLayout; mainHandler = new Handler(Looper.getMainLooper()); applicationHandler = new Handler(player.getApplicationLooper()); @@ -187,6 +190,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; PlayerWrapper playerWrapper = new PlayerWrapper(player); this.playerWrapper = playerWrapper; + this.playerWrapper.setCustomLayout(customLayout); postOrRun( applicationHandler, () -> @@ -209,6 +213,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private void setPlayerInternal( @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { playerWrapper = newPlayerWrapper; + playerWrapper.setCustomLayout(customLayout); if (oldPlayerWrapper != null) { oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener)); } @@ -307,7 +312,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; } public void setCustomLayout(List layout) { - playerWrapper.setCustomLayout(ImmutableList.copyOf(layout)); + customLayout = ImmutableList.copyOf(layout); + playerWrapper.setCustomLayout(customLayout); dispatchRemoteControllerTaskWithoutReturn( (controller, seq) -> controller.setCustomLayout(seq, layout)); } @@ -503,6 +509,10 @@ 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)) { 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 962f66cfc4..c4c6ddc0ef 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -522,6 +522,9 @@ import java.util.concurrent.ExecutionException; MediaSessionStub.VERSION_INT, MediaSessionStub.this, sessionImpl.getSessionActivity(), + connectionResult.customLayout != null + ? connectionResult.customLayout + : sessionImpl.getCustomLayout(), connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands, playerWrapper.getAvailableCommands(), diff --git a/libraries/session/src/test/java/androidx/media3/session/ConnectionResultTest.java b/libraries/session/src/test/java/androidx/media3/session/ConnectionResultTest.java new file mode 100644 index 0000000000..656877025d --- /dev/null +++ b/libraries/session/src/test/java/androidx/media3/session/ConnectionResultTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.session; + +import static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import androidx.annotation.Nullable; +import androidx.media3.common.Player; +import androidx.media3.test.utils.TestExoPlayerBuilder; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.android.controller.ServiceController; + +@RunWith(AndroidJUnit4.class) +public class ConnectionResultTest { + + @Test + public void acceptedResultBuilder_builtWidthMediaSession_correctDefaults() { + Context context = ApplicationProvider.getApplicationContext(); + MediaSession mediaSession = + new MediaSession.Builder(context, new TestExoPlayerBuilder(context).build()).build(); + + MediaSession.ConnectionResult connectionResult = + new MediaSession.ConnectionResult.AcceptedResultBuilder(mediaSession).build(); + + assertThat(connectionResult.availableSessionCommands) + .isEqualTo(MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS); + assertThat(connectionResult.availablePlayerCommands) + .isEqualTo(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS); + assertThat(connectionResult.customLayout).isNull(); + assertThat(connectionResult.isAccepted).isTrue(); + + mediaSession.getPlayer().release(); + mediaSession.release(); + } + + @Test + public void acceptedResultBuilder_builtWidthMediaSession_correctlyOverridden() { + Context context = ApplicationProvider.getApplicationContext(); + MediaSession mediaSession = + new MediaSession.Builder(context, new TestExoPlayerBuilder(context).build()).build(); + + MediaSession.ConnectionResult connectionResult = + new MediaSession.ConnectionResult.AcceptedResultBuilder(mediaSession) + .setAvailableSessionCommands(SessionCommands.EMPTY) + .setAvailablePlayerCommands(Player.Commands.EMPTY) + .setCustomLayout(ImmutableList.of()) + .build(); + + assertThat(connectionResult.availableSessionCommands.commands).isEmpty(); + assertThat(connectionResult.availablePlayerCommands.size()).isEqualTo(0); + assertThat(connectionResult.customLayout).isEmpty(); + assertThat(connectionResult.isAccepted).isTrue(); + + mediaSession.getPlayer().release(); + mediaSession.release(); + } + + @Test + public void + acceptedResultBuilder_builtWidthMediaLibrarySession_correctDefaultLibrarySessionCommands() { + Context context = ApplicationProvider.getApplicationContext(); + ServiceController serviceController = Robolectric.buildService(TestService.class); + TestService service = serviceController.create().get(); + MediaSession mediaLibrarySession = + new MediaLibraryService.MediaLibrarySession.Builder( + service, + new TestExoPlayerBuilder(context).build(), + new MediaLibraryService.MediaLibrarySession.Callback() {}) + .build(); + + MediaSession.ConnectionResult connectionResult = + new MediaSession.ConnectionResult.AcceptedResultBuilder(mediaLibrarySession).build(); + + assertThat(connectionResult.availableSessionCommands) + .isEqualTo(MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS); + assertThat(connectionResult.availablePlayerCommands) + .isEqualTo(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS); + assertThat(connectionResult.customLayout).isNull(); + assertThat(connectionResult.isAccepted).isTrue(); + mediaLibrarySession.getPlayer().release(); + mediaLibrarySession.release(); + serviceController.destroy(); + } + + @Test + public void accept() { + SessionCommands sessionCommands = + new SessionCommands.Builder().add(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM).build(); + Player.Commands playerCommands = + new Player.Commands.Builder().add(Player.COMMAND_PLAY_PAUSE).build(); + + MediaSession.ConnectionResult connectionResult = + MediaSession.ConnectionResult.accept(sessionCommands, playerCommands); + + assertThat(connectionResult.availableSessionCommands).isEqualTo(sessionCommands); + assertThat(connectionResult.availablePlayerCommands).isEqualTo(playerCommands); + assertThat(connectionResult.customLayout).isNull(); + assertThat(connectionResult.isAccepted).isTrue(); + } + + @Test + public void reject() { + MediaSession.ConnectionResult connectionResult = MediaSession.ConnectionResult.reject(); + + assertThat(connectionResult.availableSessionCommands.commands).isEmpty(); + assertThat(connectionResult.availablePlayerCommands.size()).isEqualTo(0); + assertThat(connectionResult.customLayout).isEmpty(); + assertThat(connectionResult.isAccepted).isFalse(); + } + + private static final class TestService extends MediaLibraryService { + @Nullable + @Override + public MediaLibrarySession onGetSession(MediaSession.ControllerInfo controllerInfo) { + return null; + } + } +} diff --git a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl index cad69ebcf9..ec232b2110 100644 --- a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl +++ b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl @@ -99,6 +99,7 @@ interface IRemoteMediaController { int page, int pageSize, in Bundle libraryParams); + Bundle getCustomLayout(String controllerId); Bundle getItem(String controllerId, String mediaId); Bundle search(String controllerId, String query, in Bundle libraryParams); Bundle getSearchResult( diff --git a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java index f7642e9dd2..6d710da272 100644 --- a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java +++ b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/CommonConstants.java @@ -102,6 +102,7 @@ public class CommonConstants { public static final String KEY_TRACK_SELECTION_PARAMETERS = "trackSelectionParameters"; public static final String KEY_CURRENT_TRACKS = "currentTracks"; public static final String KEY_AVAILABLE_COMMANDS = "availableCommands"; + public static final String KEY_COMMAND_BUTTON_LIST = "command_button_list"; // SessionCompat arguments public static final String KEY_SESSION_COMPAT_TOKEN = "sessionCompatToken"; 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 58e1434925..aa9f9b3eae 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 @@ -20,6 +20,7 @@ public class MediaSessionConstants { // Test method names public static final String TEST_GET_SESSION_ACTIVITY = "testGetSessionActivity"; + public static final String TEST_GET_CUSTOM_LAYOUT = "testGetCustomLayout"; public static final String TEST_WITH_CUSTOM_COMMANDS = "testWithCustomCommands"; public static final String TEST_CONTROLLER_LISTENER_SESSION_REJECTS = "connection_sessionRejects"; public static final String TEST_IS_SESSION_COMMAND_AVAILABLE = "testIsSessionCommandAvailable"; 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 a70c8abb40..229d315ebc 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,7 @@ 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.test.session.R; import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -1435,6 +1436,93 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest threadTestRule.getHandler().postAndSync(player::release); } + @Test + public void + playerWithCustomLayout_sessionBuiltWithCustomLayout_customActionsInInitialPlaybackState() + 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 mediaSession = createMediaSession(player, /* callback= */ null, customLayout); + 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(); + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCustomLayout_setCustomLayout_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 mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + 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() { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + reportedCustomLayout.set(MediaUtils.convertToCustomLayout(state)); + latch.countDown(); + } + }, + threadTestRule.getHandler()); + + getInstrumentation().runOnMainSync(() -> mediaSession.setCustomLayout(customLayout)); + + assertThat(reportedCustomLayout.get()) + .containsExactly( + customLayout.get(0).copyWithIsEnabled(true), + customLayout.get(1).copyWithIsEnabled(true)); + mediaSession.release(); + releasePlayer(player); + } + private PlaybackStateCompat getFirstPlaybackState( MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException { LinkedBlockingDeque playbackStateCompats = new LinkedBlockingDeque<>(); @@ -1477,13 +1565,19 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest } private static MediaSession createMediaSession(Player player) { - return createMediaSession(player, null); + return createMediaSession(player, /* callback= */ null); } private static MediaSession createMediaSession( Player player, @Nullable MediaSession.Callback callback) { + return createMediaSession(player, callback, /* customLayout= */ ImmutableList.of()); + } + + private static MediaSession createMediaSession( + Player player, @Nullable MediaSession.Callback callback, List customLayout) { MediaSession.Builder session = - new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player); + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player) + .setCustomLayout(customLayout); if (callback != null) { session.setCallback(callback); } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java index b88dff7b8b..40aba326f5 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerListenerWithMediaSessionCompatTest.java @@ -34,7 +34,9 @@ import androidx.media3.common.DeviceInfo; import androidx.media3.common.FlagSet; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; +import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.Util; +import androidx.media3.test.session.R; import androidx.media3.test.session.common.CommonConstants; import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.MainLooperTestRule; @@ -42,6 +44,7 @@ import androidx.media3.test.session.common.TestUtils; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; 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 java.util.ArrayList; @@ -361,4 +364,87 @@ public class MediaControllerListenerWithMediaSessionCompatTest { assertThat(deviceVolumeOnEvents.get()).isEqualTo(50); assertThat(getEventsAsList(onEvents.get())).contains(Player.EVENT_DEVICE_VOLUME_CHANGED); } + + @Test + public void getCustomLayout() throws Exception { + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + ConditionVariable onSetCustomLayoutCalled = new ConditionVariable(); + ConditionVariable onCustomLayoutChangedCalled = new ConditionVariable(); + List> setCustomLayoutArguments = new ArrayList<>(); + List> customLayoutChangedArguments = new ArrayList<>(); + List> customLayoutFromGetter = new ArrayList<>(); + controllerTestRule.createController( + session.getSessionToken(), + new MediaController.Listener() { + @Override + public ListenableFuture onSetCustomLayout( + MediaController controller, List layout) { + setCustomLayoutArguments.add(layout); + onSetCustomLayoutCalled.open(); + return MediaController.Listener.super.onSetCustomLayout(controller, layout); + } + + @Override + public void onCustomLayoutChanged( + MediaController controller, List layout) { + customLayoutChangedArguments.add(layout); + customLayoutFromGetter.add(controller.getCustomLayout()); + onCustomLayoutChangedCalled.open(); + } + }); + Bundle extras1 = new Bundle(); + extras1.putString("key", "value-1"); + PlaybackStateCompat.CustomAction customAction1 = + new PlaybackStateCompat.CustomAction.Builder( + "command1", "button1", /* icon= */ R.drawable.media3_notification_small_icon) + .setExtras(extras1) + .build(); + Bundle extras2 = new Bundle(); + extras2.putString("key", "value-2"); + PlaybackStateCompat.CustomAction customAction2 = + new PlaybackStateCompat.CustomAction.Builder( + "command2", "button2", /* icon= */ R.drawable.media3_notification_small_icon) + .setExtras(extras2) + .build(); + PlaybackStateCompat.Builder playbackState1 = + new PlaybackStateCompat.Builder() + .addCustomAction(customAction1) + .addCustomAction(customAction2); + PlaybackStateCompat.Builder playbackState2 = + new PlaybackStateCompat.Builder().addCustomAction(customAction1); + + session.setPlaybackState(playbackState1.build()); + assertThat(onSetCustomLayoutCalled.block(TIMEOUT_MS)).isTrue(); + assertThat(onCustomLayoutChangedCalled.block(TIMEOUT_MS)).isTrue(); + onSetCustomLayoutCalled.close(); + onCustomLayoutChangedCalled.close(); + session.setPlaybackState(playbackState2.build()); + assertThat(onSetCustomLayoutCalled.block(TIMEOUT_MS)).isTrue(); + assertThat(onCustomLayoutChangedCalled.block(TIMEOUT_MS)).isTrue(); + + ImmutableList expectedFirstCustomLayout = + ImmutableList.of(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true)); + ImmutableList expectedSecondCustomLayout = + ImmutableList.of(button1.copyWithIsEnabled(true)); + assertThat(setCustomLayoutArguments) + .containsExactly(expectedFirstCustomLayout, expectedSecondCustomLayout) + .inOrder(); + assertThat(customLayoutChangedArguments) + .containsExactly(expectedFirstCustomLayout, expectedSecondCustomLayout) + .inOrder(); + assertThat(customLayoutFromGetter) + .containsExactly(expectedFirstCustomLayout, expectedSecondCustomLayout) + .inOrder(); + } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java index 5405c0f6d7..8395f98942 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerTest.java @@ -21,6 +21,7 @@ import static androidx.media3.session.MediaUtils.createPlayerCommandsWithout; import static androidx.media3.test.session.common.CommonConstants.DEFAULT_TEST_NAME; import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME; import static androidx.media3.test.session.common.MediaSessionConstants.KEY_AVAILABLE_SESSION_COMMANDS; +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.TestUtils.LONG_TIMEOUT_MS; @@ -57,6 +58,7 @@ import androidx.media3.common.TrackSelectionParameters; import androidx.media3.common.Tracks; import androidx.media3.common.VideoSize; import androidx.media3.common.util.Util; +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.PollingCheck; @@ -65,6 +67,8 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; 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 java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -169,6 +173,255 @@ public class MediaControllerTest { session.cleanUp(); } + @Test + public void getCustomLayout_customLayoutBuiltWithSession_includedOnConnect() throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null); + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder() + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .build(); + setupCustomLayout(session, ImmutableList.of(button1, button2, button3)); + MediaController controller = controllerTestRule.createController(session.getToken()); + + assertThat(threadTestRule.getHandler().postAndSync(controller::getCustomLayout)) + .containsExactly(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true), button3) + .inOrder(); + + session.cleanUp(); + } + + @Test + public void getCustomLayout_sessionSetCustomLayout_customLayoutChanged() throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null); + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder() + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .build(); + CommandButton button4 = + new CommandButton.Builder() + .setDisplayName("button4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command4", Bundle.EMPTY)) + .build(); + setupCustomLayout(session, ImmutableList.of(button1, button2)); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference> reportedCustomLayout = new AtomicReference<>(); + AtomicReference> reportedCustomLayoutChanged = new AtomicReference<>(); + MediaController controller = + controllerTestRule.createController( + session.getToken(), + Bundle.EMPTY, + new MediaController.Listener() { + @Override + public ListenableFuture onSetCustomLayout( + MediaController controller1, List layout) { + latch.countDown(); + reportedCustomLayout.set(layout); + return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS)); + } + + @Override + public void onCustomLayoutChanged( + MediaController controller1, List layout) { + reportedCustomLayoutChanged.set(layout); + latch.countDown(); + } + }); + ImmutableList initialCustomLayoutFromGetter = + threadTestRule.getHandler().postAndSync(controller::getCustomLayout); + session.setCustomLayout(ImmutableList.of(button3, button4)); + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + + ImmutableList newCustomLayoutFromGetter = + threadTestRule.getHandler().postAndSync(controller::getCustomLayout); + + assertThat(initialCustomLayoutFromGetter) + .containsExactly(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true)) + .inOrder(); + assertThat(newCustomLayoutFromGetter).containsExactly(button3, button4).inOrder(); + assertThat(reportedCustomLayout.get()).containsExactly(button3, button4).inOrder(); + assertThat(reportedCustomLayoutChanged.get()).containsExactly(button3, button4).inOrder(); + session.cleanUp(); + } + + @Test + public void getCustomLayout_setAvailableCommandsAddOrRemoveCommands_reportsCustomLayoutChanged() + throws Exception { + RemoteMediaSession session = createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, null); + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + setupCustomLayout(session, ImmutableList.of(button1, button2)); + CountDownLatch latch = new CountDownLatch(2); + List> reportedCustomLayoutChanged = new ArrayList<>(); + List> getterCustomLayoutChanged = new ArrayList<>(); + MediaController.Listener listener = + new MediaController.Listener() { + @Override + public void onCustomLayoutChanged( + MediaController controller, List layout) { + reportedCustomLayoutChanged.add(layout); + getterCustomLayoutChanged.add(controller.getCustomLayout()); + latch.countDown(); + } + }; + MediaController controller = + controllerTestRule.createController( + session.getToken(), /* connectionHints= */ Bundle.EMPTY, listener); + ImmutableList initialCustomLayout = + threadTestRule.getHandler().postAndSync(controller::getCustomLayout); + + // Remove commands in custom layout from available commands. + session.setAvailableCommands(SessionCommands.EMPTY, Player.Commands.EMPTY); + // Add one command back. + session.setAvailableCommands( + new SessionCommands.Builder().add(button2.sessionCommand).build(), Player.Commands.EMPTY); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(initialCustomLayout) + .containsExactly(button1.copyWithIsEnabled(true), button2.copyWithIsEnabled(true)); + assertThat(reportedCustomLayoutChanged).hasSize(2); + assertThat(reportedCustomLayoutChanged.get(0)).containsExactly(button1, button2).inOrder(); + assertThat(reportedCustomLayoutChanged.get(1)) + .containsExactly(button1, button2.copyWithIsEnabled(true)) + .inOrder(); + assertThat(getterCustomLayoutChanged).hasSize(2); + assertThat(getterCustomLayoutChanged.get(0)).containsExactly(button1, button2).inOrder(); + assertThat(getterCustomLayoutChanged.get(1)) + .containsExactly(button1, button2.copyWithIsEnabled(true)) + .inOrder(); + } + + @Test + public void getCustomLayout_sessionSetCustomLayoutNoChange_listenerNotCalledWithEqualLayout() + throws Exception { + RemoteMediaSession session = + createRemoteMediaSession(TEST_GET_CUSTOM_LAYOUT, /* tokenExtras= */ null); + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .build(); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .build(); + CommandButton button3 = + new CommandButton.Builder() + .setDisplayName("button3") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command3", Bundle.EMPTY)) + .build(); + CommandButton button4 = + new CommandButton.Builder() + .setDisplayName("button4") + .setIconResId(R.drawable.media3_notification_small_icon) + .setSessionCommand(new SessionCommand("command4", Bundle.EMPTY)) + .build(); + setupCustomLayout(session, ImmutableList.of(button1, button2)); + CountDownLatch latch = new CountDownLatch(5); + List> reportedCustomLayout = new ArrayList<>(); + List> getterCustomLayout = new ArrayList<>(); + List> reportedCustomLayoutChanged = new ArrayList<>(); + List> getterCustomLayoutChanged = new ArrayList<>(); + MediaController.Listener listener = + new MediaController.Listener() { + @Override + public ListenableFuture onSetCustomLayout( + MediaController controller, List layout) { + reportedCustomLayout.add(layout); + getterCustomLayout.add(controller.getCustomLayout()); + latch.countDown(); + return MediaController.Listener.super.onSetCustomLayout(controller, layout); + } + + @Override + public void onCustomLayoutChanged( + MediaController controller, List layout) { + reportedCustomLayoutChanged.add(layout); + getterCustomLayoutChanged.add(controller.getCustomLayout()); + latch.countDown(); + } + }; + MediaController controller = + controllerTestRule.createController(session.getToken(), Bundle.EMPTY, listener); + ImmutableList initialCustomLayout = + threadTestRule.getHandler().postAndSync(controller::getCustomLayout); + + // First call does not trigger onCustomLayoutChanged. + session.setCustomLayout(ImmutableList.of(button1, button2)); + session.setCustomLayout(ImmutableList.of(button3, button4)); + session.setCustomLayout(ImmutableList.of(button1, button2)); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + CommandButton button1Enabled = button1.copyWithIsEnabled(true); + CommandButton button2Enabled = button2.copyWithIsEnabled(true); + assertThat(initialCustomLayout).containsExactly(button1Enabled, button2Enabled).inOrder(); + assertThat(reportedCustomLayout) + .containsExactly( + ImmutableList.of(button1Enabled, button2Enabled), + ImmutableList.of(button3, button4), + ImmutableList.of(button1Enabled, button2Enabled)) + .inOrder(); + assertThat(getterCustomLayout) + .containsExactly( + ImmutableList.of(button1Enabled, button2Enabled), + ImmutableList.of(button3, button4), + ImmutableList.of(button1Enabled, button2Enabled)) + .inOrder(); + assertThat(reportedCustomLayoutChanged) + .containsExactly( + ImmutableList.of(button3, button4), ImmutableList.of(button1Enabled, button2Enabled)) + .inOrder(); + assertThat(getterCustomLayoutChanged) + .containsExactly( + ImmutableList.of(button3, button4), ImmutableList.of(button1Enabled, button2Enabled)) + .inOrder(); + session.cleanUp(); + } + @Test public void getPackageName() throws Exception { MediaController controller = controllerTestRule.createController(remoteSession.getToken()); @@ -1461,4 +1714,21 @@ public class MediaControllerTest { return controller.getCurrentMediaItemIndex(); })); } + + private void setupCustomLayout(RemoteMediaSession session, List customLayout) + throws RemoteException, InterruptedException, Exception { + CountDownLatch latch = new CountDownLatch(1); + controllerTestRule.createController( + session.getToken(), + /* connectionHints= */ null, + new MediaController.Listener() { + @Override + public void onCustomLayoutChanged( + MediaController controller, List layout) { + latch.countDown(); + } + }); + session.setCustomLayout(ImmutableList.copyOf(customLayout)); + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + } } 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 28e1a8f129..3c8349460f 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 @@ -17,6 +17,7 @@ package androidx.media3.session; import static androidx.media3.session.MediaTestUtils.createMediaItem; import static androidx.media3.session.SessionResult.RESULT_ERROR_INVALID_STATE; +import static androidx.media3.session.SessionResult.RESULT_ERROR_PERMISSION_DENIED; import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED; import static androidx.media3.session.SessionResult.RESULT_SUCCESS; import static androidx.media3.test.session.common.CommonConstants.METADATA_MEDIA_URI; @@ -35,7 +36,9 @@ import androidx.media3.common.MediaLibraryInfo; import androidx.media3.common.Player; import androidx.media3.common.Rating; import androidx.media3.common.StarRating; +import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder; 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.TestUtils; @@ -130,6 +133,66 @@ public class MediaSessionCallbackTest { assertThat(controllerInterfaceVersion.get()).isEqualTo(MediaControllerStub.VERSION_INT); } + @Test + public void onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied() + throws Exception { + CommandButton button1 = + new CommandButton.Builder() + .setDisplayName("button1") + .setIconResId(R.drawable.media3_notification_play) + .setSessionCommand(new SessionCommand("command1", Bundle.EMPTY)) + .setEnabled(true) + .build(); + CommandButton button1Disabled = button1.copyWithIsEnabled(false); + CommandButton button2 = + new CommandButton.Builder() + .setDisplayName("button2") + .setIconResId(R.drawable.media3_notification_pause) + .setSessionCommand(new SessionCommand("command2", Bundle.EMPTY)) + .setEnabled(true) + .build(); + ImmutableList customLayout = ImmutableList.of(button1, button2); + MediaSession.Callback callback = + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + return new AcceptedResultBuilder(session) + .setAvailableSessionCommands( + new SessionCommands.Builder().add(button2.sessionCommand).build()) + .setCustomLayout(ImmutableList.of(button1, button2)) + .build(); + } + + @Override + public ListenableFuture onCustomCommand( + MediaSession session, + ControllerInfo controller, + SessionCommand customCommand, + Bundle args) { + return Futures.immediateFuture(new SessionResult(RESULT_SUCCESS)); + } + }; + MediaSession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaSession.Builder(context, player) + .setCallback(callback) + .setCustomLayout(customLayout) + .setId( + "onConnect_acceptWithMissingSessionCommand_buttonDisabledAndPermissionDenied") + .build()); + RemoteMediaController remoteController = + controllerTestRule.createRemoteController(session.getToken()); + + ImmutableList layout = remoteController.getCustomLayout(); + + assertThat(layout).containsExactly(button1Disabled, button2).inOrder(); + assertThat(remoteController.sendCustomCommand(button1.sessionCommand, Bundle.EMPTY).resultCode) + .isEqualTo(RESULT_ERROR_PERMISSION_DENIED); + assertThat(remoteController.sendCustomCommand(button2.sessionCommand, Bundle.EMPTY).resultCode) + .isEqualTo(RESULT_SUCCESS); + } + @Test public void onPostConnect_afterConnected() throws Exception { CountDownLatch latch = new CountDownLatch(1); diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java index 70be269574..df377ba949 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java @@ -17,6 +17,7 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_CONTROLLER; +import static androidx.media3.test.session.common.CommonConstants.KEY_COMMAND_BUTTON_LIST; import static androidx.media3.test.session.common.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -809,6 +810,19 @@ public class MediaControllerProviderService extends Service { return result.toBundle(); } + @Override + public Bundle getCustomLayout(String controllerId) throws RemoteException { + MediaController controller = mediaControllerMap.get(controllerId); + ArrayList customLayout = new ArrayList<>(); + ImmutableList commandButtons = runOnHandler(controller::getCustomLayout); + for (CommandButton button : commandButtons) { + customLayout.add(button.toBundle()); + } + Bundle bundle = new Bundle(); + bundle.putParcelableArrayList(KEY_COMMAND_BUTTON_LIST, customLayout); + return bundle; + } + @Override public Bundle getItem(String controllerId, String mediaId) throws RemoteException { MediaBrowser browser = (MediaBrowser) mediaControllerMap.get(controllerId); 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 4fc87705c8..38cd4d04e3 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 @@ -60,6 +60,7 @@ import static androidx.media3.test.session.common.MediaSessionConstants.KEY_COMM import static androidx.media3.test.session.common.MediaSessionConstants.KEY_CONTROLLER; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_COMMAND_GET_TRACKS; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CONTROLLER_LISTENER_SESSION_REJECTS; +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_ON_TRACKS_CHANGED_VIDEO_TO_AUDIO_TRANSITION; @@ -185,6 +186,23 @@ public class MediaSessionProviderService extends Service { builder.setSessionActivity(pendingIntent); break; } + case TEST_GET_CUSTOM_LAYOUT: + { + builder.setCallback( + new MediaSession.Callback() { + @Override + public MediaSession.ConnectionResult onConnect( + MediaSession session, ControllerInfo controller) { + return MediaSession.ConnectionResult.accept( + new SessionCommands.Builder() + .add(new SessionCommand("command1", Bundle.EMPTY)) + .add(new SessionCommand("command2", Bundle.EMPTY)) + .build(), + Player.Commands.EMPTY); + } + }); + break; + } case TEST_WITH_CUSTOM_COMMANDS: { SessionCommands availableSessionCommands = diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java index 56e891b4a4..4e42477ebe 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/RemoteMediaController.java @@ -16,6 +16,7 @@ package androidx.media3.session; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_CONTROLLER; +import static androidx.media3.test.session.common.CommonConstants.KEY_COMMAND_BUTTON_LIST; import static androidx.media3.test.session.common.CommonConstants.MEDIA3_CONTROLLER_PROVIDER_SERVICE; import static androidx.media3.test.session.common.TestUtils.SERVICE_CONNECTION_TIMEOUT_MS; import static com.google.common.truth.Truth.assertWithMessage; @@ -40,6 +41,8 @@ import androidx.media3.common.util.BundleableUtil; import androidx.media3.common.util.Log; import androidx.media3.test.session.common.IRemoteMediaController; import androidx.media3.test.session.common.TestUtils; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -349,6 +352,16 @@ public class RemoteMediaController { seekIndex); } + public ImmutableList getCustomLayout() throws RemoteException { + Bundle customLayoutBundle = binder.getCustomLayout(controllerId); + ArrayList list = customLayoutBundle.getParcelableArrayList(KEY_COMMAND_BUTTON_LIST); + ImmutableList.Builder customLayout = new ImmutableList.Builder<>(); + for (Bundle bundle : list) { + customLayout.add(CommandButton.CREATOR.fromBundle(bundle)); + } + return customLayout.build(); + } + //////////////////////////////////////////////////////////////////////////////// // Non-public methods //////////////////////////////////////////////////////////////////////////////// diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java b/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java index 792af54159..d95a37f995 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/TestMediaBrowserListener.java @@ -57,6 +57,11 @@ public final class TestMediaBrowserListener implements MediaBrowser.Listener { return delegate.onSetCustomLayout(controller, layout); } + @Override + public void onCustomLayoutChanged(MediaController controller, List layout) { + delegate.onCustomLayoutChanged(controller, layout); + } + @Override public void onExtrasChanged(MediaController controller, Bundle extras) { delegate.onExtrasChanged(controller, extras);