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:
parent
5e05e2ec22
commit
ffd7bb5639
@ -70,6 +70,12 @@
|
||||
* Use the media notification controller as proxy to set available commands
|
||||
and custom layout used to populate the notification and the platform
|
||||
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:
|
||||
* Downloads:
|
||||
* OkHttp Extension:
|
||||
|
@ -388,6 +388,11 @@ import org.checkerframework.checker.nullness.qual.NonNull;
|
||||
@Override
|
||||
public void play() {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -522,6 +527,13 @@ import org.checkerframework.checker.nullness.qual.NonNull;
|
||||
@Override
|
||||
public void setPlayWhenReady(boolean playWhenReady) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,15 @@ package androidx.media3.session;
|
||||
|
||||
import static android.app.Service.STOP_FOREGROUND_DETACH;
|
||||
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.app.Notification;
|
||||
@ -25,6 +34,7 @@ import android.content.pm.ServiceInfo;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.KeyEvent;
|
||||
import androidx.annotation.DoNotInline;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
@ -44,7 +54,6 @@ import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
@ -65,7 +74,7 @@ import java.util.concurrent.TimeoutException;
|
||||
private final NotificationManagerCompat notificationManagerCompat;
|
||||
private final Executor mainExecutor;
|
||||
private final Intent startSelfIntent;
|
||||
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap;
|
||||
private final Map<MediaSession, ControllerAndListener> controllerAndListenerMap;
|
||||
|
||||
private int totalNotificationCount;
|
||||
@Nullable private MediaNotification mediaNotification;
|
||||
@ -82,30 +91,34 @@ import java.util.concurrent.TimeoutException;
|
||||
Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
|
||||
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
|
||||
controllerMap = new HashMap<>();
|
||||
controllerAndListenerMap = new HashMap<>();
|
||||
startedInForeground = false;
|
||||
}
|
||||
|
||||
public void addSession(MediaSession session) {
|
||||
if (controllerMap.containsKey(session)) {
|
||||
if (controllerAndListenerMap.containsKey(session)) {
|
||||
return;
|
||||
}
|
||||
MediaControllerListener listener = new MediaControllerListener(mediaSessionService, session);
|
||||
MediaControllerListener controllerListener =
|
||||
new MediaControllerListener(mediaSessionService, session);
|
||||
PlayerListener playerListener = new PlayerListener(mediaSessionService, session);
|
||||
Bundle connectionHints = new Bundle();
|
||||
connectionHints.putBoolean(KEY_MEDIA_NOTIFICATION_MANAGER, true);
|
||||
ListenableFuture<MediaController> controllerFuture =
|
||||
new MediaController.Builder(mediaSessionService, session.getToken())
|
||||
.setConnectionHints(connectionHints)
|
||||
.setListener(listener)
|
||||
.setListener(controllerListener)
|
||||
.setApplicationLooper(Looper.getMainLooper())
|
||||
.buildAsync();
|
||||
controllerMap.put(session, controllerFuture);
|
||||
controllerAndListenerMap.put(
|
||||
session, new ControllerAndListener(controllerFuture, playerListener));
|
||||
controllerFuture.addListener(
|
||||
() -> {
|
||||
try {
|
||||
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
||||
listener.onConnected(shouldShowNotification(session));
|
||||
controller.addListener(listener);
|
||||
// Assert connection success.
|
||||
controllerFuture.get(/* time= */ 0, MILLISECONDS);
|
||||
controllerListener.onConnected(shouldShowNotification(session));
|
||||
session.getImpl().addPlayerListener(playerListener);
|
||||
} catch (CancellationException
|
||||
| ExecutionException
|
||||
| InterruptedException
|
||||
@ -118,9 +131,52 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
|
||||
public void removeSession(MediaSession session) {
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
|
||||
if (controllerFuture != null) {
|
||||
MediaController.releaseFuture(controllerFuture);
|
||||
ControllerAndListener controllerAndListener = controllerAndListenerMap.remove(session);
|
||||
if (controllerAndListener != null) {
|
||||
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)) {
|
||||
mainExecutor.execute(
|
||||
() -> sendCustomCommandIfCommandIsAvailable(mediaController, action));
|
||||
() -> sendCustomCommandIfCommandIsAvailable(mediaController, action, extras));
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -154,10 +210,10 @@ import java.util.concurrent.TimeoutException;
|
||||
|
||||
int notificationSequence = ++totalNotificationCount;
|
||||
MediaController mediaNotificationController = null;
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||
if (controllerFuture != null && controllerFuture.isDone()) {
|
||||
ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session);
|
||||
if (controllerAndListener != null && controllerAndListener.controller.isDone()) {
|
||||
try {
|
||||
mediaNotificationController = Futures.getDone(controllerFuture);
|
||||
mediaNotificationController = Futures.getDone(controllerAndListener.controller);
|
||||
} catch (ExecutionException e) {
|
||||
// Ignore.
|
||||
}
|
||||
@ -261,12 +317,12 @@ import java.util.concurrent.TimeoutException;
|
||||
|
||||
@Nullable
|
||||
private MediaController getConnectedControllerForSession(MediaSession session) {
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||
if (controllerFuture == null) {
|
||||
ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session);
|
||||
if (controllerAndListener == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Futures.getDone(controllerFuture);
|
||||
return Futures.getDone(controllerAndListener.controller);
|
||||
} catch (ExecutionException exception) {
|
||||
// We should never reach this.
|
||||
throw new IllegalStateException(exception);
|
||||
@ -274,7 +330,7 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
|
||||
private void sendCustomCommandIfCommandIsAvailable(
|
||||
MediaController mediaController, String action) {
|
||||
MediaController mediaController, String action, Bundle extras) {
|
||||
@Nullable SessionCommand customCommand = null;
|
||||
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) {
|
||||
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
|
||||
@ -286,7 +342,8 @@ import java.util.concurrent.TimeoutException;
|
||||
if (customCommand != null
|
||||
&& mediaController.getAvailableSessionCommands().contains(customCommand)) {
|
||||
ListenableFuture<SessionResult> future =
|
||||
mediaController.sendCustomCommand(customCommand, Bundle.EMPTY);
|
||||
mediaController.sendCustomCommand(
|
||||
new SessionCommand(action, extras), /* args= */ Bundle.EMPTY);
|
||||
Futures.addCallback(
|
||||
future,
|
||||
new FutureCallback<SessionResult>() {
|
||||
@ -304,8 +361,7 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MediaControllerListener
|
||||
implements MediaController.Listener, Player.Listener {
|
||||
private static final class MediaControllerListener implements MediaController.Listener {
|
||||
private final MediaSessionService mediaSessionService;
|
||||
private final MediaSession session;
|
||||
|
||||
@ -334,6 +390,26 @@ import java.util.concurrent.TimeoutException;
|
||||
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
|
||||
public void onEvents(Player player, Player.Events events) {
|
||||
// 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_MEDIA_METADATA_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(
|
||||
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
|
||||
@ -382,6 +455,17 @@ import java.util.concurrent.TimeoutException;
|
||||
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)
|
||||
private static class Api24 {
|
||||
|
||||
|
@ -129,6 +129,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||
private boolean closed;
|
||||
|
||||
// Should be only accessed on the application looper
|
||||
private final List<Player.Listener> wrapperListeners;
|
||||
private long sessionPositionUpdateDelayMs;
|
||||
private boolean isMediaNotificationControllerConnected;
|
||||
private ImmutableList<CommandButton> customLayout;
|
||||
@ -154,6 +155,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||
sessionStub = new MediaSessionStub(thisRef);
|
||||
this.sessionActivity = sessionActivity;
|
||||
this.customLayout = customLayout;
|
||||
wrapperListeners = new ArrayList<>();
|
||||
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
applicationHandler = new Handler(player.getApplicationLooper());
|
||||
@ -231,14 +233,38 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||
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(
|
||||
@Nullable PlayerWrapper oldPlayerWrapper, PlayerWrapper newPlayerWrapper) {
|
||||
playerWrapper = newPlayerWrapper;
|
||||
if (oldPlayerWrapper != null) {
|
||||
oldPlayerWrapper.removeListener(checkStateNotNull(this.playerListener));
|
||||
for (int i = 0; i < wrapperListeners.size(); i++) {
|
||||
oldPlayerWrapper.removeListener(wrapperListeners.get(i));
|
||||
}
|
||||
}
|
||||
PlayerListener playerListener = new PlayerListener(this, newPlayerWrapper);
|
||||
newPlayerWrapper.addListener(playerListener);
|
||||
for (int i = 0; i < wrapperListeners.size(); i++) {
|
||||
newPlayerWrapper.addListener(wrapperListeners.get(i));
|
||||
}
|
||||
this.playerListener = playerListener;
|
||||
|
||||
dispatchRemoteControllerTaskToLegacyStub(
|
||||
@ -270,6 +296,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||
if (playerListener != null) {
|
||||
playerWrapper.removeListener(playerListener);
|
||||
}
|
||||
for (int i = 0; i < wrapperListeners.size(); i++) {
|
||||
playerWrapper.removeListener(wrapperListeners.get(i));
|
||||
}
|
||||
wrapperListeners.clear();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// Catch all exceptions to ensure the rest of this method to be executed as exceptions may be
|
||||
|
@ -690,6 +690,15 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
int resultCode = sessionImpl.onPlayerCommandRequestOnHandler(controller, command);
|
||||
|
@ -428,7 +428,7 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
@Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent);
|
||||
if (keyEvent != null) {
|
||||
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
|
||||
getMediaNotificationManager().onMediaButtonEvent(session, keyEvent);
|
||||
}
|
||||
} else if (session != null && actionFactory.isCustomAction(intent)) {
|
||||
@Nullable String customAction = actionFactory.getCustomAction(intent);
|
||||
|
@ -17,22 +17,33 @@ package androidx.media3.session;
|
||||
|
||||
import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil;
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.view.KeyEvent;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.ForwardingPlayer;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.test.utils.TestExoPlayerBuilder;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
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 org.junit.After;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
@ -43,21 +54,16 @@ import org.robolectric.shadows.ShadowLooper;
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class MediaSessionServiceTest {
|
||||
|
||||
private static final int TIMEOUT_MS = 500;
|
||||
|
||||
private Context context;
|
||||
private NotificationManager notificationManager;
|
||||
private ServiceController<TestService> serviceController;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
context = ApplicationProvider.getApplicationContext();
|
||||
notificationManager =
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
serviceController = Robolectric.buildService(TestService.class);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
serviceController.destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -66,6 +72,7 @@ public class MediaSessionServiceTest {
|
||||
ExoPlayer player2 = new TestExoPlayerBuilder(context).build();
|
||||
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
|
||||
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
|
||||
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
|
||||
TestService service = serviceController.create().get();
|
||||
service.setMediaNotificationProvider(
|
||||
new DefaultMediaNotificationProvider(
|
||||
@ -92,6 +99,7 @@ public class MediaSessionServiceTest {
|
||||
session2.release();
|
||||
player1.release();
|
||||
player2.release();
|
||||
serviceController.destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -105,6 +113,7 @@ public class MediaSessionServiceTest {
|
||||
ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build();
|
||||
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
|
||||
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
|
||||
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
|
||||
TestService service = serviceController.create().get();
|
||||
service.setMediaNotificationProvider(
|
||||
new DefaultMediaNotificationProvider(
|
||||
@ -112,7 +121,6 @@ public class MediaSessionServiceTest {
|
||||
session -> 2000 + Integer.parseInt(session.getId()),
|
||||
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID,
|
||||
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID));
|
||||
|
||||
service.addSession(session1);
|
||||
service.addSession(session2);
|
||||
// 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);
|
||||
thread1.quit();
|
||||
thread2.quit();
|
||||
serviceController.destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -183,6 +192,7 @@ public class MediaSessionServiceTest {
|
||||
}
|
||||
})
|
||||
.build();
|
||||
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
|
||||
TestService service = serviceController.create().get();
|
||||
service.setMediaNotificationProvider(
|
||||
new DefaultMediaNotificationProvider(
|
||||
@ -229,6 +239,7 @@ public class MediaSessionServiceTest {
|
||||
.isEqualTo("customAction2");
|
||||
session.release();
|
||||
player.release();
|
||||
serviceController.destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -272,6 +283,7 @@ public class MediaSessionServiceTest {
|
||||
}
|
||||
})
|
||||
.build();
|
||||
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
|
||||
TestService service = serviceController.create().get();
|
||||
service.setMediaNotificationProvider(
|
||||
new DefaultMediaNotificationProvider(
|
||||
@ -317,6 +329,156 @@ public class MediaSessionServiceTest {
|
||||
|
||||
session.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
|
||||
@ -336,4 +498,60 @@ public class MediaSessionServiceTest {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user