Add interoperability for media item commands

See https://developer.android.com/training/cars/media#custom_browse_actions

- `MediaMetadata.supportedCommands` is converted to an array list of
  strings into the extras of `MediaDescriptionCompat` with `DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST` and vice versa.

- The set of media item command buttons of a session is passed in the
root hints to a legacy browser that connects. A Media3 browser connected
to a legacy service, gets the set of all commands after calling
`getLibraryRoot()`.

#cherrypick

PiperOrigin-RevId: 678807473
This commit is contained in:
bachinger 2024-09-25 12:37:38 -07:00 committed by Copybara-Service
parent 686c3fe7f5
commit b8ec6b836b
11 changed files with 561 additions and 18 deletions

View File

@ -43,6 +43,7 @@ import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Util.constrainValue;
import static androidx.media3.session.MediaConstants.EXTRA_KEY_ROOT_CHILDREN_BROWSABLE_ONLY;
import static androidx.media3.session.legacy.MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS;
import static androidx.media3.session.legacy.MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST;
import static androidx.media3.session.legacy.MediaMetadataCompat.PREFERRED_DESCRIPTION_ORDER;
import static androidx.media3.session.legacy.MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS;
import static java.lang.Math.max;
@ -539,6 +540,15 @@ import java.util.concurrent.TimeoutException;
extras.remove(MediaConstants.EXTRAS_KEY_MEDIA_TYPE_COMPAT);
}
if (extras != null
&& extras.containsKey(DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST)) {
builder.setSupportedCommands(
ImmutableList.copyOf(
checkNotNull(
extras.getStringArrayList(
DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST))));
}
if (extras != null
&& extras.containsKey(MediaConstants.EXTRAS_KEY_MEDIA_DESCRIPTION_COMPAT_TITLE)) {
builder.setTitle(
@ -826,6 +836,14 @@ import java.util.concurrent.TimeoutException;
MediaConstants.EXTRAS_KEY_MEDIA_TYPE_COMPAT, checkNotNull(metadata.mediaType));
}
}
if (!metadata.supportedCommands.isEmpty()) {
if (extras == null) {
extras = new Bundle();
}
extras.putStringArrayList(
DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST,
new ArrayList<>(metadata.supportedCommands));
}
CharSequence title;
CharSequence subtitle;
CharSequence description;
@ -1629,6 +1647,87 @@ import java.util.concurrent.TimeoutException;
return playbackInfoCompat.getCurrentVolume() == 0;
}
/**
* Converts a {@linkplain Bundle custom browse action} to a {@link CommandButton}. Returns null if
* the bundle doesn't contain sufficient information to build a command button.
*
* <p>See <a href="https://developer.android.com/training/cars/media#custom_browse_actions">Custom
* Browse Actions for Automotive OS</a>.
*
* @param browseActionBundle The bundle containing the information of a browse action.
* @return The resulting {@link CommandButton} or null.
*/
@Nullable
public static CommandButton convertCustomBrowseActionToCommandButton(Bundle browseActionBundle) {
String commandAction =
browseActionBundle.getString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID);
if (commandAction == null) {
return null;
}
@Nullable
CommandButton.Builder commandButton =
new CommandButton.Builder()
.setSessionCommand(new SessionCommand(commandAction, Bundle.EMPTY));
String label =
browseActionBundle.getString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL);
if (label != null) {
commandButton.setDisplayName(label);
}
String iconUri =
browseActionBundle.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI);
if (iconUri != null) {
try {
commandButton.setIconUri(Uri.parse(iconUri));
} catch (Throwable t) {
Log.e(TAG, "error parsing icon URI of legacy browser action " + commandAction, t);
}
}
Bundle actionExtras =
browseActionBundle.getBundle(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_EXTRAS);
if (actionExtras != null) {
commandButton.setExtras(actionExtras);
}
return commandButton.build();
}
/**
* Converts a {@link CommandButton} to a {@link Bundle} according to the browse action
* specification of Automotive OS.
*
* <p>See <a href="https://developer.android.com/training/cars/media#custom_browse_actions">Custom
* Browse Actions for Automotive OS</a>.
*
* @param commandButton The {@link CommandButton} to convert.
* @return The resulting {@link Bundle}.
*/
public static Bundle convertToBundle(CommandButton commandButton) {
Bundle buttonBundle = new Bundle();
if (commandButton.sessionCommand != null) {
buttonBundle.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID,
commandButton.sessionCommand.customAction);
}
buttonBundle.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL,
commandButton.displayName.toString());
if (commandButton.iconUri != null) {
buttonBundle.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI,
commandButton.iconUri.toString());
}
if (!commandButton.extras.isEmpty()) {
buttonBundle.putBundle(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_EXTRAS,
commandButton.extras);
}
return buttonBundle;
}
private static byte[] convertToByteArray(Bitmap bitmap) throws IOException {
try (ByteArrayOutputStream stream = new ByteArrayOutputStream()) {
bitmap.compress(Bitmap.CompressFormat.PNG, /* ignored */ 0, stream);

View File

@ -15,10 +15,12 @@
*/
package androidx.media3.session;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.session.SessionError.ERROR_BAD_VALUE;
import static androidx.media3.session.SessionError.ERROR_PERMISSION_DENIED;
import static androidx.media3.session.SessionError.ERROR_SESSION_DISCONNECTED;
import static androidx.media3.session.SessionError.ERROR_UNKNOWN;
import static androidx.media3.session.legacy.MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST;
import android.content.Context;
import android.os.Bundle;
@ -50,11 +52,11 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
private static final String TAG = "MB2ImplLegacy";
private final HashMap<LibraryParams, MediaBrowserCompat> browserCompats = new HashMap<>();
private final HashMap<String, List<SubscribeCallback>> subscribeCallbacks = new HashMap<>();
private final MediaBrowser instance;
private ImmutableMap<String, CommandButton> commandButtonsForMediaItems;
MediaBrowserImplLegacy(
Context context,
@UnderInitialization MediaBrowser instance,
@ -63,6 +65,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
BitmapLoader bitmapLoader) {
super(context, instance, token, applicationLooper, bitmapLoader);
this.instance = instance;
commandButtonsForMediaItems = ImmutableMap.of();
}
@Override
@ -91,7 +94,7 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
@Override
public ImmutableMap<String, CommandButton> getCommandButtonsForMediaItemsMap() {
return ImmutableMap.of();
return commandButtonsForMediaItems;
}
@Override
@ -376,10 +379,40 @@ import org.checkerframework.checker.initialization.qual.UnderInitialization;
// Shouldn't be happen. Internal error?
result.set(LibraryResult.ofError(ERROR_UNKNOWN));
} else {
Bundle extras = browserCompat.getExtras();
if (extras != null) {
ArrayList<Bundle> parcelableArrayList =
extras.getParcelableArrayList(
BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST);
if (parcelableArrayList != null) {
@Nullable
ImmutableMap.Builder<String, CommandButton> commandButtonsForMediaItemsBuilder = null;
// Converting custom browser action bundles to media item command buttons.
for (int i = 0; i < parcelableArrayList.size(); i++) {
CommandButton commandButton =
LegacyConversions.convertCustomBrowseActionToCommandButton(
parcelableArrayList.get(i));
if (commandButton != null) {
if (commandButtonsForMediaItemsBuilder == null) {
// Merge all media item command button of different legacy roots into a single
// map. Last wins in case of duplicate action names.
commandButtonsForMediaItemsBuilder =
new ImmutableMap.Builder<String, CommandButton>()
.putAll(commandButtonsForMediaItems);
}
String customAction = checkNotNull(commandButton.sessionCommand).customAction;
commandButtonsForMediaItemsBuilder.put(customAction, commandButton);
}
}
if (commandButtonsForMediaItemsBuilder != null) {
commandButtonsForMediaItems = commandButtonsForMediaItemsBuilder.buildKeepingLast();
}
}
}
result.set(
LibraryResult.ofItem(
createRootMediaItem(browserCompat),
LegacyConversions.convertToLibraryParams(context, browserCompat.getExtras())));
LegacyConversions.convertToLibraryParams(context, extras)));
}
}

