Resolve and dispatch media button events within Media3

Before this change, media button events are routed from `onStartCommand`
of the `MediaSessionService` to  the `MediaSessionCompat`, resolved by
the legacy library to a session command called on
`MediaSessionCompat.Callback` from where the command is delegated back
to the Media3 session.

With this change the keycode is resolved directly to a Media3 command
that is sent to the session through the media notification controller
of the session.

After this change, a playback or custom command sent to the session
from a notification, either as a pending intent (before API 33) or as
a legacy session command, look the same and the caller is the
media notification controller on all API levels.

PiperOrigin-RevId: 568224123
This commit is contained in:
bachinger 2023-09-25 08:16:45 -07:00 committed by Copybara-Service
parent 5e05e2ec22
commit ffd7bb5639
7 changed files with 403 additions and 44 deletions

View File

@ -70,6 +70,12 @@
* Use the media notification controller as proxy to set available commands * Use the media notification controller as proxy to set available commands
and custom layout used to populate the notification and the platform and custom layout used to populate the notification and the platform
session. session.
* Convert media button events that are received by
`MediaSessionService.onStartCommand()` within Media3 instead of routing
them to the platform session and back to Media3. With this, the caller
controller is always the media notification controller and apps can
easily recognize calls coming from the notification in the same way on
all supported API levels.
* UI: * UI:
* Downloads: * Downloads:
* OkHttp Extension: * OkHttp Extension:

View File

