diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java index 06fa56b04c..cf63e6cc4b 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultActionFactory.java @@ -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}. */ diff --git a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java index 61b4a3d4af..e452bead1f 100644 --- a/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java +++ b/libraries/session/src/main/java/androidx/media3/session/DefaultMediaNotificationProvider.java @@ -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; * */ @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 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 future = + mediaController.sendCustomCommand(customCommand, extras); + Futures.addCallback( + future, + new FutureCallback() { + @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. + * + *

This method is called each time a new notification is built. + * + *

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

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 getMediaButtons( + Player.Commands playerCommands, List customLayout, boolean playWhenReady) { + // Skip to previous action. + List 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. + * + *

The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons( + * Player.Commands, List, boolean)}. + * + *

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

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 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() { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java index a0c55ff9a8..e9d9b5a420 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -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 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 future = checkNotNull( - listener.onSetCustomLayout(instance, layout), + listener.onSetCustomLayout(instance, validatedCustomLayout), "MediaController.Listener#onSetCustomLayout() must not return null"); sendControllerResultWhenReady(seq, future); }); diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java index 140d5b10af..0632c80c48 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotification.java @@ -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. + * + *

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 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. */ diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java index 75c168f575..001ef42cbb 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaNotificationManager.java @@ -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> controllerMap; + private final Map> 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 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 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> customLayoutMap; - public MediaControllerListener(MediaSessionService mediaSessionService, MediaSession session) { + public MediaControllerListener( + MediaSessionService mediaSessionService, + MediaSession session, + Map> 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 onSetCustomLayout( + MediaController controller, List 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 diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java index 71f707c494..db269b2689 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java @@ -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. * + *

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

Media3 controllers that connect after calling this method will not receive the broadcast. + * You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link + * MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers + * aware of the custom layout. + * * @param layout The ordered list of {@link CommandButton}. */ public void setCustomLayout(List layout) { diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java index 8b92613b67..02300c7ba1 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java @@ -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 diff --git a/libraries/session/src/test/java/androidx/media3/session/DefaultActionFactoryTest.java b/libraries/session/src/test/java/androidx/media3/session/DefaultActionFactoryTest.java index 52a11687bf..fc7f5b1402 100644 --- a/libraries/session/src/test/java/androidx/media3/session/DefaultActionFactoryTest.java +++ b/libraries/session/src/test/java/androidx/media3/session/DefaultActionFactoryTest.java @@ -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 diff --git a/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java new file mode 100644 index 0000000000..de56133863 --- /dev/null +++ b/libraries/session/src/test/java/androidx/media3/session/DefaultMediaNotificationProviderTest.java @@ -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 mediaButtonsWhenPlaying = + defaultMediaNotificationProvider.getMediaButtons( + commands, /* customLayout= */ ImmutableList.of(), /* playWhenReady= */ true); + List 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 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 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 actionCaptor = + ArgumentCaptor.forClass(NotificationCompat.Action.class); + verify(mockNotificationBuilder, times(2)).addAction(actionCaptor.capture()); + List 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 actionCaptor = + ArgumentCaptor.forClass(NotificationCompat.Action.class); + verify(mockNotificationBuilder).addAction(actionCaptor.capture()); + verifyNoMoreInteractions(mockNotificationBuilder); + List 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; + } + } +}