View File

@ -23,6 +23,7 @@ import static androidx.media3.session.LibraryResult.RESULT_SUCCESS;
import static androidx.media3.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES;
import static androidx.media3.session.legacy.MediaBrowserCompat.EXTRA_PAGE;
import static androidx.media3.session.legacy.MediaBrowserCompat.EXTRA_PAGE_SIZE;
import static androidx.media3.session.legacy.MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST;
import static androidx.media3.session.legacy.MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED;
import android.annotation.SuppressLint;
@ -126,6 +127,22 @@ import java.util.concurrent.atomic.AtomicReference;
.isSessionCommandAvailable(controller, SessionCommand.COMMAND_CODE_LIBRARY_SEARCH);
checkNotNull(extras)
.putBoolean(BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, isSearchSessionCommandAvailable);
ImmutableList<CommandButton> commandButtonsForMediaItems =
librarySessionImpl.getCommandButtonsForMediaItems();
if (!commandButtonsForMediaItems.isEmpty()) {
ArrayList<Bundle> browserActionBundles = new ArrayList<>();
for (int i = 0; i < commandButtonsForMediaItems.size(); i++) {
CommandButton commandButton = commandButtonsForMediaItems.get(i);
if (commandButton.sessionCommand != null
&& commandButton.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) {
browserActionBundles.add(LegacyConversions.convertToBundle(commandButton));
}
}
if (!browserActionBundles.isEmpty()) {
extras.putParcelableArrayList(
BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, browserActionBundles);
}
}
return new BrowserRoot(result.value.mediaId, extras);
}
// No library root, but keep browser compat connected to allow getting session unless the