@ -388,6 +388,11 @@ import org.checkerframework.checker.nullness.qual.NonNull;
@Override @Override
public void play() { public void play() {
if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) {
Log.w(
TAG,
"Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this play"
+ " command has started the service for instance for playback resumption, this may"
+ " prevent the service from being started into the foreground.");
return; return;
} }
@ -522,6 +527,13 @@ import org.checkerframework.checker.nullness.qual.NonNull;
@Override @Override
public void setPlayWhenReady(boolean playWhenReady) { public void setPlayWhenReady(boolean playWhenReady) {
if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) { if (!isPlayerCommandAvailable(Player.COMMAND_PLAY_PAUSE)) {
if (playWhenReady) {
Log.w(
TAG,
"Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this play"
+ " command has started the service for instance for playback resumption, this may"
+ " prevent the service from being started into the foreground.");
}
return; return;
} }

View File

@ -17,6 +17,15 @@ package androidx.media3.session;
import static android.app.Service.STOP_FOREGROUND_DETACH; import static android.app.Service.STOP_FOREGROUND_DETACH;
import static android.app.Service.STOP_FOREGROUND_REMOVE; import static android.app.Service.STOP_FOREGROUND_REMOVE;
import static android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD;
import static android.view.KeyEvent.KEYCODE_MEDIA_NEXT;
import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE;
import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY;
import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE;
import static android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS;
import static android.view.KeyEvent.KEYCODE_MEDIA_REWIND;
import static android.view.KeyEvent.KEYCODE_MEDIA_STOP;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Notification; import android.app.Notification;
@ -25,6 +34,7 @@ import android.content.pm.ServiceInfo;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.view.KeyEvent;
import androidx.annotation.DoNotInline; import androidx.annotation.DoNotInline;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
@ -44,7 +54,6 @@ import java.util.Map;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
/** /**
@ -65,7 +74,7 @@ import java.util.concurrent.TimeoutException;
private final NotificationManagerCompat notificationManagerCompat; private final NotificationManagerCompat notificationManagerCompat;
private final Executor mainExecutor; private final Executor mainExecutor;
private final Intent startSelfIntent; private final Intent startSelfIntent;
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap; private final Map<MediaSession, ControllerAndListener> controllerAndListenerMap;
private int totalNotificationCount; private int totalNotificationCount;
@Nullable private MediaNotification mediaNotification; @Nullable private MediaNotification mediaNotification;
@ -82,30 +91,34 @@ import java.util.concurrent.TimeoutException;
Handler mainHandler = new Handler(Looper.getMainLooper()); Handler mainHandler = new Handler(Looper.getMainLooper());
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
controllerMap = new HashMap<>(); controllerAndListenerMap = new HashMap<>();
startedInForeground = false; startedInForeground = false;
} }
public void addSession(MediaSession session) { public void addSession(MediaSession session) {
if (controllerMap.containsKey(session)) { if (controllerAndListenerMap.containsKey(session)) {
return; return;
} }
MediaControllerListener listener = new MediaControllerListener(mediaSessionService, session); MediaControllerListener controllerListener =
new MediaControllerListener(mediaSessionService, session);
PlayerListener playerListener = new PlayerListener(mediaSessionService, session);
Bundle connectionHints = new Bundle(); Bundle connectionHints = new Bundle();
connectionHints.putBoolean(KEY_MEDIA_NOTIFICATION_MANAGER, true); connectionHints.putBoolean(KEY_MEDIA_NOTIFICATION_MANAGER, true);
ListenableFuture<MediaController> controllerFuture = ListenableFuture<MediaController> controllerFuture =
new MediaController.Builder(mediaSessionService, session.getToken()) new MediaController.Builder(mediaSessionService, session.getToken())
.setConnectionHints(connectionHints) .setConnectionHints(connectionHints)
.setListener(listener) .setListener(controllerListener)
.setApplicationLooper(Looper.getMainLooper()) .setApplicationLooper(Looper.getMainLooper())
.buildAsync(); .buildAsync();
controllerMap.put(session, controllerFuture); controllerAndListenerMap.put(
session, new ControllerAndListener(controllerFuture, playerListener));
controllerFuture.addListener( controllerFuture.addListener(
() -> { () -> {
try { try {
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); // Assert connection success.
listener.onConnected(shouldShowNotification(session)); controllerFuture.get(/* time= */ 0, MILLISECONDS);
controller.addListener(listener); controllerListener.onConnected(shouldShowNotification(session));
session.getImpl().addPlayerListener(playerListener);
} catch (CancellationException } catch (CancellationException
| ExecutionException | ExecutionException
| InterruptedException | InterruptedException
@ -118,9 +131,52 @@ import java.util.concurrent.TimeoutException;
} }
public void removeSession(MediaSession session) { public void removeSession(MediaSession session) {
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session); ControllerAndListener controllerAndListener = controllerAndListenerMap.remove(session);
if (controllerFuture != null) { if (controllerAndListener != null) {
MediaController.releaseFuture(controllerFuture); session.getImpl().removePlayerListener(controllerAndListener.listener);
MediaController.releaseFuture(controllerAndListener.controller);
}
}
public void onMediaButtonEvent(MediaSession session, KeyEvent keyEvent) {
int keyCode = keyEvent.getKeyCode();
@Nullable MediaController mediaController = getConnectedControllerForSession(session);
if (mediaController == null) {
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
return;
}
switch (keyCode) {
case KEYCODE_MEDIA_PLAY_PAUSE:
if (mediaController.getPlayWhenReady()) {
mediaController.pause();
} else {
mediaController.play();
}
break;
case KEYCODE_MEDIA_PLAY:
mediaController.play();
break;
case KEYCODE_MEDIA_PAUSE:
mediaController.pause();
break;
case KEYCODE_MEDIA_NEXT:
mediaController.seekToNext();
break;
case KEYCODE_MEDIA_PREVIOUS:
mediaController.seekToPrevious();
break;
case KEYCODE_MEDIA_FAST_FORWARD:
mediaController.seekForward();
break;
case KEYCODE_MEDIA_REWIND:
mediaController.seekBack();
break;
case KEYCODE_MEDIA_STOP:
mediaController.stop();
break;
default:
Log.w(TAG, "Received media button event with unsupported key code: " + keyCode);
break;
} }
} }
@ -135,7 +191,7 @@ import java.util.concurrent.TimeoutException;
() -> { () -> {
if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
mainExecutor.execute( mainExecutor.execute(
() -> sendCustomCommandIfCommandIsAvailable(mediaController, action)); () -> sendCustomCommandIfCommandIsAvailable(mediaController, action, extras));
} }
}); });
} }
@ -154,10 +210,10 @@ import java.util.concurrent.TimeoutException;
int notificationSequence = ++totalNotificationCount; int notificationSequence = ++totalNotificationCount;
MediaController mediaNotificationController = null; MediaController mediaNotificationController = null;
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session); ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session);
if (controllerFuture != null && controllerFuture.isDone()) { if (controllerAndListener != null && controllerAndListener.controller.isDone()) {
try { try {
mediaNotificationController = Futures.getDone(controllerFuture); mediaNotificationController = Futures.getDone(controllerAndListener.controller);
} catch (ExecutionException e) { } catch (ExecutionException e) {
// Ignore. // Ignore.
} }
@ -261,12 +317,12 @@ import java.util.concurrent.TimeoutException;
@Nullable @Nullable
private MediaController getConnectedControllerForSession(MediaSession session) { private MediaController getConnectedControllerForSession(MediaSession session) {
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session); ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session);
if (controllerFuture == null) { if (controllerAndListener == null) {
return null; return null;
} }
try { try {
return Futures.getDone(controllerFuture); return Futures.getDone(controllerAndListener.controller);
} catch (ExecutionException exception) { } catch (ExecutionException exception) {
// We should never reach this. // We should never reach this.
throw new IllegalStateException(exception); throw new IllegalStateException(exception);
@ -274,7 +330,7 @@ import java.util.concurrent.TimeoutException;
} }
private void sendCustomCommandIfCommandIsAvailable( private void sendCustomCommandIfCommandIsAvailable(
MediaController mediaController, String action) { MediaController mediaController, String action, Bundle extras) {
@Nullable SessionCommand customCommand = null; @Nullable SessionCommand customCommand = null;
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) {
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
@ -286,7 +342,8 @@ import java.util.concurrent.TimeoutException;
if (customCommand != null if (customCommand != null
&& mediaController.getAvailableSessionCommands().contains(customCommand)) { && mediaController.getAvailableSessionCommands().contains(customCommand)) {
ListenableFuture<SessionResult> future = ListenableFuture<SessionResult> future =
mediaController.sendCustomCommand(customCommand, Bundle.EMPTY); mediaController.sendCustomCommand(
new SessionCommand(action, extras), /* args= */ Bundle.EMPTY);
Futures.addCallback( Futures.addCallback(
future, future,
new FutureCallback<SessionResult>() { new FutureCallback<SessionResult>() {
@ -304,8 +361,7 @@ import java.util.concurrent.TimeoutException;
} }
} }
private static final class MediaControllerListener private static final class MediaControllerListener implements MediaController.Listener {
implements MediaController.Listener, Player.Listener {
private final MediaSessionService mediaSessionService; private final MediaSessionService mediaSessionService;
private final MediaSession session; private final MediaSession session;
@ -334,6 +390,26 @@ import java.util.concurrent.TimeoutException;
session, /* startInForegroundWhenPaused= */ false); session, /* startInForegroundWhenPaused= */ false);
} }
@Override
public void onDisconnected(MediaController controller) {
mediaSessionService.removeSession(session);
// We may need to hide the notification.
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
}
private static class PlayerListener implements Player.Listener {
private final MediaSessionService mediaSessionService;
private final MediaSession session;
private final Handler mainHandler;
public PlayerListener(MediaSessionService mediaSessionService, MediaSession session) {
this.mediaSessionService = mediaSessionService;
this.session = session;
mainHandler = new Handler(Looper.getMainLooper());
}
@Override @Override
public void onEvents(Player player, Player.Events events) { public void onEvents(Player player, Player.Events events) {
// We must limit the frequency of notification updates, otherwise the system may suppress // We must limit the frequency of notification updates, otherwise the system may suppress
@ -343,18 +419,15 @@ import java.util.concurrent.TimeoutException;
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED,
Player.EVENT_MEDIA_METADATA_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED,
Player.EVENT_TIMELINE_CHANGED)) { Player.EVENT_TIMELINE_CHANGED)) {
// onUpdateNotificationInternal is required to be called on the main thread and the
// application thread of the player may be a different thread.
Util.postOrRun(
mainHandler,
() ->
mediaSessionService.onUpdateNotificationInternal( mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false); session, /* startInForegroundWhenPaused= */ false));
} }
} }
@Override
public void onDisconnected(MediaController controller) {
mediaSessionService.removeSession(session);
// We may need to hide the notification.
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
} }
@SuppressLint("InlinedApi") // Using compile time constant FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK @SuppressLint("InlinedApi") // Using compile time constant FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
@ -382,6 +455,17 @@ import java.util.concurrent.TimeoutException;
startedInForeground = false; startedInForeground = false;
} }
private static class ControllerAndListener {
public final ListenableFuture<MediaController> controller;
public final Player.Listener listener;
private ControllerAndListener(
ListenableFuture<MediaController> controller, Player.Listener listener) {
this.controller = controller;
this.listener = listener;
}
}
@RequiresApi(24) @RequiresApi(24)
private static class Api24 { private static class Api24 {

View File

@ -129,6 +129,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private boolean closed; private boolean closed;
// Should be only accessed on the application looper // Should be only accessed on the application looper
private final List<Player.Listener> wrapperListeners;
private long sessionPositionUpdateDelayMs; private long sessionPositionUpdateDelayMs;
private boolean isMediaNotificationControllerConnected; private boolean isMediaNotificationControllerConnected;
private ImmutableList<CommandButton> customLayout; private ImmutableList<CommandButton> customLayout;
@ -154,6 +155,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
sessionStub = new MediaSessionStub(thisRef); sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity; this.sessionActivity = sessionActivity;
this.customLayout = customLayout; this.customLayout = customLayout;
wrapperListeners = new ArrayList<>();
mainHandler = new Handler(Looper.getMainLooper()); mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper()); applicationHandler = new Handler(player.getApplicationLooper());
@ -231,14 +233,38 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
playerWrapper.getAvailablePlayerCommands())); playerWrapper.getAvailablePlayerCommands()));
} }
public void addPlayerListener(Player.Listener listener) {
postOrRun(
applicationHandler,
() -> {
wrapperListeners.add(listener);
playerWrapper.addListener(listener);
});
}
public void removePlayerListener(Player.Listener listener) {
postOrRun(
applicationHandler,
() -> {
playerWrapper.removeListener(listener);
wrapperListeners.remove(listener);
});
}
private void setPlayerInternal( private void setPlayerInternal(
@Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) { @Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) {
playerWrapper = newPlayerWrapper; playerWrapper = newPlayerWrapper;
if (oldPlayerWrapper != null) { if (oldPlayerWrapper != null) {
oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener)); oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener));
for (int i = 0; i < wrapperListeners.size(); i++) {
oldPlayerWrapper.removeListener(wrapperListeners.get(i));
}
} }
PlayerListener playerListener = new PlayerListener(this, newPlayerWrapper); PlayerListener playerListener = new PlayerListener(this, newPlayerWrapper);
newPlayerWrapper.addListener(playerListener); newPlayerWrapper.addListener(playerListener);
for (int i = 0; i < wrapperListeners.size(); i++) {
newPlayerWrapper.addListener(wrapperListeners.get(i));
}
this.playerListener = playerListener; this.playerListener = playerListener;
dispatchRemoteControllerTaskToLegacyStub( dispatchRemoteControllerTaskToLegacyStub(
@ -270,6 +296,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
if (playerListener != null) { if (playerListener != null) {
playerWrapper.removeListener(playerListener); playerWrapper.removeListener(playerListener);
} }
for (int i = 0; i < wrapperListeners.size(); i++) {
playerWrapper.removeListener(wrapperListeners.get(i));
}
wrapperListeners.clear();
}); });
} catch (Exception e) { } catch (Exception e) {
// Catch all exceptions to ensure the rest of this method to be executed as exceptions may be // Catch all exceptions to ensure the rest of this method to be executed as exceptions may be

View File

@ -690,6 +690,15 @@ import org.checkerframework.checker.initialization.qual.Initialized;
return; return;
} }
if (!connectedControllersManager.isPlayerCommandAvailable(controller, command)) { if (!connectedControllersManager.isPlayerCommandAvailable(controller, command)) {
if (command == COMMAND_PLAY_PAUSE
&& !sessionImpl.getPlayerWrapper().getPlayWhenReady()) {
Log.w(
TAG,
"Calling play() omitted due to COMMAND_PLAY_PAUSE not being available. If this"
+ " play command has started the service for instance for playback"
+ " resumption, this may prevent the service from being started into the"
+ " foreground.");
}
return; return;
} }
int resultCode = sessionImpl.onPlayerCommandRequestOnHandler(controller, command); int resultCode = sessionImpl.onPlayerCommandRequestOnHandler(controller, command);

