Use proxy controller to maintain platform session and notification

With this change, the notification controller that is connected by
`MediaNotificationManager`, is used as a proxy controller of the
System UI controller. An app can use the proxy at connection time
and during the lifetime of the session for configuration of the
platform session and the media notification on all API levels.

This includes using custom layout and available player and session
commands of the proxy to maintain the platform session (actions,
custom actions, session extras) and the `MediaNotification.Provider`.

The legacy System UI controller is hidden from the public API,
instead the app interacts with the Media3 proxy:

- System UI is hidden from `MediaSession.getConnectedControllers()`.
- Calls from System UI to methods of `MediaSession.Callback`/
  `MediaLibrarySession.Callback` are mapped to the `ControllerInfo`
  of the proxy controller.
- When `getControllerForCurrentRequest()` is called during an operation of
  System UI the proxy `ControllerInfo` is returned.

PiperOrigin-RevId: 567606117
This commit is contained in:
bachinger 2023-09-22 06:34:20 -07:00 committed by Copybara-Service
parent 4f4335943c
commit 742410d517
16 changed files with 616 additions and 178 deletions

View File

@ -67,6 +67,9 @@
`android.media.session.MediaSession.setMediaButtonBroadcastReceiver()` `android.media.session.MediaSession.setMediaButtonBroadcastReceiver()`
above API 31 to avoid problems with deprecated API on Samsung devices above API 31 to avoid problems with deprecated API on Samsung devices
([#167](https://github.com/androidx/media/issues/167)). ([#167](https://github.com/androidx/media/issues/167)).
* Use the media notification controller as proxy to set available commands
and custom layout used to populate the notification and the platform
session.
* UI: * UI:
* Downloads: * Downloads:
* OkHttp Extension: * OkHttp Extension:

View File

@ -33,7 +33,6 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.* import androidx.media3.session.*
import androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED
import androidx.media3.session.MediaSession.ConnectionResult import androidx.media3.session.MediaSession.ConnectionResult
import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSession.ControllerInfo
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
@ -45,7 +44,7 @@ class PlaybackService : MediaLibraryService() {
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var customCommands: List<CommandButton> private lateinit var customLayoutCommandButtons: List<CommandButton>
companion object { companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
@ -60,7 +59,7 @@ class PlaybackService : MediaLibraryService() {
@OptIn(UnstableApi::class) // MediaSessionService.setListener @OptIn(UnstableApi::class) // MediaSessionService.setListener
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
customCommands = customLayoutCommandButtons =
listOf( listOf(
getShuffleCommandButton( getShuffleCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY) SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY)
@ -100,33 +99,47 @@ class PlaybackService : MediaLibraryService() {
// ConnectionResult.AcceptedResultBuilder // ConnectionResult.AcceptedResultBuilder
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onConnect(session: MediaSession, controller: ControllerInfo): ConnectionResult { override fun onConnect(session: MediaSession, controller: ControllerInfo): ConnectionResult {
if (session.isMediaNotificationController(controller)) {
// Set the required available session commands and the custom layout for the notification
// on all API levels.
val availableSessionCommands = val availableSessionCommands =
ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon() ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
for (commandButton in customCommands) { // Add the session commands of all command buttons.
// Add custom command to available session commands. customLayoutCommandButtons.forEach { commandButton ->
commandButton.sessionCommand?.let { availableSessionCommands.add(it) } commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
} }
// Select the buttons to display.
val customLayout =
ImmutableList.of(customLayoutCommandButtons[if (player.shuffleModeEnabled) 1 else 0])
return ConnectionResult.AcceptedResultBuilder(session) return ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(availableSessionCommands.build()) .setAvailableSessionCommands(availableSessionCommands.build())
.setCustomLayout(customLayout)
.build() .build()
} }
// Default commands without custom layout for common controllers.
return ConnectionResult.AcceptedResultBuilder(session).build()
}
@OptIn(UnstableApi::class) // MediaSession.isMediaNotificationController
override fun onCustomCommand( override fun onCustomCommand(
session: MediaSession, session: MediaSession,
controller: ControllerInfo, controller: ControllerInfo,
customCommand: SessionCommand, customCommand: SessionCommand,
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
if (!session.isMediaNotificationController(controller)) {
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
}
if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON == customCommand.customAction) { if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON == customCommand.customAction) {
// Enable shuffling. // Enable shuffling.
player.shuffleModeEnabled = true player.shuffleModeEnabled = true
// Change the custom layout to contain the `Disable shuffling` command. // Change the custom layout to contain the `Disable shuffling` command.
session.setCustomLayout(ImmutableList.of(customCommands[1])) session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[1]))
} else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) { } else if (CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF == customCommand.customAction) {
// Disable shuffling. // Disable shuffling.
player.shuffleModeEnabled = false player.shuffleModeEnabled = false
// Change the custom layout to contain the `Enable shuffling` command. // Change the custom layout to contain the `Enable shuffling` command.
session.setCustomLayout(ImmutableList.of(customCommands[0])) session.setCustomLayout(controller, ImmutableList.of(customLayoutCommandButtons[0]))
} }
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
} }
@ -136,12 +149,6 @@ class PlaybackService : MediaLibraryService() {
browser: ControllerInfo, browser: ControllerInfo,
params: LibraryParams? params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> { ): ListenableFuture<LibraryResult<MediaItem>> {
if (params != null && params.isRecent) {
// The service currently does not support playback resumption. Tell System UI by returning
// an error of type 'RESULT_ERROR_NOT_SUPPORTED' for a `params.isRecent` request. See
// https://github.com/androidx/media/issues/355
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED))
}
return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params)) return Futures.immediateFuture(LibraryResult.ofItem(MediaItemTree.getRootItem(), params))
} }
@ -234,7 +241,6 @@ class PlaybackService : MediaLibraryService() {
mediaLibrarySession = mediaLibrarySession =
MediaLibrarySession.Builder(this, player, librarySessionCallback) MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(getSingleTopActivity()) .setSessionActivity(getSingleTopActivity())
.setCustomLayout(ImmutableList.of(customCommands[0]))
.setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this))) .setBitmapLoader(CacheBitmapLoader(DataSourceBitmapLoader(/* context= */ this)))
.build() .build()
} }
@ -270,10 +276,6 @@ class PlaybackService : MediaLibraryService() {
.build() .build()
} }
private fun ignoreFuture(customLayout: ListenableFuture<SessionResult>) {
/* Do nothing. */
}
@OptIn(UnstableApi::class) // MediaSessionService.Listener @OptIn(UnstableApi::class) // MediaSessionService.Listener
private inner class MediaSessionServiceListener : Listener { private inner class MediaSessionServiceListener : Listener {

View File

@ -215,6 +215,7 @@ public final class CommandButton implements Bundleable {
sessionCommand, playerCommand, iconResId, displayName, new Bundle(extras), isEnabled); sessionCommand, playerCommand, iconResId, displayName, new Bundle(extras), isEnabled);
} }
/** Checks the given command button for equality while ignoring {@link #extras}. */
@Override @Override
public boolean equals(@Nullable Object obj) { public boolean equals(@Nullable Object obj) {
if (this == obj) { if (this == obj) {

View File

@ -128,6 +128,13 @@ import java.util.concurrent.Future;
public void notifyChildrenChanged( public void notifyChildrenChanged(
ControllerInfo browser, String parentId, int itemCount, @Nullable LibraryParams params) { ControllerInfo browser, String parentId, int itemCount, @Nullable LibraryParams params) {
if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) {
ControllerInfo systemUiBrowser = getSystemUiControllerInfo();
if (systemUiBrowser == null) {
return;
}
browser = systemUiBrowser;
}
dispatchRemoteControllerTaskWithoutReturn( dispatchRemoteControllerTaskWithoutReturn(
browser, browser,
(callback, seq) -> { (callback, seq) -> {
@ -140,6 +147,13 @@ import java.util.concurrent.Future;
public void notifySearchResultChanged( public void notifySearchResultChanged(
ControllerInfo browser, String query, int itemCount, @Nullable LibraryParams params) { ControllerInfo browser, String query, int itemCount, @Nullable LibraryParams params) {
if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) {
ControllerInfo systemUiBrowser = getSystemUiControllerInfo();
if (systemUiBrowser == null) {
return;
}
browser = systemUiBrowser;
}
dispatchRemoteControllerTaskWithoutReturn( dispatchRemoteControllerTaskWithoutReturn(
browser, (callback, seq) -> callback.onSearchResultChanged(seq, query, itemCount, params)); browser, (callback, seq) -> callback.onSearchResultChanged(seq, query, itemCount, params));
} }
@ -163,7 +177,7 @@ import java.util.concurrent.Future;
params)); params));
} }
ListenableFuture<LibraryResult<MediaItem>> future = ListenableFuture<LibraryResult<MediaItem>> future =
callback.onGetLibraryRoot(instance, browser, params); callback.onGetLibraryRoot(instance, resolveControllerInfoForCallback(browser), params);
future.addListener( future.addListener(
() -> { () -> {
@Nullable LibraryResult<MediaItem> result = tryGetFutureResult(future); @Nullable LibraryResult<MediaItem> result = tryGetFutureResult(future);
@ -204,7 +218,8 @@ import java.util.concurrent.Future;
params)); params));
} }
ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> future = ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> future =
callback.onGetChildren(instance, browser, parentId, page, pageSize, params); callback.onGetChildren(
instance, resolveControllerInfoForCallback(browser), parentId, page, pageSize, params);
future.addListener( future.addListener(
() -> { () -> {
@Nullable LibraryResult<ImmutableList<MediaItem>> result = tryGetFutureResult(future); @Nullable LibraryResult<ImmutableList<MediaItem>> result = tryGetFutureResult(future);
@ -220,7 +235,7 @@ import java.util.concurrent.Future;
public ListenableFuture<LibraryResult<MediaItem>> onGetItemOnHandler( public ListenableFuture<LibraryResult<MediaItem>> onGetItemOnHandler(
ControllerInfo browser, String mediaId) { ControllerInfo browser, String mediaId) {
ListenableFuture<LibraryResult<MediaItem>> future = ListenableFuture<LibraryResult<MediaItem>> future =
callback.onGetItem(instance, browser, mediaId); callback.onGetItem(instance, resolveControllerInfoForCallback(browser), mediaId);
future.addListener( future.addListener(
() -> { () -> {
@Nullable LibraryResult<MediaItem> result = tryGetFutureResult(future); @Nullable LibraryResult<MediaItem> result = tryGetFutureResult(future);
@ -251,7 +266,8 @@ import java.util.concurrent.Future;
// so we explicitly null-check the result to fail early if an app accidentally returns null. // so we explicitly null-check the result to fail early if an app accidentally returns null.
ListenableFuture<LibraryResult<Void>> future = ListenableFuture<LibraryResult<Void>> future =
checkNotNull( checkNotNull(
callback.onSubscribe(instance, browser, parentId, params), callback.onSubscribe(
instance, resolveControllerInfoForCallback(browser), parentId, params),
"onSubscribe must return non-null future"); "onSubscribe must return non-null future");
// When error happens, remove from the subscription list. // When error happens, remove from the subscription list.
@ -270,7 +286,7 @@ import java.util.concurrent.Future;
public ListenableFuture<LibraryResult<Void>> onUnsubscribeOnHandler( public ListenableFuture<LibraryResult<Void>> onUnsubscribeOnHandler(
ControllerInfo browser, String parentId) { ControllerInfo browser, String parentId) {
ListenableFuture<LibraryResult<Void>> future = ListenableFuture<LibraryResult<Void>> future =
callback.onUnsubscribe(instance, browser, parentId); callback.onUnsubscribe(instance, resolveControllerInfoForCallback(browser), parentId);
future.addListener( future.addListener(
() -> removeSubscription(checkStateNotNull(browser.getControllerCb()), parentId), () -> removeSubscription(checkStateNotNull(browser.getControllerCb()), parentId),
@ -282,7 +298,7 @@ import java.util.concurrent.Future;
public ListenableFuture<LibraryResult<Void>> onSearchOnHandler( public ListenableFuture<LibraryResult<Void>> onSearchOnHandler(
ControllerInfo browser, String query, @Nullable LibraryParams params) { ControllerInfo browser, String query, @Nullable LibraryParams params) {
ListenableFuture<LibraryResult<Void>> future = ListenableFuture<LibraryResult<Void>> future =
callback.onSearch(instance, browser, query, params); callback.onSearch(instance, resolveControllerInfoForCallback(browser), query, params);
future.addListener( future.addListener(
() -> { () -> {
@Nullable LibraryResult<Void> result = tryGetFutureResult(future); @Nullable LibraryResult<Void> result = tryGetFutureResult(future);
@ -301,7 +317,8 @@ import java.util.concurrent.Future;
int pageSize, int pageSize,
@Nullable LibraryParams params) { @Nullable LibraryParams params) {
ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> future = ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> future =
callback.onGetSearchResult(instance, browser, query, page, pageSize, params); callback.onGetSearchResult(
instance, resolveControllerInfoForCallback(browser), query, page, pageSize, params);
future.addListener( future.addListener(
() -> { () -> {
@Nullable LibraryResult<ImmutableList<MediaItem>> result = tryGetFutureResult(future); @Nullable LibraryResult<ImmutableList<MediaItem>> result = tryGetFutureResult(future);
@ -410,6 +427,10 @@ import java.util.concurrent.Future;
ControllerInfo controller, @Nullable LibraryParams params) { ControllerInfo controller, @Nullable LibraryParams params) {
SettableFuture<LibraryResult<ImmutableList<MediaItem>>> settableFuture = SettableFuture<LibraryResult<ImmutableList<MediaItem>>> settableFuture =
SettableFuture.create(); SettableFuture.create();
controller =
isMediaNotificationControllerConnected()
? checkNotNull(getMediaNotificationControllerInfo())
: controller;
ListenableFuture<MediaSession.MediaItemsWithStartPosition> future = ListenableFuture<MediaSession.MediaItemsWithStartPosition> future =
callback.onPlaybackResumption(instance, controller); callback.onPlaybackResumption(instance, controller);
Futures.addCallback( Futures.addCallback(

View File

@ -32,7 +32,6 @@ import android.os.Looper;
import android.os.RemoteException; import android.os.RemoteException;
import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.view.KeyEvent; import android.view.KeyEvent;
import androidx.annotation.GuardedBy; import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -775,9 +774,7 @@ public class MediaSession {
/** /**
* Returns whether the given media controller info belongs to the media notification controller. * Returns whether the given media controller info belongs to the media notification controller.
* *
* <p>Use this method for instance in {@link Callback#onConnect(MediaSession, ControllerInfo)} to * <p>See {@link #getMediaNotificationControllerInfo()}.
* recognize the media notification controller and provide a {@link ConnectionResult} with a
* custom layout specific for this controller.
* *
* @param controllerInfo The controller info. * @param controllerInfo The controller info.
* @return Whether the controller info belongs to the media notification controller. * @return Whether the controller info belongs to the media notification controller.
@ -792,8 +789,28 @@ public class MediaSession {
* *
* <p>Use this controller info to set {@linkplain #setAvailableCommands(ControllerInfo, * <p>Use this controller info to set {@linkplain #setAvailableCommands(ControllerInfo,
* SessionCommands, Player.Commands) available commands} and {@linkplain * SessionCommands, Player.Commands) available commands} and {@linkplain
* #setCustomLayout(ControllerInfo, List) custom layout} that are applied to the media * #setCustomLayout(ControllerInfo, List) custom layout} that are consistently applied to the
* notification. * media notification on all API levels.
*
* <p>Available {@linkplain SessionCommands session commands} of the media notification controller
* are used to enable or disable buttons of the custom layout before it is passed to the
* {@linkplain MediaNotification.Provider#createNotification(MediaSession, ImmutableList,
* MediaNotification.ActionFactory, MediaNotification.Provider.Callback) notification provider}.
* Disabled command buttons are not converted to notification actions when using {@link
* DefaultMediaNotificationProvider}. This affects the media notification displayed by System UI
* below API 33.
*
* <p>The available session commands of the media notification controller are used to maintain
* custom actions of the platform session (see {@code PlaybackStateCompat.getCustomActions()}).
* Command buttons of the custom layout are disabled or enabled according to the available session
* commands. Disabled command buttons are not converted to custom actions of the platform session.
* This affects the media notification displayed by System UI <a
* href="https://developer.android.com/about/versions/13/behavior-changes-13#playback-controls">starting
* with API 33</a>.
*
* <p>The available {@linkplain Player.Commands player commands} are intersected with the actual
* available commands of the underlying player to determine the playback actions of the platform
* session (see {@code PlaybackStateCompat.getActions()}).
*/ */
@UnstableApi @UnstableApi
@Nullable @Nullable
@ -815,7 +832,8 @@ public class MediaSession {
* <p>On the controller side, {@link * <p>On the controller side, {@link
* MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the * MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the
* new custom layout is different to the custom layout the {@link * new custom layout is different to the custom layout the {@link
* MediaController#getCustomLayout() controller already has available}. * MediaController#getCustomLayout() controller already has available}. Note that this comparison
* uses {@link CommandButton#equals} and therefore ignores {@link CommandButton#extras}.
* *
* <p>It's up to controller's decision how to represent the layout in its own UI. * <p>It's up to controller's decision how to represent the layout in its own UI.
* *
@ -836,22 +854,17 @@ public class MediaSession {
/** /**
* Sets the custom layout that can initially be set when building the session. * Sets the custom layout that can initially be set when building the session.
* *
* <p>Calling this method broadcasts the custom layout to all connected Media3 controllers and * <p>Calling this method broadcasts the custom layout to all connected Media3 controllers,
* converts the {@linkplain CommandButton command buttons} to {@linkplain * including the {@linkplain #getMediaNotificationControllerInfo() media notification controller}.
* 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.
* *
* <p>On the controller side, {@link * <p>On the controller side, the {@linkplain CommandButton#isEnabled enabled} flag is set
* MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called if the * according to the available commands of the controller which overrides a value that has been set
* new custom layout is different to the custom layout the {@linkplain * by the session.
* MediaController#getCustomLayout() controller already has available}.
* *
* <p>When converting, the {@linkplain SessionCommand#customExtras custom extras of the session * <p>{@link MediaController.Listener#onCustomLayoutChanged(MediaController, List)} is only called
* command} is used for the extras of the legacy custom action. * if the new custom layout is different to the custom layout the {@linkplain
* MediaController#getCustomLayout() controller already has available}. Note that {@link Bundle
* extras} are ignored when comparing {@linkplain CommandButton command buttons}.
* *
* <p>Controllers that connect after calling this method will have the new custom layout available * <p>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 * with the initial connection result. A custom layout specific to a controller can be set when

View File

@ -130,6 +130,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
// Should be only accessed on the application looper // Should be only accessed on the application looper
private long sessionPositionUpdateDelayMs; private long sessionPositionUpdateDelayMs;
private boolean isMediaNotificationControllerConnected;
private ImmutableList<CommandButton> customLayout; private ImmutableList<CommandButton> customLayout;
public MediaSessionImpl( public MediaSessionImpl(
@ -191,10 +192,19 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
sessionLegacyStub = sessionLegacyStub =
new MediaSessionLegacyStub(/* session= */ thisRef, sessionUri, applicationHandler); new MediaSessionLegacyStub(/* session= */ thisRef, sessionUri, applicationHandler);
// For PlayerWrapper, use the same default commands as the proxy controller gets when the app
PlayerWrapper playerWrapper = new PlayerWrapper(player, playIfSuppressed); // doesn't overrides the default commands in `onConnect`. When the default is overridden by the
// app in `onConnect`, the default set here will be overridden with these values.
MediaSession.ConnectionResult connectionResult =
new MediaSession.ConnectionResult.AcceptedResultBuilder(instance).build();
PlayerWrapper playerWrapper =
new PlayerWrapper(
player,
playIfSuppressed,
customLayout,
connectionResult.availableSessionCommands,
connectionResult.availablePlayerCommands);
this.playerWrapper = playerWrapper; this.playerWrapper = playerWrapper;
this.playerWrapper.setCustomLayout(customLayout);
postOrRun( postOrRun(
applicationHandler, applicationHandler,
() -> () ->
@ -212,13 +222,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return; return;
} }
setPlayerInternal( setPlayerInternal(
/* oldPlayerWrapper= */ playerWrapper, new PlayerWrapper(player, playIfSuppressed)); /* oldPlayerWrapper= */ playerWrapper,
new PlayerWrapper(
player,
playIfSuppressed,
playerWrapper.getCustomLayout(),
playerWrapper.getAvailableSessionCommands(),
playerWrapper.getAvailablePlayerCommands()));
} }
private void setPlayerInternal( private void setPlayerInternal(
@Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) {
playerWrapper = newPlayerWrapper; playerWrapper = newPlayerWrapper;
playerWrapper.setCustomLayout(customLayout);
if (oldPlayerWrapper != null) { if (oldPlayerWrapper != null) {
oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener)); oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener));
} }
@ -295,14 +310,27 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
public List<ControllerInfo> getConnectedControllers() { public List<ControllerInfo> getConnectedControllers() {
List<ControllerInfo> controllers = new ArrayList<>(); List<ControllerInfo> controllers = new ArrayList<>();
controllers.addAll(sessionStub.getConnectedControllersManager().getConnectedControllers()); controllers.addAll(sessionStub.getConnectedControllersManager().getConnectedControllers());
if (isMediaNotificationControllerConnected) {
ImmutableList<ControllerInfo> legacyControllers =
sessionLegacyStub.getConnectedControllersManager().getConnectedControllers();
for (int i = 0; i < legacyControllers.size(); i++) {
ControllerInfo legacyController = legacyControllers.get(i);
if (!isSystemUiController(legacyController)) {
controllers.add(legacyController);
}
}
} else {
controllers.addAll( controllers.addAll(
sessionLegacyStub.getConnectedControllersManager().getConnectedControllers()); sessionLegacyStub.getConnectedControllersManager().getConnectedControllers());
}
return controllers; return controllers;
} }
@Nullable @Nullable
public ControllerInfo getControllerForCurrentRequest() { public ControllerInfo getControllerForCurrentRequest() {
return controllerForCurrentRequest; return controllerForCurrentRequest != null
? resolveControllerInfoForCallback(controllerForCurrentRequest)
: null;
} }
public boolean isConnected(ControllerInfo controller) { public boolean isConnected(ControllerInfo controller) {
@ -372,17 +400,39 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return null; return null;
} }
public ListenableFuture<SessionResult> setCustomLayout( /** Returns whether the media notification controller is connected. */
ControllerInfo controller, List<CommandButton> layout) { protected boolean isMediaNotificationControllerConnected() {
return dispatchRemoteControllerTask( return isMediaNotificationControllerConnected;
controller, (controller1, seq) -> controller1.setCustomLayout(seq, layout));
} }
public void setCustomLayout(List<CommandButton> layout) { /**
customLayout = ImmutableList.copyOf(layout); * Sets the custom layout for the given {@link MediaController}.
*
* @param controller The controller.
* @param customLayout The custom layout.
* @return The session result from the controller.
*/
public ListenableFuture<SessionResult> setCustomLayout(
ControllerInfo controller, ImmutableList<CommandButton> customLayout) {
if (isMediaNotificationController(controller)) {
playerWrapper.setCustomLayout(customLayout);
sessionLegacyStub.updateLegacySessionPlaybackStateCompat();
}
return dispatchRemoteControllerTask(
controller, (controller1, seq) -> controller1.setCustomLayout(seq, customLayout));
}
/** Sets the custom layout of the session and sends the custom layout to all controllers. */
public void setCustomLayout(ImmutableList<CommandButton> customLayout) {
this.customLayout = customLayout;
playerWrapper.setCustomLayout(customLayout); playerWrapper.setCustomLayout(customLayout);
dispatchRemoteControllerTaskWithoutReturn( dispatchRemoteControllerTaskWithoutReturn(
(controller, seq) -> controller.setCustomLayout(seq, layout)); (controller, seq) -> controller.setCustomLayout(seq, customLayout));
}
/** Returns the custom layout. */
public ImmutableList<CommandButton> getCustomLayout() {
return customLayout;
} }
public void setSessionExtras(Bundle sessionExtras) { public void setSessionExtras(Bundle sessionExtras) {
@ -408,6 +458,18 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
public void setAvailableCommands( public void setAvailableCommands(
ControllerInfo controller, SessionCommands sessionCommands, Player.Commands playerCommands) { ControllerInfo controller, SessionCommands sessionCommands, Player.Commands playerCommands) {
if (sessionStub.getConnectedControllersManager().isConnected(controller)) { if (sessionStub.getConnectedControllersManager().isConnected(controller)) {
if (isMediaNotificationController(controller)) {
playerWrapper.setAvailableCommands(sessionCommands, playerCommands);
sessionLegacyStub.updateLegacySessionPlaybackStateCompat();
ControllerInfo systemUiControllerInfo = getSystemUiControllerInfo();
if (systemUiControllerInfo != null) {
// Set the available commands of the proxy controller to the ConnectedControllerRecord of
// the hidden System UI controller.
sessionLegacyStub
.getConnectedControllersManager()
.updateCommandsFromSession(systemUiControllerInfo, sessionCommands, playerCommands);
}
}
sessionStub sessionStub
.getConnectedControllersManager() .getConnectedControllersManager()
.updateCommandsFromSession(controller, sessionCommands, playerCommands); .updateCommandsFromSession(controller, sessionCommands, playerCommands);
@ -482,45 +544,103 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
public MediaSession.ConnectionResult onConnectOnHandler(ControllerInfo controller) { public MediaSession.ConnectionResult onConnectOnHandler(ControllerInfo controller) {
return checkNotNull( if (isMediaNotificationControllerConnected && isSystemUiController(controller)) {
callback.onConnect(instance, controller), "Callback.onConnect must return non-null future"); // Hide System UI and provide the connection result from the `PlayerWrapper` state.
return new MediaSession.ConnectionResult.AcceptedResultBuilder(instance)
.setAvailableSessionCommands(playerWrapper.getAvailableSessionCommands())
.setAvailablePlayerCommands(playerWrapper.getAvailablePlayerCommands())
.setCustomLayout(playerWrapper.getCustomLayout())
.build();
}
MediaSession.ConnectionResult connectionResult =
checkNotNull(
callback.onConnect(instance, controller),
"Callback.onConnect must return non-null future");
if (isMediaNotificationController(controller)) {
isMediaNotificationControllerConnected = true;
playerWrapper.setAvailableCommands(
connectionResult.availableSessionCommands, connectionResult.availablePlayerCommands);
playerWrapper.setCustomLayout(
connectionResult.customLayout != null
? connectionResult.customLayout
: instance.getCustomLayout());
sessionLegacyStub.updateLegacySessionPlaybackStateCompat();
}
return connectionResult;
} }
public void onPostConnectOnHandler(ControllerInfo controller) { public void onPostConnectOnHandler(ControllerInfo controller) {
if (isMediaNotificationControllerConnected && isSystemUiController(controller)) {
// Hide System UI. Apps can use the media notification controller to maintain the platform
// session
return;
}
callback.onPostConnect(instance, controller); callback.onPostConnect(instance, controller);
} }
public void onDisconnectedOnHandler(ControllerInfo controller) { public void onDisconnectedOnHandler(ControllerInfo controller) {
if (isMediaNotificationControllerConnected) {
if (isSystemUiController(controller)) {
// Hide System UI controller. Apps can use the media notification controller to maintain the
// platform session.
return;
} else if (isMediaNotificationController(controller)) {
isMediaNotificationControllerConnected = false;
}
}
callback.onDisconnected(instance, controller); callback.onDisconnected(instance, controller);
} }
@SuppressWarnings("deprecation") // Calling deprecated callback method. @SuppressWarnings("deprecation") // Calling deprecated callback method.
public @SessionResult.Code int onPlayerCommandRequestOnHandler( public @SessionResult.Code int onPlayerCommandRequestOnHandler(
ControllerInfo controller, @Player.Command int playerCommand) { ControllerInfo controller, @Player.Command int playerCommand) {
return callback.onPlayerCommandRequest(instance, controller, playerCommand); return callback.onPlayerCommandRequest(
instance, resolveControllerInfoForCallback(controller), playerCommand);
} }
public ListenableFuture<SessionResult> onSetRatingOnHandler( public ListenableFuture<SessionResult> onSetRatingOnHandler(
ControllerInfo controller, String mediaId, Rating rating) { ControllerInfo controller, String mediaId, Rating rating) {
return checkNotNull( return checkNotNull(
callback.onSetRating(instance, controller, mediaId, rating), callback.onSetRating(
instance, resolveControllerInfoForCallback(controller), mediaId, rating),
"Callback.onSetRating must return non-null future"); "Callback.onSetRating must return non-null future");
} }
public ListenableFuture<SessionResult> onSetRatingOnHandler( public ListenableFuture<SessionResult> onSetRatingOnHandler(
ControllerInfo controller, Rating rating) { ControllerInfo controller, Rating rating) {
return checkNotNull( return checkNotNull(
callback.onSetRating(instance, controller, rating), callback.onSetRating(instance, resolveControllerInfoForCallback(controller), rating),
"Callback.onSetRating must return non-null future"); "Callback.onSetRating must return non-null future");
} }
public ListenableFuture<SessionResult> onCustomCommandOnHandler( public ListenableFuture<SessionResult> onCustomCommandOnHandler(
ControllerInfo browser, SessionCommand command, Bundle extras) { ControllerInfo controller, SessionCommand command, Bundle extras) {
return checkNotNull( return checkNotNull(
callback.onCustomCommand(instance, browser, command, extras), callback.onCustomCommand(
instance, resolveControllerInfoForCallback(controller), command, extras),
"Callback.onCustomCommandOnHandler must return non-null future"); "Callback.onCustomCommandOnHandler must return non-null future");
} }
protected ListenableFuture<List<MediaItem>> onAddMediaItemsOnHandler(
ControllerInfo controller, List<MediaItem> mediaItems) {
return checkNotNull(
callback.onAddMediaItems(
instance, resolveControllerInfoForCallback(controller), mediaItems),
"Callback.onAddMediaItems must return a non-null future");
}
protected ListenableFuture<MediaItemsWithStartPosition> onSetMediaItemsOnHandler(
ControllerInfo controller, List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
return checkNotNull(
callback.onSetMediaItems(
instance,
resolveControllerInfoForCallback(controller),
mediaItems,
startIndex,
startPositionMs),
"Callback.onSetMediaItems must return a non-null future");
}
public void connectFromService( public void connectFromService(
IMediaController caller, IMediaController caller,
int controllerVersion, int controllerVersion,
@ -555,20 +675,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return applicationHandler; return applicationHandler;
} }
protected ListenableFuture<List<MediaItem>> onAddMediaItemsOnHandler(
ControllerInfo controller, List<MediaItem> mediaItems) {
return checkNotNull(
callback.onAddMediaItems(instance, controller, mediaItems),
"Callback.onAddMediaItems must return a non-null future");
}
protected ListenableFuture<MediaItemsWithStartPosition> onSetMediaItemsOnHandler(
ControllerInfo controller, List<MediaItem> mediaItems, int startIndex, long startPositionMs) {
return checkNotNull(
callback.onSetMediaItems(instance, controller, mediaItems, startIndex, startPositionMs),
"Callback.onSetMediaItems must return a non-null future");
}
protected boolean isReleased() { protected boolean isReleased() {
synchronized (lock) { synchronized (lock) {
return closed; return closed;
@ -580,10 +686,6 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
return sessionActivity; return sessionActivity;
} }
protected ImmutableList<CommandButton> getCustomLayout() {
return customLayout;
}
@UnstableApi @UnstableApi
protected void setSessionActivity(PendingIntent sessionActivity) { protected void setSessionActivity(PendingIntent sessionActivity) {
if (Objects.equals(this.sessionActivity, sessionActivity)) { if (Objects.equals(this.sessionActivity, sessionActivity)) {
@ -603,6 +705,12 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
} }
protected ControllerInfo resolveControllerInfoForCallback(ControllerInfo controller) {
return isMediaNotificationControllerConnected && isSystemUiController(controller)
? checkNotNull(getMediaNotificationControllerInfo())
: controller;
}
/** /**
* Gets the service binder from the MediaBrowserServiceCompat. Should be only called by the thread * Gets the service binder from the MediaBrowserServiceCompat. Should be only called by the thread
* with a Looper. * with a Looper.
@ -693,7 +801,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Nullable @Nullable
ListenableFuture<MediaItemsWithStartPosition> future = ListenableFuture<MediaItemsWithStartPosition> future =
checkNotNull( checkNotNull(
callback.onPlaybackResumption(instance, controller), callback.onPlaybackResumption(instance, resolveControllerInfoForCallback(controller)),
"Callback.onPlaybackResumption must return a non-null future"); "Callback.onPlaybackResumption must return a non-null future");
// Use a direct executor when an immediate future is returned to execute the player setup in the // Use a direct executor when an immediate future is returned to execute the player setup in the
// caller's looper event on the application thread. // caller's looper event on the application thread.

View File

@ -823,6 +823,15 @@ import org.checkerframework.checker.initialization.qual.Initialized;
connectionTimeoutMs = timeoutMs; connectionTimeoutMs = timeoutMs;
} }
public void updateLegacySessionPlaybackStateCompat() {
postOrRun(
sessionImpl.getApplicationHandler(),
() ->
sessionImpl
.getSessionCompat()
.setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat()));
}
private void handleMediaRequest(MediaItem mediaItem, boolean play) { private void handleMediaRequest(MediaItem mediaItem, boolean play) {
dispatchSessionTaskWithPlayerCommand( dispatchSessionTaskWithPlayerCommand(
COMMAND_SET_MEDIA_ITEM, COMMAND_SET_MEDIA_ITEM,
@ -1081,16 +1090,12 @@ import org.checkerframework.checker.initialization.qual.Initialized;
@Override @Override
public void onPlayerError(int seq, @Nullable PlaybackException playerError) { public void onPlayerError(int seq, @Nullable PlaybackException playerError) {
sessionImpl updateLegacySessionPlaybackStateCompat();
.getSessionCompat()
.setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat());
} }
@Override @Override
public void setCustomLayout(int seq, List<CommandButton> layout) { public void setCustomLayout(int seq, List<CommandButton> layout) {
sessionImpl updateLegacySessionPlaybackStateCompat();
.getSessionCompat()
.setPlaybackState(sessionImpl.getPlayerWrapper().createPlaybackStateCompat());
} }
@Override @Override

View File

@ -21,6 +21,7 @@ import static androidx.media3.common.util.Util.msToUs;
import static androidx.media3.common.util.Util.postOrRun; import static androidx.media3.common.util.Util.postOrRun;
import static androidx.media3.session.MediaConstants.EXTRAS_KEY_MEDIA_ID_COMPAT; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_MEDIA_ID_COMPAT;
import static androidx.media3.session.MediaConstants.EXTRAS_KEY_PLAYBACK_SPEED_COMPAT; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_PLAYBACK_SPEED_COMPAT;
import static androidx.media3.session.MediaUtils.intersect;
import android.media.AudioManager; import android.media.AudioManager;
import android.os.Bundle; import android.os.Bundle;
@ -70,12 +71,43 @@ import java.util.List;
@Nullable private String legacyErrorMessage; @Nullable private String legacyErrorMessage;
@Nullable private Bundle legacyErrorExtras; @Nullable private Bundle legacyErrorExtras;
private ImmutableList<CommandButton> customLayout; private ImmutableList<CommandButton> customLayout;
private SessionCommands availableSessionCommands;
private Commands availablePlayerCommands;
public PlayerWrapper(Player player, boolean playIfSuppressed) { public PlayerWrapper(
Player player,
boolean playIfSuppressed,
ImmutableList<CommandButton> customLayout,
SessionCommands availableSessionCommands,
Commands availablePlayerCommands) {
super(player); super(player);
this.playIfSuppressed = playIfSuppressed; this.playIfSuppressed = playIfSuppressed;
this.customLayout = customLayout;
this.availableSessionCommands = availableSessionCommands;
this.availablePlayerCommands = availablePlayerCommands;
legacyStatusCode = STATUS_CODE_SUCCESS_COMPAT; legacyStatusCode = STATUS_CODE_SUCCESS_COMPAT;
customLayout = ImmutableList.of(); }
public void setAvailableCommands(
SessionCommands availableSessionCommands, Commands availablePlayerCommands) {
this.availableSessionCommands = availableSessionCommands;
this.availablePlayerCommands = availablePlayerCommands;
}
public SessionCommands getAvailableSessionCommands() {
return availableSessionCommands;
}
public Commands getAvailablePlayerCommands() {
return availablePlayerCommands;
}
public void setCustomLayout(ImmutableList<CommandButton> customLayout) {
this.customLayout = customLayout;
}
/* package */ ImmutableList<CommandButton> getCustomLayout() {
return customLayout;
} }
/** /**
@ -104,11 +136,6 @@ import java.util.List;
return legacyStatusCode; return legacyStatusCode;
} }
/** Sets the custom layout. */
public void setCustomLayout(ImmutableList<CommandButton> customLayout) {
this.customLayout = customLayout;
}
/** Clears the legacy error status. */ /** Clears the legacy error status. */
public void clearLegacyErrorStatus() { public void clearLegacyErrorStatus() {
legacyStatusCode = STATUS_CODE_SUCCESS_COMPAT; legacyStatusCode = STATUS_CODE_SUCCESS_COMPAT;
@ -974,7 +1001,7 @@ import java.util.List;
int state = MediaUtils.convertToPlaybackStateCompatState(/* player= */ this, playIfSuppressed); int state = MediaUtils.convertToPlaybackStateCompatState(/* player= */ this, playIfSuppressed);
// Always advertise ACTION_SET_RATING. // Always advertise ACTION_SET_RATING.
long actions = PlaybackStateCompat.ACTION_SET_RATING; long actions = PlaybackStateCompat.ACTION_SET_RATING;
Commands availableCommands = getAvailableCommands(); Commands availableCommands = intersect(availablePlayerCommands, getAvailableCommands());
for (int i = 0; i < availableCommands.size(); i++) { for (int i = 0; i < availableCommands.size(); i++) {
actions |= convertCommandToPlaybackStateActions(availableCommands.get(i)); actions |= convertCommandToPlaybackStateActions(availableCommands.get(i));
} }
@ -1006,7 +1033,9 @@ import java.util.List;
CommandButton commandButton = customLayout.get(i); CommandButton commandButton = customLayout.get(i);
if (commandButton.sessionCommand != null) { if (commandButton.sessionCommand != null) {
SessionCommand sessionCommand = commandButton.sessionCommand; SessionCommand sessionCommand = commandButton.sessionCommand;
if (sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) { if (sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
&& CommandButton.isEnabled(
commandButton, availableSessionCommands, availablePlayerCommands)) {
builder.addCustomAction( builder.addCustomAction(
new PlaybackStateCompat.CustomAction.Builder( new PlaybackStateCompat.CustomAction.Builder(
sessionCommand.customAction, sessionCommand.customAction,

View File

@ -157,6 +157,7 @@ public final class SessionCommand implements Bundleable {
customExtras = new Bundle(checkNotNull(extras)); customExtras = new Bundle(checkNotNull(extras));
} }
/** Checks the given session command for equality while ignoring extras. */
@Override @Override
public boolean equals(@Nullable Object obj) { public boolean equals(@Nullable Object obj) {
if (!(obj instanceof SessionCommand)) { if (!(obj instanceof SessionCommand)) {

View File

@ -22,6 +22,7 @@ import static org.mockito.Mockito.when;
import android.os.Looper; import android.os.Looper;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@ -42,7 +43,13 @@ public class PlayerWrapperTest {
@Before @Before
public void setUp() { public void setUp() {
playerWrapper = new PlayerWrapper(player, /* playIfSuppressed= */ true); playerWrapper =
new PlayerWrapper(
player,
/* playIfSuppressed= */ true,
ImmutableList.of(),
SessionCommands.EMPTY,
Player.Commands.EMPTY);
when(player.isCommandAvailable(anyInt())).thenReturn(true); when(player.isCommandAvailable(anyInt())).thenReturn(true);
when(player.getApplicationLooper()).thenReturn(Looper.myLooper()); when(player.getApplicationLooper()).thenReturn(Looper.myLooper());
} }

View File

@ -30,7 +30,8 @@ public class MediaSessionConstants {
"onTracksChanged_videoToAudioTransition"; "onTracksChanged_videoToAudioTransition";
public static final String TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE = public static final String TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE =
"testSetShowPlayButtonIfSuppressedToFalse"; "testSetShowPlayButtonIfSuppressedToFalse";
public static final String TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST =
"MediaControllerCompatCallbackWithMediaSessionTest";
// Bundle keys // Bundle keys
public static final String KEY_AVAILABLE_SESSION_COMMANDS = "availableSessionCommands"; public static final String KEY_AVAILABLE_SESSION_COMMANDS = "availableSessionCommands";
public static final String KEY_CONTROLLER = "controllerKey"; public static final String KEY_CONTROLLER = "controllerKey";

View File

@ -45,6 +45,7 @@ dependencies {
implementation 'androidx.test:core:' + androidxTestCoreVersion implementation 'androidx.test:core:' + androidxTestCoreVersion
implementation project(modulePrefix + 'test-data') implementation project(modulePrefix + 'test-data')
androidTestImplementation project(modulePrefix + 'lib-exoplayer') androidTestImplementation project(modulePrefix + 'lib-exoplayer')
androidTestImplementation project(modulePrefix + 'test-utils')
androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion androidTestImplementation 'androidx.test.ext:junit:' + androidxTestJUnitVersion
androidTestImplementation 'androidx.test.ext:truth:' + androidxTestTruthVersion androidTestImplementation 'androidx.test.ext:truth:' + androidxTestTruthVersion
androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion androidTestImplementation 'androidx.test:core:' + androidxTestCoreVersion

View File

@ -20,6 +20,7 @@ import static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_MEDIA_ID
import static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_USER_RATING; import static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_USER_RATING;
import static androidx.media3.common.Player.STATE_ENDED; import static androidx.media3.common.Player.STATE_ENDED;
import static androidx.media3.common.Player.STATE_READY; import static androidx.media3.common.Player.STATE_READY;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE;
import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS;
import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS;
@ -78,10 +79,10 @@ import org.junit.runner.RunWith;
@LargeTest @LargeTest
public class MediaControllerCompatCallbackWithMediaSessionTest { public class MediaControllerCompatCallbackWithMediaSessionTest {
private static final String TAG = "MCCCallbackTestWithMS2"; private static final String SESSION_ID =
private static final float EPSILON = 1e-6f; TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST;
@Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG); @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(SESSION_ID);
private Context context; private Context context;
private TestHandler handler; private TestHandler handler;
@ -92,7 +93,10 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
public void setUp() throws Exception { public void setUp() throws Exception {
context = ApplicationProvider.getApplicationContext(); context = ApplicationProvider.getApplicationContext();
handler = threadTestRule.getHandler(); handler = threadTestRule.getHandler();
session = new RemoteMediaSession(TAG, context, null); Bundle tokenExtras = new Bundle();
tokenExtras.putBoolean(
MediaSessionProviderService.KEY_ENABLE_FAKE_MEDIA_NOTIFICATION_MANAGER_CONTROLLER, true);
session = new RemoteMediaSession(SESSION_ID, context, tokenExtras);
controllerCompat = new MediaControllerCompat(context, session.getCompatToken()); controllerCompat = new MediaControllerCompat(context, session.getCompatToken());
} }
@ -181,7 +185,7 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
public void getError_withPlayerErrorAfterConnected_returnsError() throws Exception { public void getError_withPlayerErrorAfterConnected_returnsError() throws Exception {
PlaybackException testPlayerError = PlaybackException testPlayerError =
new PlaybackException( new PlaybackException(
/* messaage= */ "testremote", /* message= */ "testremote",
/* cause= */ null, /* cause= */ null,
PlaybackException.ERROR_CODE_REMOTE_ERROR); PlaybackException.ERROR_CODE_REMOTE_ERROR);
Bundle playerConfig = Bundle playerConfig =
@ -207,7 +211,7 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
public void playerError_notified() throws Exception { public void playerError_notified() throws Exception {
PlaybackException testPlayerError = PlaybackException testPlayerError =
new PlaybackException( new PlaybackException(
/* messaage= */ "player error", /* message= */ "player error",
/* cause= */ null, /* cause= */ null,
PlaybackException.ERROR_CODE_UNSPECIFIED); PlaybackException.ERROR_CODE_UNSPECIFIED);
@ -937,53 +941,46 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
@Test @Test
public void setCustomLayout_onPlaybackStateCompatChangedCalled() throws Exception { public void setCustomLayout_onPlaybackStateCompatChangedCalled() throws Exception {
List<CommandButton> buttons = new ArrayList<>();
Bundle extras1 = new Bundle(); Bundle extras1 = new Bundle();
extras1.putString("key", "value-1"); extras1.putString("key", "value-1");
CommandButton button1 = SessionCommand command1 = new SessionCommand("command1", extras1);
new CommandButton.Builder()
.setSessionCommand(new SessionCommand("action1", extras1))
.setDisplayName("actionName1")
.setIconResId(1)
.build();
Bundle extras2 = new Bundle(); Bundle extras2 = new Bundle();
extras2.putString("key", "value-2"); extras2.putString("key", "value-2");
CommandButton button2 = SessionCommand command2 = new SessionCommand("command2", extras2);
ImmutableList<CommandButton> customLayout =
ImmutableList.of(
new CommandButton.Builder() new CommandButton.Builder()
.setSessionCommand(new SessionCommand("action2", extras2)) .setSessionCommand(command1)
.setDisplayName("actionName2") .setDisplayName("command1")
.setIconResId(1)
.build()
.copyWithIsEnabled(true),
new CommandButton.Builder()
.setSessionCommand(command2)
.setDisplayName("command2")
.setIconResId(2) .setIconResId(2)
.build(); .build()
buttons.add(button1); .copyWithIsEnabled(true));
buttons.add(button2); List<ImmutableList<CommandButton>> reportedCustomLayouts = new ArrayList<>();
List<String> receivedActions = new ArrayList<>(); CountDownLatch latch1 = new CountDownLatch(2);
List<String> receivedDisplayNames = new ArrayList<>();
List<String> receivedBundleValues = new ArrayList<>();
List<Integer> receivedIconResIds = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(1);
MediaControllerCompat.Callback callback = MediaControllerCompat.Callback callback =
new MediaControllerCompat.Callback() { new MediaControllerCompat.Callback() {
@Override @Override
public void onPlaybackStateChanged(PlaybackStateCompat state) { public void onPlaybackStateChanged(PlaybackStateCompat state) {
List<PlaybackStateCompat.CustomAction> layout = state.getCustomActions(); reportedCustomLayouts.add(MediaUtils.convertToCustomLayout(state));
for (PlaybackStateCompat.CustomAction action : layout) { latch1.countDown();
receivedActions.add(action.getAction());
receivedDisplayNames.add(String.valueOf(action.getName()));
receivedBundleValues.add(action.getExtras().getString("key"));
receivedIconResIds.add(action.getIcon());
}
latch.countDown();
} }
}; };
controllerCompat.registerCallback(callback, handler); controllerCompat.registerCallback(callback, handler);
session.setCustomLayout(buttons); session.setCustomLayout(customLayout);
session.setAvailableCommands(
SessionCommands.EMPTY.buildUpon().add(command1).add(command2).build(),
Player.Commands.EMPTY);
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(latch1.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(receivedActions).containsExactly("action1", "action2").inOrder(); assertThat(reportedCustomLayouts.get(0)).containsExactly(customLayout.get(0));
assertThat(receivedDisplayNames).containsExactly("actionName1", "actionName2").inOrder(); assertThat(reportedCustomLayouts.get(1)).isEqualTo(customLayout);
assertThat(receivedIconResIds).containsExactly(1, 2).inOrder();
assertThat(receivedBundleValues).containsExactly("value-1", "value-2").inOrder();
} }
@Test @Test
@ -1403,6 +1400,6 @@ public class MediaControllerCompatCallbackWithMediaSessionTest {
PlaybackStateCompat state, PlaybackException playerError) { PlaybackStateCompat state, PlaybackException playerError) {
assertThat(state.getState()).isEqualTo(PlaybackStateCompat.STATE_ERROR); assertThat(state.getState()).isEqualTo(PlaybackStateCompat.STATE_ERROR);
assertThat(state.getErrorCode()).isEqualTo(PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR); assertThat(state.getErrorCode()).isEqualTo(PlaybackStateCompat.ERROR_CODE_UNKNOWN_ERROR);
assertThat(state.getErrorMessage()).isEqualTo(playerError.getMessage()); assertThat(state.getErrorMessage().toString()).isEqualTo(playerError.getMessage());
} }
} }

View File

@ -43,6 +43,8 @@ import androidx.media3.common.Timeline;
import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Consumer; import androidx.media3.common.util.Consumer;
import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.session.MediaSession.ConnectionResult;
import androidx.media3.session.MediaSession.ConnectionResult.AcceptedResultBuilder;
import androidx.media3.test.session.R; import androidx.media3.test.session.R;
import androidx.media3.test.session.common.HandlerThreadTestRule; import androidx.media3.test.session.common.HandlerThreadTestRule;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
@ -51,6 +53,7 @@ import androidx.test.filters.LargeTest;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@ -1459,18 +1462,23 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
.setIconResId(R.drawable.media3_notification_pause) .setIconResId(R.drawable.media3_notification_pause)
.setSessionCommand(command2) .setSessionCommand(command2)
.build()); .build());
MediaSession mediaSession = createMediaSession(player, /* callback= */ null, customLayout); MediaSession.Callback callback =
new MediaSession.Callback() {
@Override
public ConnectionResult onConnect(
MediaSession session, MediaSession.ControllerInfo controller) {
return new AcceptedResultBuilder(session)
.setAvailableSessionCommands(
ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build())
.build();
}
};
MediaSession mediaSession = createMediaSession(player, callback, customLayout);
connectMediaNotificationController(mediaSession);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
// Wait until a playback state is sent to the controller. assertThat(MediaUtils.convertToCustomLayout(controllerCompat.getPlaybackState()))
PlaybackStateCompat firstPlaybackState = .containsExactly(customLayout.get(0).copyWithIsEnabled(true));
getFirstPlaybackState(controllerCompat, threadTestRule.getHandler());
assertThat(MediaUtils.convertToCustomLayout(firstPlaybackState))
.containsExactly(
customLayout.get(0).copyWithIsEnabled(true),
customLayout.get(1).copyWithIsEnabled(true))
.inOrder();
mediaSession.release(); mediaSession.release();
releasePlayer(player); releasePlayer(player);
} }
@ -1497,11 +1505,23 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
.setIconResId(R.drawable.media3_notification_pause) .setIconResId(R.drawable.media3_notification_pause)
.setSessionCommand(command2) .setSessionCommand(command2)
.build()); .build());
MediaSession mediaSession = createMediaSession(player); MediaSession.Callback callback =
new MediaSession.Callback() {
@Override
public ConnectionResult onConnect(
MediaSession session, MediaSession.ControllerInfo controller) {
return new ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build())
.build();
}
};
MediaSession mediaSession = createMediaSession(player, callback);
connectMediaNotificationController(mediaSession);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
ImmutableList<CommandButton> initialCustomLayout =
MediaUtils.convertToCustomLayout(controllerCompat.getPlaybackState());
AtomicReference<List<CommandButton>> reportedCustomLayout = new AtomicReference<>(); AtomicReference<List<CommandButton>> reportedCustomLayout = new AtomicReference<>();
// Wait until a playback state is sent to the controller.
getFirstPlaybackState(controllerCompat, threadTestRule.getHandler());
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
controllerCompat.registerCallback( controllerCompat.registerCallback(
new MediaControllerCompat.Callback() { new MediaControllerCompat.Callback() {
@ -1516,14 +1536,97 @@ public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest
getInstrumentation().runOnMainSync(() -> mediaSession.setCustomLayout(customLayout)); getInstrumentation().runOnMainSync(() -> mediaSession.setCustomLayout(customLayout));
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(initialCustomLayout).isEmpty();
assertThat(reportedCustomLayout.get()) assertThat(reportedCustomLayout.get())
.containsExactly( .containsExactly(customLayout.get(0).copyWithIsEnabled(true));
customLayout.get(0).copyWithIsEnabled(true),
customLayout.get(1).copyWithIsEnabled(true));
mediaSession.release(); mediaSession.release();
releasePlayer(player); releasePlayer(player);
} }
@Test
public void
playerWithCustomLayout_setCustomLayoutForMediaNotificationController_playbackStateChangedWithCustomActionsChanged()
throws Exception {
Player player = createDefaultPlayer();
Bundle extras1 = new Bundle();
extras1.putString("key1", "value1");
Bundle extras2 = new Bundle();
extras1.putString("key2", "value2");
SessionCommand command1 = new SessionCommand("command1", extras1);
SessionCommand command2 = new SessionCommand("command2", extras2);
ImmutableList<CommandButton> customLayout =
ImmutableList.of(
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_play)
.setSessionCommand(command1)
.build(),
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_pause)
.setSessionCommand(command2)
.build());
MediaSession.Callback callback =
new MediaSession.Callback() {
@Override
public ConnectionResult onConnect(
MediaSession session, MediaSession.ControllerInfo controller) {
return new ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon().add(command1).build())
.build();
}
};
MediaSession mediaSession = createMediaSession(player, callback);
connectMediaNotificationController(mediaSession);
MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession);
ImmutableList<CommandButton> initialCustomLayout =
MediaUtils.convertToCustomLayout(controllerCompat.getPlaybackState());
AtomicReference<List<CommandButton>> reportedCustomLayout = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
controllerCompat.registerCallback(
new MediaControllerCompat.Callback() {
@Override
public void onPlaybackStateChanged(PlaybackStateCompat state) {
reportedCustomLayout.set(MediaUtils.convertToCustomLayout(state));
latch.countDown();
}
},
threadTestRule.getHandler());
getInstrumentation()
.runOnMainSync(
() ->
mediaSession.setCustomLayout(
mediaSession.getMediaNotificationControllerInfo(), customLayout));
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(initialCustomLayout).isEmpty();
assertThat(reportedCustomLayout.get())
.containsExactly(customLayout.get(0).copyWithIsEnabled(true));
mediaSession.release();
releasePlayer(player);
}
/**
* Connect a controller that mimics the media notification controller that is connected by {@link
* MediaNotificationManager} when the session is running in the service.
*/
private void connectMediaNotificationController(MediaSession mediaSession)
throws InterruptedException {
CountDownLatch connectionLatch = new CountDownLatch(1);
Bundle connectionHints = new Bundle();
connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true);
ListenableFuture<MediaController> mediaNotificationControllerFuture =
new MediaController.Builder(
ApplicationProvider.getApplicationContext(), mediaSession.getToken())
.setConnectionHints(connectionHints)
.buildAsync();
mediaNotificationControllerFuture.addListener(
connectionLatch::countDown, MoreExecutors.directExecutor());
assertThat(connectionLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
}
private PlaybackStateCompat getFirstPlaybackState( private PlaybackStateCompat getFirstPlaybackState(
MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException { MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException {
LinkedBlockingDeque<PlaybackStateCompat> playbackStateCompats = new LinkedBlockingDeque<>(); LinkedBlockingDeque<PlaybackStateCompat> playbackStateCompats = new LinkedBlockingDeque<>();

View File

@ -27,18 +27,24 @@ import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.ConditionVariable;
import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.session.MediaSession.ControllerInfo; 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.HandlerThreadTestRule;
import androidx.media3.test.session.common.MainLooperTestRule; import androidx.media3.test.session.common.MainLooperTestRule;
import androidx.media3.test.session.common.TestHandler;
import androidx.media3.test.session.common.TestUtils; import androidx.media3.test.session.common.TestUtils;
import androidx.media3.test.utils.TestExoPlayerBuilder;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.MediumTest; import androidx.test.filters.MediumTest;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.SettableFuture;
import java.util.ArrayList; import java.util.ArrayList;
@ -145,6 +151,100 @@ public class MediaSessionServiceTest {
service.blockUntilAllControllersUnbind(TIMEOUT_MS); service.blockUntilAllControllersUnbind(TIMEOUT_MS);
} }
@Test
public void onCreate_mediaNotificationManagerController_correctSessionStateFromOnConnect()
throws Exception {
SessionCommand command1 = new SessionCommand("command1", Bundle.EMPTY);
SessionCommand command2 = new SessionCommand("command2", Bundle.EMPTY);
SessionCommand command3 = new SessionCommand("command3", Bundle.EMPTY);
CommandButton button1 =
new CommandButton.Builder()
.setDisplayName("button1")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(command1)
.build();
CommandButton button2 =
new CommandButton.Builder()
.setDisplayName("button2")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(command2)
.build();
CommandButton button3 =
new CommandButton.Builder()
.setDisplayName("button3")
.setIconResId(R.drawable.media3_notification_small_icon)
.setSessionCommand(command3)
.build();
Bundle testHints = new Bundle();
testHints.putString("test_key", "test_value");
List<ControllerInfo> controllerInfoList = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(2);
TestHandler handler = new TestHandler(Looper.getMainLooper());
ExoPlayer player =
handler.postAndSync(
() -> {
ExoPlayer exoPlayer = new TestExoPlayerBuilder(context).build();
exoPlayer.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
exoPlayer.prepare();
return exoPlayer;
});
MediaSession mediaSession =
new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player)
.setCustomLayout(Lists.newArrayList(button1, button2))
.setCallback(
new MediaSession.Callback() {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) {
controllerInfoList.add(controller);
if (session.isMediaNotificationController(controller)) {
latch.countDown();
return new MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
SessionCommands.EMPTY.buildUpon().add(command1).add(command3).build())
.setAvailablePlayerCommands(Player.Commands.EMPTY)
.setCustomLayout(ImmutableList.of(button1, button3))
.build();
}
latch.countDown();
return new MediaSession.ConnectionResult.AcceptedResultBuilder(session).build();
}
})
.build();
TestServiceRegistry.getInstance().setOnGetSessionHandler(controllerInfo -> mediaSession);
MediaControllerCompat mediaControllerCompat =
new MediaControllerCompat(
ApplicationProvider.getApplicationContext(), mediaSession.getSessionCompat());
ImmutableList<CommandButton> initialCustomLayoutInControllerCompat =
MediaUtils.convertToCustomLayout(mediaControllerCompat.getPlaybackState());
// Start the service by creating a remote controller.
RemoteMediaController remoteController =
controllerTestRule.createRemoteController(token, /* waitForConnection= */ true, testHints);
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(
controllerInfoList
.get(0)
.getConnectionHints()
.getBoolean(
MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER,
/* defaultValue= */ false))
.isTrue();
assertThat(TestUtils.equals(controllerInfoList.get(1).getConnectionHints(), testHints))
.isTrue();
assertThat(mediaControllerCompat.getPlaybackState().getActions())
.isEqualTo(PlaybackStateCompat.ACTION_SET_RATING);
assertThat(remoteController.getCustomLayout()).containsExactly(button1, button2).inOrder();
assertThat(initialCustomLayoutInControllerCompat).isEmpty();
assertThat(MediaUtils.convertToCustomLayout(mediaControllerCompat.getPlaybackState()))
.containsExactly(button1.copyWithIsEnabled(true), button3.copyWithIsEnabled(true))
.inOrder();
mediaSession.release();
((MockMediaSessionService) TestServiceRegistry.getInstance().getServiceInstance())
.blockUntilAllControllersUnbind(TIMEOUT_MS);
}
/** /**
* Tests whether {@link MediaSessionService#onGetSession(ControllerInfo)} is called when * Tests whether {@link MediaSessionService#onGetSession(ControllerInfo)} is called when
* controller tries to connect, with the proper arguments. * controller tries to connect, with the proper arguments.

View File

@ -16,6 +16,7 @@
package androidx.media3.session; package androidx.media3.session;
import static androidx.media3.common.Player.COMMAND_GET_TRACKS; import static androidx.media3.common.Player.COMMAND_GET_TRACKS;
import static androidx.media3.session.MediaSession.ConnectionResult.accept;
import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION; import static androidx.media3.test.session.common.CommonConstants.ACTION_MEDIA3_SESSION;
import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES; import static androidx.media3.test.session.common.CommonConstants.KEY_AUDIO_ATTRIBUTES;
import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS; import static androidx.media3.test.session.common.CommonConstants.KEY_AVAILABLE_COMMANDS;
@ -63,6 +64,7 @@ import static androidx.media3.test.session.common.MediaSessionConstants.TEST_CON
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_CUSTOM_LAYOUT;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_GET_SESSION_ACTIVITY; 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_IS_SESSION_COMMAND_AVAILABLE;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_TRACKS_CHANGED_VIDEO_TO_AUDIO_TRANSITION; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_TRACKS_CHANGED_VIDEO_TO_AUDIO_TRANSITION;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_VIDEO_SIZE_CHANGED; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_ON_VIDEO_SIZE_CHANGED;
import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE; import static androidx.media3.test.session.common.MediaSessionConstants.TEST_SET_SHOW_PLAY_BUTTON_IF_SUPPRESSED_TO_FALSE;
@ -101,6 +103,7 @@ import androidx.media3.test.session.common.TestHandler;
import androidx.media3.test.session.common.TestHandler.TestRunnable; import androidx.media3.test.session.common.TestHandler.TestRunnable;
import androidx.media3.test.session.common.TestUtils; import androidx.media3.test.session.common.TestUtils;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -113,6 +116,8 @@ import java.util.concurrent.Callable;
*/ */
public class MediaSessionProviderService extends Service { public class MediaSessionProviderService extends Service {
public static final String KEY_ENABLE_FAKE_MEDIA_NOTIFICATION_MANAGER_CONTROLLER =
"key_enable_fake_media_notification_manager_controller";
private static final String TAG = "MSProviderService"; private static final String TAG = "MSProviderService";
private Map<String, MediaSession> sessionMap = new HashMap<>(); private Map<String, MediaSession> sessionMap = new HashMap<>();
@ -164,15 +169,18 @@ public class MediaSessionProviderService extends Service {
@Override @Override
public void create(String sessionId, Bundle tokenExtras) throws RemoteException { public void create(String sessionId, Bundle tokenExtras) throws RemoteException {
if (tokenExtras == null) {
tokenExtras = Bundle.EMPTY;
}
boolean useFakeMediaNotificationManagerController =
tokenExtras.getBoolean(
KEY_ENABLE_FAKE_MEDIA_NOTIFICATION_MANAGER_CONTROLLER, /* defaultValue= */ false);
MockPlayer mockPlayer = MockPlayer mockPlayer =
new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build(); new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build();
MediaSession.Builder builder = MediaSession.Builder builder =
new MediaSession.Builder(MediaSessionProviderService.this, mockPlayer).setId(sessionId); new MediaSession.Builder(MediaSessionProviderService.this, mockPlayer).setId(sessionId);
if (tokenExtras != null) {
builder.setExtras(tokenExtras); builder.setExtras(tokenExtras);
}
switch (sessionId) { switch (sessionId) {
case TEST_GET_SESSION_ACTIVITY: case TEST_GET_SESSION_ACTIVITY:
{ {
@ -194,7 +202,7 @@ public class MediaSessionProviderService extends Service {
@Override @Override
public MediaSession.ConnectionResult onConnect( public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) { MediaSession session, ControllerInfo controller) {
return MediaSession.ConnectionResult.accept( return accept(
new SessionCommands.Builder() new SessionCommands.Builder()
.add(new SessionCommand("command1", Bundle.EMPTY)) .add(new SessionCommand("command1", Bundle.EMPTY))
.add(new SessionCommand("command2", Bundle.EMPTY)) .add(new SessionCommand("command2", Bundle.EMPTY))
@ -216,8 +224,7 @@ public class MediaSessionProviderService extends Service {
@Override @Override
public MediaSession.ConnectionResult onConnect( public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) { MediaSession session, ControllerInfo controller) {
return MediaSession.ConnectionResult.accept( return accept(availableSessionCommands, Player.Commands.EMPTY);
availableSessionCommands, Player.Commands.EMPTY);
} }
}); });
break; break;
@ -244,8 +251,7 @@ public class MediaSessionProviderService extends Service {
@Override @Override
public MediaSession.ConnectionResult onConnect( public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) { MediaSession session, ControllerInfo controller) {
return MediaSession.ConnectionResult.accept( return accept(availableSessionCommands, Player.Commands.EMPTY);
availableSessionCommands, Player.Commands.EMPTY);
} }
}); });
break; break;
@ -272,8 +278,7 @@ public class MediaSessionProviderService extends Service {
.getBoolean(KEY_COMMAND_GET_TASKS_UNAVAILABLE, /* defaultValue= */ false)) { .getBoolean(KEY_COMMAND_GET_TASKS_UNAVAILABLE, /* defaultValue= */ false)) {
commandBuilder.remove(COMMAND_GET_TRACKS); commandBuilder.remove(COMMAND_GET_TRACKS);
} }
return MediaSession.ConnectionResult.accept( return accept(SessionCommands.EMPTY, commandBuilder.build());
SessionCommands.EMPTY, commandBuilder.build());
} }
}); });
break; break;
@ -290,6 +295,31 @@ public class MediaSessionProviderService extends Service {
builder.setShowPlayButtonIfPlaybackIsSuppressed(false); builder.setShowPlayButtonIfPlaybackIsSuppressed(false);
break; break;
} }
case TEST_MEDIA_CONTROLLER_COMPAT_CALLBACK_WITH_MEDIA_SESSION_TEST:
{
builder.setCallback(
new MediaSession.Callback() {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) {
MediaSession.ConnectionResult connectionResult =
MediaSession.Callback.super.onConnect(session, controller);
SessionCommands availableSessionCommands =
connectionResult.availableSessionCommands;
if (session.isMediaNotificationController(controller)) {
availableSessionCommands =
connectionResult
.availableSessionCommands
.buildUpon()
.add(new SessionCommand("command1", Bundle.EMPTY))
.build();
}
return accept(
availableSessionCommands, connectionResult.availablePlayerCommands);
}
});
break;
}
default: // fall out default: // fall out
} }
@ -297,6 +327,22 @@ public class MediaSessionProviderService extends Service {
() -> { () -> {
MediaSession session = builder.build(); MediaSession session = builder.build();
session.setSessionPositionUpdateDelayMs(0L); session.setSessionPositionUpdateDelayMs(0L);
if (useFakeMediaNotificationManagerController) {
Bundle connectionHints = new Bundle();
connectionHints.putBoolean("androidx.media3.session.MediaNotificationManager", true);
//noinspection unused
ListenableFuture<MediaController> unusedFuture =
new MediaController.Builder(getApplicationContext(), session.getToken())
.setListener(
new MediaController.Listener() {
@Override
public void onDisconnected(MediaController controller) {
controller.release();
}
})
.setConnectionHints(connectionHints)
.buildAsync();
}
sessionMap.put(sessionId, session); sessionMap.put(sessionId, session);
}); });
} }