View File

@ -29,10 +29,13 @@ public class MediaBrowserConstants {
public static final String ROOT_EXTRAS_KEY = "root_extras_key";
public static final int ROOT_EXTRAS_VALUE = 4321;
public static final String COMMAND_ACTION_PLAYLIST_ADD = "androidx.media3.actions.playlist_add";
public static final String COMMAND_PLAYLIST_ADD = "androidx.media3.commands.playlist_add";
public static final String COMMAND_RADIO = "androidx.media3.commands.radio";
public static final String MEDIA_ID_GET_BROWSABLE_ITEM = "media_id_get_browsable_item";
public static final String MEDIA_ID_GET_PLAYABLE_ITEM = "media_id_get_playable_item";
public static final String MEDIA_ID_GET_ITEM_WITH_BROWSE_ACTIONS =
"media_id_item_with_browse_actions";
public static final String MEDIA_ID_GET_ITEM_WITH_METADATA = "media_id_get_item_with_metadata";
public static final String PARENT_ID = "parent_id";

View File

@ -30,6 +30,8 @@ public class MediaBrowserServiceCompatConstants {
public static final String TEST_GET_CHILDREN_NON_FATAL_AUTHENTICATION_ERROR =
"getLibraryRoot_nonFatalAuthenticationError_receivesPlaybackException";
public static final String TEST_SEND_CUSTOM_COMMAND = "sendCustomCommand";
public static final String TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS =
"getLibraryRoot_withBrowseActions";
private MediaBrowserServiceCompatConstants() {}
}

View File

@ -22,6 +22,7 @@ import static androidx.media3.session.MediaLibraryService.MediaLibrarySession.LI
import static androidx.media3.session.MediaLibraryService.MediaLibrarySession.LIBRARY_ERROR_REPLICATION_MODE_NON_FATAL;
import static androidx.media3.session.MockMediaLibraryService.CONNECTION_HINTS_CUSTOM_LIBRARY_ROOT;
import static androidx.media3.session.MockMediaLibraryService.createNotifyChildrenChangedBundle;
import static androidx.media3.session.legacy.MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST;
import static androidx.media3.test.session.common.CommonConstants.METADATA_ALBUM_TITLE;
import static androidx.media3.test.session.common.CommonConstants.METADATA_ARTIST;
import static androidx.media3.test.session.common.CommonConstants.METADATA_ARTWORK_URI;
@ -37,6 +38,7 @@ import static androidx.media3.test.session.common.MediaBrowserConstants.CUSTOM_A
import static androidx.media3.test.session.common.MediaBrowserConstants.GET_CHILDREN_RESULT;
import static androidx.media3.test.session.common.MediaBrowserConstants.LONG_LIST_COUNT;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_BROWSABLE_ITEM;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_ITEM_WITH_BROWSE_ACTIONS;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_ITEM_WITH_METADATA;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_PLAYABLE_ITEM;
import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID;
@ -78,6 +80,7 @@ import android.support.v4.media.MediaBrowserCompat.SubscriptionCallback;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.text.TextUtils;
import androidx.media3.test.session.common.MediaBrowserConstants;
import androidx.media3.test.session.common.TestUtils;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.ext.truth.os.BundleSubject;
@ -114,6 +117,71 @@ public class MediaBrowserCompatWithMediaLibraryServiceTest
.getExtras()
.getInt(ROOT_EXTRAS_KEY, /* defaultValue= */ ROOT_EXTRAS_VALUE + 1))
.isEqualTo(ROOT_EXTRAS_VALUE);
ArrayList<Bundle> mediaItemCommandButtons =
browserCompat
.getExtras()
.getParcelableArrayList(
androidx.media3.session.legacy.MediaConstants
.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST);
assertThat(mediaItemCommandButtons).hasSize(2);
assertThat(
mediaItemCommandButtons
.get(0)
.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID))
.isEqualTo(MediaBrowserConstants.COMMAND_PLAYLIST_ADD);
assertThat(
mediaItemCommandButtons
.get(0)
.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL))
.isEqualTo("Add to playlist");
assertThat(
mediaItemCommandButtons
.get(0)
.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI))
.isEqualTo("http://www.example.com/icon/playlist_add");
assertThat(
mediaItemCommandButtons
.get(0)
.getBundle(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_EXTRAS)
.getString("key-1"))
.isEqualTo("playlist_add");
assertThat(
mediaItemCommandButtons
.get(1)
.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID))
.isEqualTo(MediaBrowserConstants.COMMAND_RADIO);
assertThat(
mediaItemCommandButtons
.get(1)
.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL))
.isEqualTo("Radio station");
assertThat(
mediaItemCommandButtons
.get(1)
.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI))
.isEqualTo("http://www.example.com/icon/radio");
assertThat(
mediaItemCommandButtons
.get(1)
.getBundle(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_EXTRAS)
.getString("key-1"))
.isEqualTo("radio");
// Note: Cannot use equals() here because browser compat's extra contains server version,
// extra binder, and extra messenger.
@ -166,6 +234,35 @@ public class MediaBrowserCompatWithMediaLibraryServiceTest
assertThat(itemRef.get().getDescription().getIconBitmap()).isNotNull();
}
@Test
public void getItem_playableWithBrowseActions_browseActionCorrectlyConverted() throws Exception {
String mediaId = MEDIA_ID_GET_ITEM_WITH_BROWSE_ACTIONS;
connectAndWait(/* rootHints= */ Bundle.EMPTY);
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<MediaItem> itemRef = new AtomicReference<>();
browserCompat.getItem(
mediaId,
new ItemCallback() {
@Override
public void onItemLoaded(MediaItem item) {
itemRef.set(item);
latch.countDown();
}
});
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(
itemRef
.get()
.getDescription()
.getExtras()
.getStringArrayList(DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST))
.containsExactly(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD, MediaBrowserConstants.COMMAND_RADIO)
.inOrder();
}
@Test
public void getItem_metadata() throws Exception {
String mediaId = MEDIA_ID_GET_ITEM_WITH_METADATA;
@ -295,6 +392,14 @@ public class MediaBrowserCompatWithMediaLibraryServiceTest
.isEqualTo(EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED);
assertThat(mediaItem.getDescription().getIconBitmap()).isNotNull();
assertThat(onChildrenLoadedWithBundleCalled.get()).isFalse();
assertThat(
mediaItem
.getDescription()
.getExtras()
.getStringArrayList(DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST))
.containsExactly(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD, MediaBrowserConstants.COMMAND_RADIO)
.inOrder();
}
}

