Support custom actions with DefaultMediaNotificationProvider

Refactors the DefaultMediaNotificationProvider by separating the
selection of actions and building the notification with it.

The custom commands of the custom layout of the session are turned
into notification actions and when received from the notification
converted back to custom session commands that are sent to the
session.

PiperOrigin-RevId: 450404350
This commit is contained in:
bachinger 2022-05-23 12:13:04 +01:00 committed by Ian Baker
parent 0fa0735935
commit 8d03fdfe34
9 changed files with 691 additions and 72 deletions

View File

@ -29,6 +29,8 @@ import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import android.app.PendingIntent;
import android.app.Service;
@ -64,7 +66,7 @@ import androidx.media3.common.util.Util;
@Override
public NotificationCompat.Action createMediaAction(
IconCompat icon, CharSequence title, @Player.Command long command) {
IconCompat icon, CharSequence title, @Player.Command int command) {
return new NotificationCompat.Action(icon, title, createMediaActionPendingIntent(command));
}
@ -75,6 +77,20 @@ import androidx.media3.common.util.Util;
icon, title, createCustomActionPendingIntent(customAction, extras));
}
@Override
public NotificationCompat.Action createCustomActionFromCustomCommandButton(
CommandButton customCommandButton) {
checkArgument(
customCommandButton.sessionCommand != null
&& customCommandButton.sessionCommand.commandCode
== SessionCommand.COMMAND_CODE_CUSTOM);
SessionCommand customCommand = checkNotNull(customCommandButton.sessionCommand);
return new NotificationCompat.Action(
IconCompat.createWithResource(service, customCommandButton.iconResId),
customCommandButton.displayName,
createCustomActionPendingIntent(customCommand.customAction, customCommand.customExtras));
}
@Override
public PendingIntent createMediaActionPendingIntent(@Player.Command long command) {
int keyCode = toKeyCode(command);
@ -120,7 +136,8 @@ import androidx.media3.common.util.Util;
service,
/* requestCode= */ ++customActionPendingIntentRequestCode,
intent,
Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
PendingIntent.FLAG_UPDATE_CURRENT
| (Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
}
/** Returns whether {@code intent} was part of a {@link #createMediaAction media action}. */

View File

@ -15,12 +15,15 @@
*/
package androidx.media3.session;
import static androidx.media3.common.C.INDEX_UNSET;
import static androidx.media3.common.Player.COMMAND_INVALID;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_STOP;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.castNonNull;
@ -44,10 +47,14 @@ import androidx.media3.common.util.Consumer;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
/**
@ -76,7 +83,15 @@ import java.util.concurrent.ExecutionException;
* </ul>
*/
@UnstableApi
public final class DefaultMediaNotificationProvider implements MediaNotification.Provider {
public class DefaultMediaNotificationProvider implements MediaNotification.Provider {
/**
* An extras key that can be used to define the index of a {@link CommandButton} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}.
*/
public static final String COMMAND_KEY_COMPACT_VIEW_INDEX =
"androidx.media3.session.command.COMPACT_VIEW_INDEX";
private static final String TAG = "NotificationProvider";
private static final int NOTIFICATION_ID = 1001;
private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id";
@ -110,56 +125,15 @@ public final class DefaultMediaNotificationProvider implements MediaNotification
}
@Override
public MediaNotification createNotification(
public final MediaNotification createNotification(
MediaController mediaController,
ImmutableList<CommandButton> customLayout,
MediaNotification.ActionFactory actionFactory,
Callback onNotificationChangedCallback) {
ensureNotificationChannel();
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID);
Player.Commands availableCommands = mediaController.getAvailableCommands();
// Skip to previous action.
boolean skipToPreviousAdded = false;
if (availableCommands.containsAny(
COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
skipToPreviousAdded = true;
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(
context, R.drawable.media3_notification_seek_to_previous),
context.getString(R.string.media3_controls_seek_to_previous_description),
COMMAND_SEEK_TO_PREVIOUS));
}
boolean playPauseAdded = false;
if (availableCommands.contains(COMMAND_PLAY_PAUSE)) {
playPauseAdded = true;
if (mediaController.getPlaybackState() == Player.STATE_ENDED
|| !mediaController.getPlayWhenReady()) {
// Play action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_play),
context.getString(R.string.media3_controls_play_description),
COMMAND_PLAY_PAUSE));
} else {
// Pause action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
context.getString(R.string.media3_controls_pause_description),
COMMAND_PLAY_PAUSE));
}
}
// Skip to next action.
if (availableCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) {
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next),
context.getString(R.string.media3_controls_seek_to_next_description),
COMMAND_SEEK_TO_NEXT));
}
// Set metadata info in the notification.
MediaMetadata metadata = mediaController.getMediaMetadata();
builder.setContentTitle(metadata.title).setContentText(metadata.artist);
@ -188,14 +162,19 @@ public final class DefaultMediaNotificationProvider implements MediaNotification
}
MediaStyle mediaStyle = new MediaStyle();
int[] compactViewIndices =
addNotificationActions(
getMediaButtons(
mediaController.getAvailableCommands(),
customLayout,
mediaController.getPlayWhenReady()),
builder,
actionFactory);
mediaStyle.setShowActionsInCompactView(compactViewIndices);
if (mediaController.isCommandAvailable(COMMAND_STOP) || Util.SDK_INT < 21) {
// We must include a cancel intent for pre-L devices.
mediaStyle.setCancelButtonIntent(actionFactory.createMediaActionPendingIntent(COMMAND_STOP));
}
if (playPauseAdded) {
// Show play/pause button only in compact view.
mediaStyle.setShowActionsInCompactView(skipToPreviousAdded ? 1 : 0);
}
long playbackStartTimeMs = getPlaybackStartTimeEpochMs(mediaController);
boolean displayElapsedTimeWithChronometer = playbackStartTimeMs != C.TIME_UNSET;
@ -218,8 +197,174 @@ public final class DefaultMediaNotificationProvider implements MediaNotification
}
@Override
public void handleCustomAction(MediaController mediaController, String action, Bundle extras) {
// We don't handle custom commands.
public final void handleCustomCommand(
MediaController mediaController, String action, Bundle extras) {
@Nullable SessionCommand customCommand = null;
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) {
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
&& command.customAction.equals(action)) {
customCommand = command;
break;
}
}
if (customCommand != null) {
ListenableFuture<SessionResult> future =
mediaController.sendCustomCommand(customCommand, extras);
Futures.addCallback(
future,
new FutureCallback<SessionResult>() {
@Override
public void onSuccess(SessionResult result) {
// Do nothing.
}
@Override
public void onFailure(Throwable t) {
Log.w(TAG, "custom command " + action + " produced an error: " + t.getMessage(), t);
}
},
MoreExecutors.directExecutor());
}
}
/**
* Returns the ordered list of {@linkplain CommandButton command buttons} to be used to build the
* notification.
*
* <p>This method is called each time a new notification is built.
*
* <p>Override this method to customize the buttons on the notification. Commands of the buttons
* returned by this method must be contained in {@link MediaController#getAvailableCommands()}.
*
* <p>By default the notification shows {@link Player#COMMAND_PLAY_PAUSE} in {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}. This can be
* customized by defining the index of the command in compact view of up to 3 commands in their
* extras with key {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}.
*
* @param playerCommands The available player commands.
* @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of
* commands}.
* @param playWhenReady The current {@code playWhenReady} state.
* @return The ordered list of command buttons to be placed on the notification.
*/
protected List<CommandButton> getMediaButtons(
Player.Commands playerCommands, List<CommandButton> customLayout, boolean playWhenReady) {
// Skip to previous action.
List<CommandButton> commandButtons = new ArrayList<>();
if (playerCommands.containsAny(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.setIconResId(R.drawable.media3_notification_seek_to_previous)
.setDisplayName(
context.getString(R.string.media3_controls_seek_to_previous_description))
.setExtras(commandButtonExtras)
.build());
}
if (playerCommands.contains(COMMAND_PLAY_PAUSE)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_PLAY_PAUSE)
.setIconResId(
playWhenReady
? R.drawable.media3_notification_pause
: R.drawable.media3_notification_play)
.setExtras(commandButtonExtras)
.setDisplayName(
playWhenReady
? context.getString(R.string.media3_controls_pause_description)
: context.getString(R.string.media3_controls_play_description))
.build());
}
// Skip to next action.
if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) {
Bundle commandButtonExtras = new Bundle();
commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
commandButtons.add(
new CommandButton.Builder()
.setPlayerCommand(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.setIconResId(R.drawable.media3_notification_seek_to_next)
.setExtras(commandButtonExtras)
.setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
.build());
}
for (int i = 0; i < customLayout.size(); i++) {
CommandButton button = customLayout.get(i);
if (button.sessionCommand != null
&& button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) {
commandButtons.add(button);
}
}
return commandButtons;
}
/**
* Adds the media buttons to the notification builder for the given action factory.
*
* <p>The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons(
* Player.Commands, List, boolean)}.
*
* <p>Override this method to customize how the media buttons {@linkplain
* NotificationCompat.Builder#addAction(NotificationCompat.Action) are added} to the notification
* and define which actions are shown in compact view by returning the indices of the buttons to
* be shown in compact view.
*
* <p>By default {@link Player#COMMAND_PLAY_PAUSE} is shown in compact view, unless some of the
* buttons are marked with {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}
* to declare the index in compact view of the given command button in the button extras.
*
* @param mediaButtons The command buttons to be included in the notification.
* @param builder The builder to add the actions to.
* @param actionFactory The actions factory to be used to build notifications.
* @return The indices of the buttons to be {@linkplain
* Notification.MediaStyle#setShowActionsInCompactView(int...) used in compact view of the
* notification}.
*/
protected int[] addNotificationActions(
List<CommandButton> mediaButtons,
NotificationCompat.Builder builder,
MediaNotification.ActionFactory actionFactory) {
int[] compactViewIndices = new int[3];
Arrays.fill(compactViewIndices, INDEX_UNSET);
int compactViewCommandCount = 0;
for (int i = 0; i < mediaButtons.size(); i++) {
CommandButton commandButton = mediaButtons.get(i);
if (commandButton.sessionCommand != null) {
builder.addAction(actionFactory.createCustomActionFromCustomCommandButton(commandButton));
} else {
checkState(commandButton.playerCommand != COMMAND_INVALID);
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, commandButton.iconResId),
commandButton.displayName,
commandButton.playerCommand));
}
if (compactViewCommandCount == 3) {
continue;
}
int compactViewIndex =
commandButton.extras.getInt(
COMMAND_KEY_COMPACT_VIEW_INDEX, /* defaultValue= */ INDEX_UNSET);
if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.length) {
compactViewCommandCount++;
compactViewIndices[compactViewIndex] = i;
} else if (commandButton.playerCommand == COMMAND_PLAY_PAUSE
&& compactViewCommandCount == 0) {
// If there is no custom configuration we use the play/pause action in compact view.
compactViewIndices[0] = i;
}
}
for (int i = 0; i < compactViewIndices.length; i++) {
if (compactViewIndices[i] == INDEX_UNSET) {
compactViewIndices = Arrays.copyOf(compactViewIndices, i);
break;
}
}
return compactViewIndices;
}
private void ensureNotificationChannel() {

View File

@ -1077,7 +1077,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* positionDiscontinuity= */ false,
/* ignored= */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* mediaItemTransition= */ oldTimeline.isEmpty(),
MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED);
}
@ -1987,7 +1987,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* positionDiscontinuity= */ false,
/* ignored= */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* mediaItemTransition= */ false,
/* ignored */ MEDIA_ITEM_TRANSITION_REASON_REPEAT);
}
@ -2262,7 +2262,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
IMediaSession getSessionInterfaceWithSessionCommandIfAble(SessionCommand command) {
checkArgument(command.commandCode == COMMAND_CODE_CUSTOM);
if (!sessionCommands.contains(command)) {
Log.w(TAG, "Controller isn't allowed to call session command:" + command);
Log.w(TAG, "Controller isn't allowed to call custom session command:" + command.customAction);
return null;
}
return iSession;
@ -2578,11 +2578,21 @@ import org.checkerframework.checker.nullness.qual.NonNull;
if (!isConnected()) {
return;
}
List<CommandButton> validatedCustomLayout = new ArrayList<>();
for (int i = 0; i < layout.size(); i++) {
CommandButton button = layout.get(i);
if (intersectedPlayerCommands.contains(button.playerCommand)
|| (button.sessionCommand != null && sessionCommands.contains(button.sessionCommand))
|| (button.playerCommand != Player.COMMAND_INVALID
&& sessionCommands.contains(button.playerCommand))) {
validatedCustomLayout.add(button);
}
}
instance.notifyControllerListener(
listener -> {
ListenableFuture<SessionResult> future =
checkNotNull(
listener.onSetCustomLayout(instance, layout),
listener.onSetCustomLayout(instance, validatedCustomLayout),
"MediaController.Listener#onSetCustomLayout() must not return null");
sendControllerResultWhenReady(seq, future);
});

View File

@ -26,6 +26,8 @@ import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import com.google.common.collect.ImmutableList;
import java.util.List;
/** A notification for media playbacks. */
public final class MediaNotification {
@ -46,23 +48,40 @@ public final class MediaNotification {
* @param command A command to send when users trigger this action.
*/
NotificationCompat.Action createMediaAction(
IconCompat icon, CharSequence title, @Player.Command long command);
IconCompat icon, CharSequence title, @Player.Command int command);
/**
* Creates a {@link NotificationCompat.Action} for a notification with a custom action. Actions
* created with this method are not expected to be handled by the library and will be forwarded
* to the {@linkplain MediaNotification.Provider#handleCustomAction notification provider} that
* to the {@linkplain MediaNotification.Provider#handleCustomCommand notification provider} that
* provided them.
*
* @param icon The icon to show for this action.
* @param title The title of the action.
* @param customAction The custom action set.
* @param extras Extras to be included in the action.
* @see MediaNotification.Provider#handleCustomAction
* @see MediaNotification.Provider#handleCustomCommand
*/
NotificationCompat.Action createCustomAction(
IconCompat icon, CharSequence title, String customAction, Bundle extras);
/**
* Creates a {@link NotificationCompat.Action} for a notification from a custom command button.
* Actions created with this method are not expected to be handled by the library and will be
* forwarded to the {@linkplain MediaNotification.Provider#handleCustomCommand notification
* provider} that provided them.
*
* <p>The returned {@link NotificationCompat.Action} will have a {@link PendingIntent} with the
* extras from {@link SessionCommand#customExtras}. Accordingly the {@linkplain
* SessionCommand#customExtras command's extras} will be passed to {@link
* Provider#handleCustomCommand(MediaController, String, Bundle)} when the action is executed.
*
* @param customCommandButton A {@linkplain CommandButton custom command button}.
* @see MediaNotification.Provider#handleCustomCommand
*/
NotificationCompat.Action createCustomActionFromCustomCommandButton(
CommandButton customCommandButton);
/**
* Creates a {@link PendingIntent} for a media action that will be handled by the library.
*
@ -100,24 +119,28 @@ public final class MediaNotification {
* @param mediaController The controller of the session.
* @param actionFactory The {@link ActionFactory} for creating notification {@linkplain
* NotificationCompat.Action actions}.
* @param customLayout The custom layout {@linkplain MediaSession#setCustomLayout(List) set by
* the session}.
* @param onNotificationChangedCallback A callback that the provider needs to notify when the
* notification has changed and needs to be posted again, for example after a bitmap has
* been loaded asynchronously.
*/
MediaNotification createNotification(
MediaController mediaController,
ImmutableList<CommandButton> customLayout,
ActionFactory actionFactory,
Callback onNotificationChangedCallback);
/**
* Handles a notification's custom action.
* Handles a notification's custom command.
*
* @param mediaController The controller of the session.
* @param action The custom action.
* @param extras Extras set in the custom action, otherwise {@link Bundle#EMPTY}.
* @param action The custom command action.
* @param extras A bundle {@linkplain SessionCommand#customExtras set in the custom command},
* otherwise {@link Bundle#EMPTY}.
* @see ActionFactory#createCustomAction
*/
void handleCustomAction(MediaController mediaController, String action, Bundle extras);
void handleCustomCommand(MediaController mediaController, String action, Bundle extras);
}
/** The notification id. */

View File

@ -31,6 +31,7 @@ import androidx.core.content.ContextCompat;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.HashMap;
@ -57,6 +58,7 @@ import java.util.concurrent.TimeoutException;
private final Executor mainExecutor;
private final Intent startSelfIntent;
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap;
private final Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap;
private int totalNotificationCount;
@Nullable private MediaNotification mediaNotification;
@ -73,13 +75,16 @@ import java.util.concurrent.TimeoutException;
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
controllerMap = new HashMap<>();
customLayoutMap = new HashMap<>();
}
public void addSession(MediaSession session) {
if (controllerMap.containsKey(session)) {
return;
}
MediaControllerListener listener = new MediaControllerListener(mediaSessionService, session);
customLayoutMap.put(session, ImmutableList.of());
MediaControllerListener listener =
new MediaControllerListener(mediaSessionService, session, customLayoutMap);
ListenableFuture<MediaController> controllerFuture =
new MediaController.Builder(mediaSessionService, session.getToken())
.setListener(listener)
@ -104,6 +109,7 @@ import java.util.concurrent.TimeoutException;
}
public void removeSession(MediaSession session) {
customLayoutMap.remove(session);
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
if (controllerFuture != null) {
MediaController.releaseFuture(controllerFuture);
@ -117,7 +123,7 @@ import java.util.concurrent.TimeoutException;
}
try {
MediaController mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS);
mediaNotificationProvider.handleCustomAction(mediaController, action, extras);
mediaNotificationProvider.handleCustomCommand(mediaController, action, extras);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// We should never reach this.
throw new IllegalStateException(e);
@ -150,7 +156,11 @@ import java.util.concurrent.TimeoutException;
() -> onNotificationUpdated(notificationSequence, session, notification));
MediaNotification mediaNotification =
this.mediaNotificationProvider.createNotification(mediaController, actionFactory, callback);
this.mediaNotificationProvider.createNotification(
mediaController,
checkStateNotNull(customLayoutMap.get(session)),
actionFactory,
callback);
updateNotificationInternal(session, mediaNotification);
}
@ -229,10 +239,15 @@ import java.util.concurrent.TimeoutException;
implements MediaController.Listener, Player.Listener {
private final MediaSessionService mediaSessionService;
private final MediaSession session;
private final Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap;
public MediaControllerListener(MediaSessionService mediaSessionService, MediaSession session) {
public MediaControllerListener(
MediaSessionService mediaSessionService,
MediaSession session,
Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap) {
this.mediaSessionService = mediaSessionService;
this.session = session;
this.customLayoutMap = customLayoutMap;
}
public void onConnected() {
@ -242,6 +257,14 @@ import java.util.concurrent.TimeoutException;
}
}
@Override
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
customLayoutMap.put(session, ImmutableList.copyOf(layout));
mediaSessionService.onUpdateNotification(session);
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
@Override
public void onEvents(Player player, Player.Events events) {
// We must limit the frequency of notification updates, otherwise the system may suppress

View File

@ -631,9 +631,19 @@ public class MediaSession {
}
/**
* Sets the custom layout and broadcasts it to all connected controllers including the legacy
* Broadcasts the custom layout to all connected Media3 controllers and converts the buttons to
* custom actions in the legacy media session playback state (see {@code
* PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}) for legacy
* controllers.
*
* <p>When converting, the {@link SessionCommand#customExtras custom extras of the session
* command} is used for the extras of the legacy custom action.
*
* <p>Media3 controllers that connect after calling this method will not receive the broadcast.
* You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link
* MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers
* aware of the custom layout.
*
* @param layout The ordered list of {@link CommandButton}.
*/
public void setCustomLayout(List<CommandButton> layout) {

View File

@ -191,13 +191,14 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
@Override
public void onCustomAction(String action, @Nullable Bundle extras) {
Bundle args = extras == null ? Bundle.EMPTY : extras;
SessionCommand command = new SessionCommand(action, args);
public void onCustomAction(String action, @Nullable Bundle args) {
SessionCommand command = new SessionCommand(action, /* extras= */ Bundle.EMPTY);
dispatchSessionTaskWithSessionCommand(
command,
controller ->
ignoreFuture(sessionImpl.onCustomCommandOnHandler(controller, command, args)));
ignoreFuture(
sessionImpl.onCustomCommandOnHandler(
controller, command, args != null ? args : Bundle.EMPTY)));
}
@Override

View File

@ -20,9 +20,12 @@ import static org.robolectric.Shadows.shadowOf;
import android.app.PendingIntent;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.media3.common.Player;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
@ -64,6 +67,51 @@ public class DefaultActionFactoryTest {
assertThat(actionFactory.isCustomAction(intent)).isFalse();
}
@Test
public void createCustomActionFromCustomCommandButton() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
Bundle commandBundle = new Bundle();
commandBundle.putString("command-key", "command-value");
Bundle buttonBundle = new Bundle();
buttonBundle.putString("button-key", "button-value");
CommandButton customSessionCommand =
new CommandButton.Builder()
.setSessionCommand(new SessionCommand("a", commandBundle))
.setExtras(buttonBundle)
.setIconResId(R.drawable.media3_notification_pause)
.setDisplayName("name")
.build();
NotificationCompat.Action notificationAction =
actionFactory.createCustomActionFromCustomCommandButton(customSessionCommand);
assertThat(String.valueOf(notificationAction.title)).isEqualTo("name");
assertThat(notificationAction.getIconCompat().getResId())
.isEqualTo(R.drawable.media3_notification_pause);
assertThat(notificationAction.getExtras().size()).isEqualTo(0);
assertThat(notificationAction.getActionIntent()).isNotNull();
}
@Test
public void
createCustomActionFromCustomCommandButton_notACustomAction_throwsIllegalArgumentException() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
CommandButton customSessionCommand =
new CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setIconResId(R.drawable.media3_notification_pause)
.setDisplayName("name")
.build();
Assert.assertThrows(
IllegalArgumentException.class,
() -> actionFactory.createCustomActionFromCustomCommandButton(customSessionCommand));
}
/** A test service for unit tests. */
public static final class TestService extends MediaLibraryService {
@Nullable

View File

@ -0,0 +1,342 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.session;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.media3.common.Player;
import androidx.media3.common.Player.Commands;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.robolectric.Robolectric;
/** Tests for {@link DefaultMediaNotificationProvider}. */
@RunWith(AndroidJUnit4.class)
public class DefaultMediaNotificationProviderTest {
@Test
public void getMediaButtons_playWhenReadyTrueOrFalse_correctPlayPauseResources() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
Commands commands = new Commands.Builder().addAllCommands().build();
List<CommandButton> mediaButtonsWhenPlaying =
defaultMediaNotificationProvider.getMediaButtons(
commands, /* customLayout= */ ImmutableList.of(), /* playWhenReady= */ true);
List<CommandButton> mediaButtonWhenPaused =
defaultMediaNotificationProvider.getMediaButtons(
commands, /* customLayout= */ ImmutableList.of(), /* playWhenReady= */ false);
assertThat(mediaButtonsWhenPlaying).hasSize(3);
assertThat(mediaButtonsWhenPlaying.get(1).playerCommand).isEqualTo(Player.COMMAND_PLAY_PAUSE);
assertThat(mediaButtonsWhenPlaying.get(1).iconResId)
.isEqualTo(R.drawable.media3_notification_pause);
assertThat(String.valueOf(mediaButtonsWhenPlaying.get(1).displayName)).isEqualTo("Pause");
assertThat(mediaButtonWhenPaused).hasSize(3);
assertThat(mediaButtonWhenPaused.get(1).playerCommand).isEqualTo(Player.COMMAND_PLAY_PAUSE);
assertThat(mediaButtonWhenPaused.get(1).iconResId)
.isEqualTo(R.drawable.media3_notification_play);
assertThat(String.valueOf(mediaButtonWhenPaused.get(1).displayName)).isEqualTo("Play");
}
@Test
public void getMediaButtons_allCommandsAvailable_createsPauseSkipNextSkipPreviousButtons() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
Commands commands = new Commands.Builder().addAllCommands().build();
SessionCommand customSessionCommand = new SessionCommand("", Bundle.EMPTY);
CommandButton customCommandButton =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(customSessionCommand)
.build();
List<CommandButton> mediaButtons =
defaultMediaNotificationProvider.getMediaButtons(
commands, ImmutableList.of(customCommandButton), /* playWhenReady= */ true);
assertThat(mediaButtons).hasSize(4);
assertThat(mediaButtons.get(0).playerCommand)
.isEqualTo(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM);
assertThat(mediaButtons.get(1).playerCommand).isEqualTo(Player.COMMAND_PLAY_PAUSE);
assertThat(mediaButtons.get(2).playerCommand).isEqualTo(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM);
assertThat(mediaButtons.get(3)).isEqualTo(customCommandButton);
}
@Test
public void getMediaButtons_noPlayerCommandsAvailable_onlyCustomLayoutButtons() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
Commands commands = new Commands.Builder().build();
SessionCommand customSessionCommand = new SessionCommand("action1", Bundle.EMPTY);
CommandButton customCommandButton =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(customSessionCommand)
.build();
List<CommandButton> mediaButtons =
defaultMediaNotificationProvider.getMediaButtons(
commands, ImmutableList.of(customCommandButton), /* playWhenReady= */ true);
assertThat(mediaButtons).containsExactly(customCommandButton);
}
@Test
public void addNotificationActions_customCompactViewDeclarations_correctCompactViewIndices() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
CommandButton commandButton1 =
new CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.build();
Bundle commandButton2Bundle = new Bundle();
commandButton2Bundle.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 0);
CommandButton commandButton2 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action2", Bundle.EMPTY))
.setExtras(commandButton2Bundle)
.build();
Bundle commandButton3Bundle = new Bundle();
commandButton3Bundle.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2);
CommandButton commandButton3 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action3", Bundle.EMPTY))
.setExtras(commandButton3Bundle)
.build();
Bundle commandButton4Bundle = new Bundle();
commandButton4Bundle.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1);
CommandButton commandButton4 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action4", Bundle.EMPTY))
.setExtras(commandButton4Bundle)
.build();
int[] compactViewIndices =
defaultMediaNotificationProvider.addNotificationActions(
ImmutableList.of(commandButton1, commandButton2, commandButton3, commandButton4),
mockNotificationBuilder,
mockActionFactory);
verify(mockNotificationBuilder, times(4)).addAction(any());
InOrder inOrder = Mockito.inOrder(mockActionFactory);
inOrder
.verify(mockActionFactory)
.createMediaAction(any(), eq("displayName"), eq(commandButton1.playerCommand));
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton2);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton3);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton4);
verifyNoMoreInteractions(mockActionFactory);
assertThat(compactViewIndices).asList().containsExactly(1, 3, 2).inOrder();
}
@Test
public void addNotificationActions_playPauseCommandNoCustomDeclaration_playPauseInCompactView() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
CommandButton commandButton1 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action1", Bundle.EMPTY))
.build();
CommandButton commandButton2 =
new CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.build();
int[] compactViewIndices =
defaultMediaNotificationProvider.addNotificationActions(
ImmutableList.of(commandButton1, commandButton2),
mockNotificationBuilder,
mockActionFactory);
ArgumentCaptor<NotificationCompat.Action> actionCaptor =
ArgumentCaptor.forClass(NotificationCompat.Action.class);
verify(mockNotificationBuilder, times(2)).addAction(actionCaptor.capture());
List<NotificationCompat.Action> actions = actionCaptor.getAllValues();
assertThat(actions).hasSize(2);
InOrder inOrder = Mockito.inOrder(mockActionFactory);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton1);
inOrder
.verify(mockActionFactory)
.createMediaAction(any(), eq("displayName"), eq(commandButton2.playerCommand));
verifyNoMoreInteractions(mockActionFactory);
assertThat(compactViewIndices).asList().containsExactly(1);
}
@Test
public void
addNotificationActions_noPlayPauseCommandNoCustomDeclaration_emptyCompactViewIndices() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
CommandButton commandButton1 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action1", Bundle.EMPTY))
.build();
int[] compactViewIndices =
defaultMediaNotificationProvider.addNotificationActions(
ImmutableList.of(commandButton1), mockNotificationBuilder, mockActionFactory);
InOrder inOrder = Mockito.inOrder(mockActionFactory);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton1);
verifyNoMoreInteractions(mockActionFactory);
assertThat(compactViewIndices).asList().isEmpty();
}
@Test
public void addNotificationActions_outOfBoundsCompactViewIndices_ignored() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
Bundle commandButtonBundle1 = new Bundle();
commandButtonBundle1.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2);
CommandButton commandButton1 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action1", Bundle.EMPTY))
.setExtras(commandButtonBundle1)
.build();
Bundle commandButtonBundle2 = new Bundle();
commandButtonBundle2.putInt(
DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, -1);
CommandButton commandButton2 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action1", Bundle.EMPTY))
.setExtras(commandButtonBundle2)
.build();
int[] compactViewIndices =
defaultMediaNotificationProvider.addNotificationActions(
ImmutableList.of(commandButton1, commandButton2),
mockNotificationBuilder,
mockActionFactory);
InOrder inOrder = Mockito.inOrder(mockActionFactory);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton1);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton2);
verifyNoMoreInteractions(mockActionFactory);
assertThat(compactViewIndices).asList().isEmpty();
}
@Test
public void addNotificationActions_unsetLeadingArrayFields_cropped() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
MediaNotification.ActionFactory mockActionFactory = mock(MediaNotification.ActionFactory.class);
Bundle commandButtonBundle = new Bundle();
commandButtonBundle.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1);
CommandButton commandButton1 =
new CommandButton.Builder()
.setDisplayName("displayName")
.setIconResId(R.drawable.media3_icon_circular_play)
.setSessionCommand(new SessionCommand("action1", Bundle.EMPTY))
.setExtras(commandButtonBundle)
.build();
int[] compactViewIndices =
defaultMediaNotificationProvider.addNotificationActions(
ImmutableList.of(commandButton1), mockNotificationBuilder, mockActionFactory);
InOrder inOrder = Mockito.inOrder(mockActionFactory);
inOrder.verify(mockActionFactory).createCustomActionFromCustomCommandButton(commandButton1);
verifyNoMoreInteractions(mockActionFactory);
// [INDEX_UNSET, 1, INDEX_UNSET] cropped up to the first INDEX_UNSET value
assertThat(compactViewIndices).asList().isEmpty();
}
@Test
public void addNotificationActions_correctNotificationActionAttributes() {
DefaultMediaNotificationProvider defaultMediaNotificationProvider =
new DefaultMediaNotificationProvider(ApplicationProvider.getApplicationContext());
NotificationCompat.Builder mockNotificationBuilder = mock(NotificationCompat.Builder.class);
DefaultActionFactory defaultActionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
Bundle commandButtonBundle = new Bundle();
commandButtonBundle.putString("testKey", "testValue");
CommandButton commandButton1 =
new CommandButton.Builder()
.setDisplayName("displayName1")
.setIconResId(R.drawable.media3_notification_play)
.setSessionCommand(new SessionCommand("action1", Bundle.EMPTY))
.setExtras(commandButtonBundle)
.build();
defaultMediaNotificationProvider.addNotificationActions(
ImmutableList.of(commandButton1), mockNotificationBuilder, defaultActionFactory);
ArgumentCaptor<NotificationCompat.Action> actionCaptor =
ArgumentCaptor.forClass(NotificationCompat.Action.class);
verify(mockNotificationBuilder).addAction(actionCaptor.capture());
verifyNoMoreInteractions(mockNotificationBuilder);
List<NotificationCompat.Action> actions = actionCaptor.getAllValues();
assertThat(actions).hasSize(1);
assertThat(String.valueOf(actions.get(0).title)).isEqualTo("displayName1");
assertThat(actions.get(0).getIconCompat().getResId()).isEqualTo(commandButton1.iconResId);
assertThat(actions.get(0).getExtras().size()).isEqualTo(0);
}
/** A test service for unit tests. */
private static final class TestService extends MediaLibraryService {
@Nullable
@Override
public MediaLibrarySession onGetSession(MediaSession.ControllerInfo controllerInfo) {
return null;
}
}
}