View File

@ -428,7 +428,7 @@ public abstract class MediaSessionService extends Service {
} }
@Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent); @Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent);
if (keyEvent != null) { if (keyEvent != null) {
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); getMediaNotificationManager().onMediaButtonEvent(session, keyEvent);
} }
} else if (session != null && actionFactory.isCustomAction(intent)) { } else if (session != null && actionFactory.isCustomAction(intent)) {
@Nullable String customAction = actionFactory.getCustomAction(intent); @Nullable String customAction = actionFactory.getCustomAction(intent);

View File

@ -17,22 +17,33 @@ package androidx.media3.session;
import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil; import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.app.NotificationManager; import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.HandlerThread; import android.os.HandlerThread;
import android.service.notification.StatusBarNotification; import android.service.notification.StatusBarNotification;
import android.view.KeyEvent;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.ForwardingPlayer;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.Player;
import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.test.utils.TestExoPlayerBuilder; import androidx.media3.test.utils.TestExoPlayerBuilder;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import org.junit.After; import java.util.concurrent.atomic.AtomicReference;
import org.junit.Before; import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@ -43,21 +54,16 @@ import org.robolectric.shadows.ShadowLooper;
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public class MediaSessionServiceTest { public class MediaSessionServiceTest {
private static final int TIMEOUT_MS = 500;
private Context context; private Context context;
private NotificationManager notificationManager; private NotificationManager notificationManager;
private ServiceController<TestService> serviceController;
@Before @Before
public void setUp() { public void setUp() {
context = ApplicationProvider.getApplicationContext(); context = ApplicationProvider.getApplicationContext();
notificationManager = notificationManager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
serviceController = Robolectric.buildService(TestService.class);
}
@After
public void tearDown() {
serviceController.destroy();
} }
@Test @Test
@ -66,6 +72,7 @@ public class MediaSessionServiceTest {
ExoPlayer player2 = new TestExoPlayerBuilder(context).build(); ExoPlayer player2 = new TestExoPlayerBuilder(context).build();
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build(); MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build(); MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get(); TestService service = serviceController.create().get();
service.setMediaNotificationProvider( service.setMediaNotificationProvider(
new DefaultMediaNotificationProvider( new DefaultMediaNotificationProvider(
@ -92,6 +99,7 @@ public class MediaSessionServiceTest {
session2.release(); session2.release();
player1.release(); player1.release();
player2.release(); player2.release();
serviceController.destroy();
} }
@Test @Test
@ -105,6 +113,7 @@ public class MediaSessionServiceTest {
ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build(); ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build();
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build(); MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build(); MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get(); TestService service = serviceController.create().get();
service.setMediaNotificationProvider( service.setMediaNotificationProvider(
new DefaultMediaNotificationProvider( new DefaultMediaNotificationProvider(
@ -112,7 +121,6 @@ public class MediaSessionServiceTest {
session -> 2000 + Integer.parseInt(session.getId()), session -> 2000 + Integer.parseInt(session.getId()),
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID, DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID,
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID)); DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID));
service.addSession(session1); service.addSession(session1);
service.addSession(session2); service.addSession(session2);
// Start the players so that we also create notifications for them. // Start the players so that we also create notifications for them.
@ -141,6 +149,7 @@ public class MediaSessionServiceTest {
new Handler(thread2.getLooper()).post(player2::release); new Handler(thread2.getLooper()).post(player2::release);
thread1.quit(); thread1.quit();
thread2.quit(); thread2.quit();
serviceController.destroy();
} }
@Test @Test
@ -183,6 +192,7 @@ public class MediaSessionServiceTest {
} }
}) })
.build(); .build();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get(); TestService service = serviceController.create().get();
service.setMediaNotificationProvider( service.setMediaNotificationProvider(
new DefaultMediaNotificationProvider( new DefaultMediaNotificationProvider(
@ -229,6 +239,7 @@ public class MediaSessionServiceTest {
.isEqualTo("customAction2"); .isEqualTo("customAction2");
session.release(); session.release();
player.release(); player.release();
serviceController.destroy();
} }
@Test @Test
@ -272,6 +283,7 @@ public class MediaSessionServiceTest {
} }
}) })
.build(); .build();
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
TestService service = serviceController.create().get(); TestService service = serviceController.create().get();
service.setMediaNotificationProvider( service.setMediaNotificationProvider(
new DefaultMediaNotificationProvider( new DefaultMediaNotificationProvider(
@ -317,6 +329,156 @@ public class MediaSessionServiceTest {
session.release(); session.release();
player.release(); player.release();
serviceController.destroy();
}
@Test
public void onStartCommand_mediaButtonEvent_pausedByMediaNotificationController()
throws InterruptedException {
ExoPlayer player = new TestExoPlayerBuilder(context).build();
AtomicReference<MediaSession> session = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
ForwardingPlayer forwardingPlayer =
new ForwardingPlayer(player) {
@Override
public void pause() {
super.pause();
if (session
.get()
.isMediaNotificationController(session.get().getControllerForCurrentRequest())) {
latch.countDown();
}
}
};
session.set(new MediaSession.Builder(context, forwardingPlayer).build());
Intent playIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
playIntent.setData(session.get().getUri());
playIntent.putExtra(
Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE));
ServiceController<TestService> serviceController =
Robolectric.buildService(TestService.class, playIntent);
TestService service = serviceController.create().get();
service.addSession(session.get());
player.setMediaItems(ImmutableList.of(MediaItem.fromUri("asset:///media/mp4/sample.mp4")));
player.play();
player.prepare();
serviceController.startCommand(/* flags= */ 0, /* startId= */ 0);
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
assertThat(player.getPlayWhenReady()).isFalse();
session.get().release();
player.release();
serviceController.destroy();
}
@Test
public void onStartCommand_playbackResumption_calledByMediaNotificationController()
throws InterruptedException, ExecutionException, TimeoutException {
Intent playIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
playIntent.putExtra(
Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY));
ServiceController<TestServiceWithPlaybackResumption> serviceController =
Robolectric.buildService(TestServiceWithPlaybackResumption.class, playIntent);
TestServiceWithPlaybackResumption service = serviceController.create().get();
service.setMediaItems(
ImmutableList.of(
new MediaItem.Builder()
.setMediaId("media-id-0")
.setUri("asset:///media/mp4/sample.mp4")
.build()));
MediaController controller =
new MediaController.Builder(context, service.session.getToken())
.buildAsync()
.get(TIMEOUT_MS, MILLISECONDS);
CountDownLatch latch = new CountDownLatch(1);
controller.addListener(
new Player.Listener() {
@Override
public void onEvents(Player player, Player.Events events) {
if (events.contains(Player.EVENT_TIMELINE_CHANGED)
&& player.getMediaItemCount() == 1
&& player.getCurrentMediaItem().mediaId.equals("media-id-0")
&& events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
&& player.getPlayWhenReady()
&& events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)
&& player.getPlaybackState() == Player.STATE_BUFFERING) {
latch.countDown();
}
}
});
serviceController.startCommand(/* flags= */ 0, /* startId= */ 0);
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
serviceController.destroy();
}
@Test
public void onStartCommand_customCommands_deliveredByMediaNotificationController()
throws InterruptedException {
ExoPlayer player = new TestExoPlayerBuilder(context).build();
AtomicReference<MediaSession> sessionRef = new AtomicReference<>();
SessionCommand expectedCustomCommand = new SessionCommand("enable_shuffle", Bundle.EMPTY);
CountDownLatch latch = new CountDownLatch(1);
sessionRef.set(
new MediaSession.Builder(context, player)
.setCallback(
new MediaSession.Callback() {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, MediaSession.ControllerInfo controller) {
if (session.getUri().equals(sessionRef.get().getUri())
&& session.isMediaNotificationController(controller)) {
return new MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(
new SessionCommands.Builder().add(expectedCustomCommand).build())
.build();
} else {
return MediaSession.ConnectionResult.reject();
}
}
@Override
public ListenableFuture<SessionResult> onCustomCommand(
MediaSession session,
MediaSession.ControllerInfo controller,
SessionCommand customCommand,
Bundle args) {
if (session.getUri().equals(sessionRef.get().getUri())
&& session.isMediaNotificationController(controller)
&& customCommand.equals(expectedCustomCommand)
&& customCommand
.customExtras
.getString("expectedKey", /* defaultValue= */ "")
.equals("expectedValue")
&& args.isEmpty()) {
latch.countDown();
}
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
})
.build());
MediaSession session = sessionRef.get();
Intent customCommandIntent = new Intent("androidx.media3.session.CUSTOM_NOTIFICATION_ACTION");
customCommandIntent.setData(session.getUri());
customCommandIntent.putExtra(
"androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION", "enable_shuffle");
Bundle extras = new Bundle();
extras.putString("expectedKey", "expectedValue");
customCommandIntent.putExtra(
"androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION_EXTRAS", extras);
ServiceController<TestService> serviceController =
Robolectric.buildService(TestService.class, customCommandIntent);
TestService service = serviceController.create().get();
service.addSession(session);
serviceController.startCommand(/* flags= */ 0, /* startId= */ 0);
assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
session.release();
player.release();
serviceController.destroy();
} }
@Nullable @Nullable
@ -336,4 +498,60 @@ public class MediaSessionServiceTest {
return null; // No need to support binding or pending intents for this test. return null; // No need to support binding or pending intents for this test.
} }
} }
private static final class TestServiceWithPlaybackResumption extends MediaSessionService {
private List<MediaItem> mediaItems = ImmutableList.of();
public void setMediaItems(List<MediaItem> mediaItems) {
this.mediaItems = mediaItems;
}
@Nullable private MediaSession session;
@Override
public void onCreate() {
super.onCreate();
Context context = ApplicationProvider.getApplicationContext();
ExoPlayer player = new TestExoPlayerBuilder(context).build();
session =
new MediaSession.Builder(context, player)
.setCallback(
new MediaSession.Callback() {
@Override
public ListenableFuture<MediaSession.MediaItemsWithStartPosition>
onPlaybackResumption(
MediaSession mediaSession, MediaSession.ControllerInfo controller) {
// Automatic playback resumption is expected to be called only from the media
// notification controller. So we call it here only if the callback is
// actually called from the media notification controller (or a fake of it).
if (mediaSession.isMediaNotificationController(controller)) {
return Futures.immediateFuture(
new MediaSession.MediaItemsWithStartPosition(
mediaItems,
/* startIndex= */ 0,
/* startPositionMs= */ C.TIME_UNSET));
}
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}
})
.build();
}
@Nullable
@Override
public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) {
return session;
}
@Override
public void onDestroy() {
session.getPlayer().stop();
session.getPlayer().clearMediaItems();
session.getPlayer().release();
session.release();
session = null;
super.onDestroy();
}
}
} }