View File

@ -31,19 +31,23 @@ import static androidx.media3.test.session.common.MediaBrowserServiceCompatConst
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_GET_CHILDREN_NON_FATAL_AUTHENTICATION_ERROR;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_GET_CHILDREN_WITH_NULL_LIST;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_GET_LIBRARY_ROOT;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_ON_CHILDREN_CHANGED_SUBSCRIBE_AND_UNSUBSCRIBE;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_SEND_CUSTOM_COMMAND;
import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.junit.Assert.assertThrows;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.session.MediaLibraryService.LibraryParams;
@ -118,6 +122,114 @@ public class MediaBrowserListenerWithMediaBrowserServiceCompatTest {
assertThat(thrown).hasCauseThat().isInstanceOf(SecurityException.class);
}
@Test
public void getLibraryRoot_browseActionsAvailable() throws Exception {
remoteService.setProxyForTest(TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS);
CommandButton playlistAddButton =
new CommandButton.Builder()
.setDisplayName("Add to playlist")
.setIconUri(Uri.parse("https://www.example.com/icon/playlist_add"))
.setSessionCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_PLAYLIST_ADD, Bundle.EMPTY))
.build();
CommandButton radioButton =
new CommandButton.Builder()
.setDisplayName("Radio station")
.setIconUri(Uri.parse("https://www.example.com/icon/radio"))
.setSessionCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_RADIO, Bundle.EMPTY))
.build();
MediaItem mediaItem =
new MediaItem.Builder()
.setMediaId("mediaId")
.setMediaMetadata(
new MediaMetadata.Builder()
.setSupportedCommands(
ImmutableList.of(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD,
MediaBrowserConstants.COMMAND_RADIO,
"invalid"))
.build())
.build();
MediaBrowser mediaBrowser = createBrowser(/* listener= */ null);
// When connected to a legacy browser service, the library root needs to be requested
// before media item commands are available.
LibraryResult<MediaItem> libraryResult =
threadTestRule
.getHandler()
.postAndSync(() -> mediaBrowser.getLibraryRoot(new LibraryParams.Builder().build()))
.get();
assertThat(libraryResult.resultCode).isEqualTo(RESULT_SUCCESS);
ImmutableList<CommandButton> commandButtons =
mediaBrowser.getCommandButtonsForMediaItem(mediaItem);
assertThat(commandButtons).containsExactly(playlistAddButton, radioButton).inOrder();
assertThat(commandButtons.get(0).extras.getString("key-1")).isEqualTo("playlist_add");
assertThat(commandButtons.get(1).extras.getString("key-1")).isEqualTo("radio");
}
@Test
public void getItem_supportedCommandActions_convertedCorrectly() throws Exception {
remoteService.setProxyForTest(TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS);
MediaBrowser mediaBrowser = createBrowser(/* listener= */ null);
CommandButton playlistAddButton =
new CommandButton.Builder()
.setDisplayName("Add to playlist")
.setIconUri(Uri.parse("https://www.example.com/icon/playlist_add"))
.setSessionCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_PLAYLIST_ADD, Bundle.EMPTY))
.build();
CommandButton radioButton =
new CommandButton.Builder()
.setDisplayName("Radio station")
.setIconUri(Uri.parse("https://www.example.com/icon/radio"))
.setSessionCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_RADIO, Bundle.EMPTY))
.build();
// When connected to a legacy browser service, the library root needs to be requested
// before media item commands are available.
LibraryResult<MediaItem> libraryResult =
threadTestRule
.getHandler()
.postAndSync(() -> mediaBrowser.getLibraryRoot(new LibraryParams.Builder().build()))
.get();
assertThat(libraryResult.resultCode).isEqualTo(RESULT_SUCCESS);
MediaItem mediaItem =
threadTestRule.getHandler().postAndSync(() -> mediaBrowser.getItem("mediaId")).get().value;
ImmutableList<CommandButton> commandButtons =
threadTestRule
.getHandler()
.postAndSync(
() -> mediaBrowser.getCommandButtonsForMediaItem(requireNonNull(mediaItem)));
assertThat(commandButtons).containsExactly(playlistAddButton, radioButton).inOrder();
assertThat(commandButtons.get(0).extras.getString("key-1")).isEqualTo("playlist_add");
assertThat(commandButtons.get(1).extras.getString("key-1")).isEqualTo("radio");
}
@Test
public void sendCustomCommandWithMediaItem_mediaItemIdConvertedCorrectly() throws Exception {
remoteService.setProxyForTest(TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS);
MediaBrowser mediaBrowser = createBrowser(/* listener= */ null);
MediaItem mediaItem = new MediaItem.Builder().setMediaId("mediaIdFromCommand").build();
SessionResult sessionResult =
threadTestRule
.getHandler()
.postAndSync(
() ->
mediaBrowser.sendCustomCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_RADIO, Bundle.EMPTY),
mediaItem,
/* args= */ Bundle.EMPTY))
.get();
assertThat(sessionResult.extras.getString(MediaConstants.EXTRA_KEY_MEDIA_ID))
.isEqualTo("mediaIdFromCommand");
}
@Test
public void onChildrenChanged_subscribeAndUnsubscribe() throws Exception {
String testParentId = "testOnChildrenChanged";
@ -358,8 +470,7 @@ public class MediaBrowserListenerWithMediaBrowserServiceCompatTest {
() ->
browser.sendCustomCommand(
new SessionCommand(
MediaBrowserConstants.COMMAND_ACTION_PLAYLIST_ADD,
/* extras= */ Bundle.EMPTY),
MediaBrowserConstants.COMMAND_PLAYLIST_ADD, /* extras= */ Bundle.EMPTY),
/* args= */ Bundle.EMPTY));
Futures.addCallback(
resultFuture,
@ -397,8 +508,7 @@ public class MediaBrowserListenerWithMediaBrowserServiceCompatTest {
() ->
browser.sendCustomCommand(
new SessionCommand(
MediaBrowserConstants.COMMAND_ACTION_PLAYLIST_ADD,
/* extras= */ Bundle.EMPTY),
MediaBrowserConstants.COMMAND_PLAYLIST_ADD, /* extras= */ Bundle.EMPTY),
args));
Futures.addCallback(
resultFuture,

View File

@ -61,6 +61,7 @@ import androidx.media3.common.VideoSize;
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.MediaBrowserConstants;
import androidx.media3.test.session.common.PollingCheck;
import androidx.media3.test.session.common.TestUtils;
import androidx.test.core.app.ApplicationProvider;
@ -557,11 +558,12 @@ public class MediaControllerTest {
CommandButton playlistAddButton =
new CommandButton.Builder(CommandButton.ICON_PLAYLIST_ADD)
.setSessionCommand(
new SessionCommand("androidx.media3.actions.playlist_add", Bundle.EMPTY))
new SessionCommand(MediaBrowserConstants.COMMAND_PLAYLIST_ADD, Bundle.EMPTY))
.build();
CommandButton radioButton =
new CommandButton.Builder(CommandButton.ICON_RADIO)
.setSessionCommand(new SessionCommand("androidx.media3.actions.radio", Bundle.EMPTY))
.setSessionCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_RADIO, Bundle.EMPTY))
.build();
MediaItem mediaItem =
new MediaItem.Builder()
@ -570,8 +572,8 @@ public class MediaControllerTest {
new MediaMetadata.Builder()
.setSupportedCommands(
ImmutableList.of(
"androidx.media3.actions.playlist_add",
"androidx.media3.actions.radio",
MediaBrowserConstants.COMMAND_PLAYLIST_ADD,
MediaBrowserConstants.COMMAND_RADIO,
"invalid"))
.build())
.build();
@ -595,7 +597,8 @@ public class MediaControllerTest {
.setMediaId("mediaId-1")
.setMediaMetadata(
new MediaMetadata.Builder()
.setSupportedCommands(ImmutableList.of("androidx.media3.actions.playlist_add"))
.setSupportedCommands(
ImmutableList.of(MediaBrowserConstants.COMMAND_PLAYLIST_ADD))
.build())
.build();
CountDownLatch latch = new CountDownLatch(/* count= */ 1);

View File

@ -103,6 +103,7 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerInfo;
import androidx.media3.test.session.common.IRemoteMediaSession;
import androidx.media3.test.session.common.MediaBrowserConstants;
import androidx.media3.test.session.common.MockActivity;
import androidx.media3.test.session.common.TestHandler;
import androidx.media3.test.session.common.TestHandler.TestRunnable;
@ -240,12 +241,13 @@ public class MediaSessionProviderService extends Service {
CommandButton playlistAddButton =
new CommandButton.Builder(CommandButton.ICON_PLAYLIST_ADD)
.setSessionCommand(
new SessionCommand("androidx.media3.actions.playlist_add", Bundle.EMPTY))
new SessionCommand(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD, Bundle.EMPTY))
.build();
CommandButton radioButton =
new CommandButton.Builder(CommandButton.ICON_RADIO)
.setSessionCommand(
new SessionCommand("androidx.media3.actions.radio", Bundle.EMPTY))
new SessionCommand(MediaBrowserConstants.COMMAND_RADIO, Bundle.EMPTY))
.build();
builder.setCommandButtonsForMediaItems(
ImmutableList.of(playlistAddButton, radioButton));

View File

@ -19,6 +19,9 @@ import static androidx.media3.session.MediaConstants.EXTRAS_KEY_COMPLETION_STATU
import static androidx.media3.session.MediaConstants.EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED;
import static androidx.media3.session.MediaConstants.EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED;
import static androidx.media3.session.MediaConstants.EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED;
import static androidx.media3.session.legacy.MediaConstants.BROWSER_ROOT_HINTS_KEY_CUSTOM_BROWSER_ACTION_LIMIT;
import static androidx.media3.session.legacy.MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST;
import static androidx.media3.session.legacy.MediaConstants.DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST;
import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME;
import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_EXTRAS;
import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_ID;
@ -29,8 +32,10 @@ import static androidx.media3.test.session.common.MediaBrowserServiceCompatConst
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_GET_CHILDREN_NON_FATAL_AUTHENTICATION_ERROR;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_GET_CHILDREN_WITH_NULL_LIST;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_GET_LIBRARY_ROOT;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_ON_CHILDREN_CHANGED_SUBSCRIBE_AND_UNSUBSCRIBE;
import static androidx.media3.test.session.common.MediaBrowserServiceCompatConstants.TEST_SEND_CUSTOM_COMMAND;
import static java.lang.Math.min;
import android.content.Intent;
import android.net.Uri;
@ -51,6 +56,7 @@ import androidx.media3.test.session.common.MediaBrowserConstants;
import androidx.media3.test.session.common.MediaBrowserServiceCompatConstants;
import com.google.common.collect.ImmutableList;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -276,6 +282,9 @@ public class MockMediaBrowserServiceCompat extends MediaBrowserServiceCompat {
case TEST_SEND_CUSTOM_COMMAND:
setProxyForTestSendCustomCommand();
break;
case TEST_MEDIA_ITEMS_WITH_BROWSE_ACTIONS:
setProxyForMediaItemsWithBrowseActions(session);
break;
default:
throw new IllegalArgumentException("Unknown testName: " + testName);
}
@ -341,6 +350,120 @@ public class MockMediaBrowserServiceCompat extends MediaBrowserServiceCompat {
});
}
private void setProxyForMediaItemsWithBrowseActions(MediaSessionCompat session) {
// See https://developer.android.com/training/cars/media#custom_browse_actions
Bundle playlistAddBrowseAction = new Bundle();
Bundle playlistAddExtras = new Bundle();
playlistAddExtras.putString("key-1", "playlist_add");
playlistAddBrowseAction.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID,
MediaBrowserConstants.COMMAND_PLAYLIST_ADD);
playlistAddBrowseAction.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL,
"Add to playlist");
playlistAddBrowseAction.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI,
"https://www.example.com/icon/playlist_add");
playlistAddBrowseAction.putBundle(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_EXTRAS,
playlistAddExtras);
Bundle radioBrowseAction = new Bundle();
Bundle radioExtras = new Bundle();
radioExtras.putString("key-1", "radio");
radioBrowseAction.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID,
MediaBrowserConstants.COMMAND_RADIO);
radioBrowseAction.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_LABEL,
"Radio station");
radioBrowseAction.putString(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ICON_URI,
"https://www.example.com/icon/radio");
radioBrowseAction.putBundle(
androidx.media3.session.legacy.MediaConstants.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_EXTRAS,
radioExtras);
ImmutableList<Bundle> browseActions =
ImmutableList.of(playlistAddBrowseAction, radioBrowseAction);
setMediaBrowserServiceProxy(
new MockMediaBrowserServiceCompat.Proxy() {
@Override
public BrowserRoot onGetRoot(
String clientPackageName, int clientUid, Bundle rootHints) {
int actionLimit =
rootHints.getInt(
BROWSER_ROOT_HINTS_KEY_CUSTOM_BROWSER_ACTION_LIMIT,
/* defaultValue= */ browseActions.size());
Bundle extras = new Bundle(rootHints);
ArrayList<Bundle> browseActionList = new ArrayList<>();
for (int i = 0; i < min(actionLimit, browseActions.size()); i++) {
browseActionList.add(browseActions.get(i));
}
extras.putParcelableArrayList(
BROWSER_SERVICE_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ROOT_LIST, browseActionList);
session.setPlaybackState(
new PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_PLAYING,
/* position= */ 123L,
/* playbackSpeed= */ 1.0f)
.addCustomAction(
new PlaybackStateCompat.CustomAction.Builder(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD,
"Add to playlist",
CommandButton.ICON_PLAYLIST_ADD)
.build())
.addCustomAction(
new PlaybackStateCompat.CustomAction.Builder(
MediaBrowserConstants.COMMAND_RADIO,
"Radio station",
CommandButton.ICON_RADIO)
.build())
.build());
return new BrowserRoot(ROOT_ID, extras);
}
@Override
public void onLoadItem(String itemId, Result<MediaItem> result) {
Bundle extras = new Bundle();
ArrayList<String> supportedActions = new ArrayList<>();
supportedActions.add(MediaBrowserConstants.COMMAND_PLAYLIST_ADD);
supportedActions.add(MediaBrowserConstants.COMMAND_RADIO);
extras.putStringArrayList(
DESCRIPTION_EXTRAS_KEY_CUSTOM_BROWSER_ACTION_ID_LIST, supportedActions);
MediaDescriptionCompat description =
new MediaDescriptionCompat.Builder()
.setMediaId(itemId)
.setExtras(extras)
.setTitle("title of " + itemId)
.build();
result.sendResult(new MediaItem(description, MediaItem.FLAG_PLAYABLE));
}
@Override
public void onCustomAction(String action, Bundle extras, Result<Bundle> result) {
if (action.equals(MediaBrowserConstants.COMMAND_PLAYLIST_ADD)
|| action.equals(MediaBrowserConstants.COMMAND_RADIO)) {
Bundle resultBundle = new Bundle();
if (extras.containsKey(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_MEDIA_ITEM_ID)) {
resultBundle.putString(
MediaConstants.EXTRA_KEY_MEDIA_ID,
extras.getString(
androidx.media3.session.legacy.MediaConstants
.EXTRAS_KEY_CUSTOM_BROWSER_ACTION_MEDIA_ITEM_ID));
}
session.setExtras(resultBundle);
result.sendResult(resultBundle);
}
}
});
}
private void getChildren_authenticationError_receivesPlaybackException(
MediaSessionCompat session, boolean isFatal) {
setMediaBrowserServiceProxy(
@ -393,7 +516,7 @@ public class MockMediaBrowserServiceCompat extends MediaBrowserServiceCompat {
/* playbackSpeed= */ 1.0f)
.addCustomAction(
new PlaybackStateCompat.CustomAction.Builder(
MediaBrowserConstants.COMMAND_ACTION_PLAYLIST_ADD,
MediaBrowserConstants.COMMAND_PLAYLIST_ADD,
"Add to playlist",
CommandButton.ICON_PLAYLIST_ADD)
.build())
@ -405,7 +528,7 @@ public class MockMediaBrowserServiceCompat extends MediaBrowserServiceCompat {
@Override
public void onCustomAction(String action, Bundle extras, Result<Bundle> result) {
Bundle resultBundle = new Bundle();
if (action.equals(MediaBrowserConstants.COMMAND_ACTION_PLAYLIST_ADD)) {
if (action.equals(MediaBrowserConstants.COMMAND_PLAYLIST_ADD)) {
if (extras.getBoolean("request_error", /* defaultValue= */ false)) {
resultBundle.putString("key-1", "error-from-service");
result.sendError(resultBundle);

View File

@ -38,6 +38,7 @@ import static androidx.media3.test.session.common.MediaBrowserConstants.EXTRAS_K
import static androidx.media3.test.session.common.MediaBrowserConstants.GET_CHILDREN_RESULT;
import static androidx.media3.test.session.common.MediaBrowserConstants.LONG_LIST_COUNT;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_BROWSABLE_ITEM;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_ITEM_WITH_BROWSE_ACTIONS;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_ITEM_WITH_METADATA;
import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_PLAYABLE_ITEM;
import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID;
@ -67,6 +68,7 @@ import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.HandlerThread;
import android.os.IBinder;
@ -79,6 +81,7 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerInfo;
import androidx.media3.test.session.common.CommonConstants;
import androidx.media3.test.session.common.MediaBrowserConstants;
import androidx.media3.test.session.common.TestHandler;
import androidx.media3.test.session.common.TestUtils;
import com.google.common.collect.ImmutableList;
@ -219,6 +222,10 @@ public class MockMediaLibraryService extends MediaLibraryService {
.getInt(
CONNECTION_HINTS_KEY_LIBRARY_ERROR_REPLICATION_MODE,
LIBRARY_ERROR_REPLICATION_MODE_FATAL);
Bundle playlistAddExtras = new Bundle();
playlistAddExtras.putString("key-1", "playlist_add");
Bundle radioExtras = new Bundle();
radioExtras.putString("key-1", "radio");
session =
new MediaLibrarySession.Builder(
MockMediaLibraryService.this,
@ -226,6 +233,23 @@ public class MockMediaLibraryService extends MediaLibraryService {
callback != null ? callback : new TestLibrarySessionCallback())
.setId(ID)
.setLibraryErrorReplicationMode(libraryErrorReplicationMode)
.setCommandButtonsForMediaItems(
ImmutableList.of(
new CommandButton.Builder(CommandButton.ICON_PLAYLIST_ADD)
.setDisplayName("Add to playlist")
.setIconUri(Uri.parse("http://www.example.com/icon/playlist_add"))
.setSessionCommand(
new SessionCommand(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD, Bundle.EMPTY))
.setExtras(playlistAddExtras)
.build(),
new CommandButton.Builder(CommandButton.ICON_RADIO)
.setDisplayName("Radio station")
.setIconUri(Uri.parse("http://www.example.com/icon/radio"))
.setSessionCommand(
new SessionCommand(MediaBrowserConstants.COMMAND_RADIO, Bundle.EMPTY))
.setExtras(radioExtras)
.build()))
.build();
}
return session;
@ -333,6 +357,10 @@ public class MockMediaLibraryService extends MediaLibraryService {
return Futures.immediateFuture(
LibraryResult.ofItem(
createPlayableMediaItemWithArtworkData(mediaId), /* params= */ null));
case MEDIA_ID_GET_ITEM_WITH_BROWSE_ACTIONS:
return Futures.immediateFuture(
LibraryResult.ofItem(
createPlayableMediaItemWithBrowseActions(mediaId), /* params= */ null));
case MEDIA_ID_GET_ITEM_WITH_METADATA:
return Futures.immediateFuture(
LibraryResult.ofItem(createMediaItemWithMetadata(mediaId), /* params= */ null));
@ -589,11 +617,29 @@ public class MockMediaLibraryService extends MediaLibraryService {
mediaItem
.mediaMetadata
.buildUpon()
.setSupportedCommands(
ImmutableList.of(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD,
MediaBrowserConstants.COMMAND_RADIO))
.setArtworkData(getArtworkData(), MediaMetadata.PICTURE_TYPE_FRONT_COVER)
.build();
return mediaItem.buildUpon().setMediaMetadata(mediaMetadataWithArtwork).build();
}
private MediaItem createPlayableMediaItemWithBrowseActions(String mediaId) {
MediaItem mediaItem = createPlayableMediaItem(mediaId);
MediaMetadata mediaMetadataWithBrowseActions =
mediaItem
.mediaMetadata
.buildUpon()
.setSupportedCommands(
ImmutableList.of(
MediaBrowserConstants.COMMAND_PLAYLIST_ADD,
MediaBrowserConstants.COMMAND_RADIO))
.build();
return mediaItem.buildUpon().setMediaMetadata(mediaMetadataWithBrowseActions).build();
}
private static MediaItem createPlayableMediaItem(String mediaId) {
Bundle extras = new Bundle();
extras.putInt(EXTRAS_KEY_COMPLETION_STATUS, EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED);