diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 914de6a377..00ecd24d26 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -37,6 +37,8 @@
(([#339](https://github.com/androidx/media/issues/339)).
* Use `DataSourceBitmapLoader` by default instead of `SimpleBitmapLoader`
([#271](https://github.com/androidx/media/issues/271),[#327](https://github.com/androidx/media/issues/327)).
+ * Add `MediaSession.Callback.onMediaButtonEvent(Intent)` that allows apps
+ to override the default media button event handling.
* UI:
* Downloads:
* OkHttp Extension:
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 ba653118d7..63f064bea5 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java
@@ -24,6 +24,7 @@ import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import android.app.PendingIntent;
import android.content.Context;
+import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
@@ -1444,6 +1445,32 @@ public class MediaSession {
MediaSession mediaSession, ControllerInfo controller) {
return Futures.immediateFailedFuture(new UnsupportedOperationException());
}
+
+ /**
+ * Called when a media button event has been received by the session.
+ *
+ *
Media3 handles media button events internally. An app can override the default behaviour
+ * by overriding this method.
+ *
+ *
Return true to stop propagating the event any further. When false is returned, Media3
+ * handles the event and calls {@linkplain MediaSession#getPlayer() the session player}
+ * accordingly.
+ *
+ *
Apps normally don't need to override this method. When overriding this method, an app
+ * can/needs to handle all API-level specifics on its own. The intent passed to this method can
+ * come directly from the system that routed a media key event (for instance sent by Bluetooth)
+ * to your session.
+ *
+ * @param session The session that received the media button event.
+ * @param controllerInfo The controller to which the media button event is attributed to.
+ * @param intent The media button intent.
+ * @return True if the event was handled, false otherwise.
+ */
+ @UnstableApi
+ default boolean onMediaButtonEvent(
+ MediaSession session, ControllerInfo controllerInfo, Intent intent) {
+ return false;
+ }
}
/** Representation of a list of {@linkplain MediaItem media items} and where to start playing. */
diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java
index 79253148b6..4193ff3e40 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java
@@ -29,7 +29,6 @@ import static androidx.media3.common.Player.COMMAND_CHANGE_MEDIA_ITEMS;
import static androidx.media3.common.Player.COMMAND_SET_MEDIA_ITEM;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
-import static androidx.media3.common.util.Util.SDK_INT;
import static androidx.media3.common.util.Util.postOrRun;
import static androidx.media3.session.MediaSessionStub.UNKNOWN_SEQUENCE_NUMBER;
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
@@ -52,6 +51,7 @@ import android.os.RemoteException;
import android.os.SystemClock;
import android.support.v4.media.session.MediaSessionCompat;
import android.view.KeyEvent;
+import android.view.ViewConfiguration;
import androidx.annotation.CheckResult;
import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy;
@@ -116,6 +116,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private final Uri sessionUri;
private final PlayerInfoChangedHandler onPlayerInfoChangedHandler;
+ private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSession.Callback callback;
private final Context context;
private final MediaSessionStub sessionStub;
@@ -161,28 +162,30 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
BitmapLoader bitmapLoader,
boolean playIfSuppressed,
boolean isPeriodicPositionUpdateEnabled) {
- this.context = context;
this.instance = instance;
+ this.context = context;
+ sessionId = id;
+ this.sessionActivity = sessionActivity;
+ this.customLayout = customLayout;
+ this.callback = callback;
+ this.bitmapLoader = bitmapLoader;
+ this.playIfSuppressed = playIfSuppressed;
+ this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled;
@SuppressWarnings("nullness:assignment")
@Initialized
MediaSessionImpl thisRef = this;
sessionStub = new MediaSessionStub(thisRef);
- this.sessionActivity = sessionActivity;
- this.customLayout = customLayout;
mainHandler = new Handler(Looper.getMainLooper());
- applicationHandler = new Handler(player.getApplicationLooper());
- this.callback = callback;
- this.bitmapLoader = bitmapLoader;
- this.playIfSuppressed = playIfSuppressed;
- this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled;
+ Looper applicationLooper = player.getApplicationLooper();
+ applicationHandler = new Handler(applicationLooper);
playerInfo = PlayerInfo.DEFAULT;
- onPlayerInfoChangedHandler = new PlayerInfoChangedHandler(player.getApplicationLooper());
+ onPlayerInfoChangedHandler = new PlayerInfoChangedHandler(applicationLooper);
+ mediaPlayPauseKeyHandler = new MediaPlayPauseKeyHandler(applicationLooper);
- sessionId = id;
// Build Uri that differentiate sessions across the creation/destruction in PendingIntent.
// Here's the reason why Session ID / SessionToken aren't suitable here.
// - Session ID
@@ -280,6 +283,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
}
closed = true;
}
+ mediaPlayPauseKeyHandler.clearPendingPlayPauseTask();
applicationHandler.removeCallbacksAndMessages(null);
try {
postOrRun(
@@ -1080,7 +1084,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
(callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo));
}
- /* package */ boolean onMediaButtonEvent(Intent intent) {
+ /**
+ * Returns true if the media button event was handled, false otherwise.
+ *
+ *
Must be called on the application thread of the session.
+ *
+ * @param callerInfo The calling {@link ControllerInfo}.
+ * @param intent The media button intent.
+ * @return True if the event was handled, false otherwise.
+ */
+ /* package */ boolean onMediaButtonEvent(ControllerInfo callerInfo, Intent intent) {
KeyEvent keyEvent = DefaultActionFactory.getKeyEvent(intent);
ComponentName intentComponent = intent.getComponent();
if (!Objects.equals(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)
@@ -1090,18 +1103,66 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|| keyEvent.getAction() != KeyEvent.ACTION_DOWN) {
return false;
}
- ControllerInfo controllerInfo = getMediaNotificationControllerInfo();
- if (controllerInfo == null) {
- if (intentComponent != null) {
- // Fallback to legacy if this is a media button event sent to one of our components.
- return getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent)
- || SDK_INT < 21;
- }
- return false;
+
+ verifyApplicationThread();
+ if (callback.onMediaButtonEvent(instance, callerInfo, intent)) {
+ // Event handled by app callback.
+ return true;
+ }
+ // Double tap detection.
+ int keyCode = keyEvent.getKeyCode();
+ boolean doubleTapCompleted = false;
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ if (callerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION
+ || keyEvent.getRepeatCount() != 0) {
+ // Double tap detection is only for media button events from external sources
+ // (for instance Bluetooth) and excluding long press (repeatCount > 0).
+ mediaPlayPauseKeyHandler.flush();
+ } else if (mediaPlayPauseKeyHandler.hasPendingPlayPauseTask()) {
+ // A double tap arrived. Clear the pending playPause task.
+ mediaPlayPauseKeyHandler.clearPendingPlayPauseTask();
+ doubleTapCompleted = true;
+ } else {
+ // Handle event with a delayed callback that's run if no double tap arrives in time.
+ mediaPlayPauseKeyHandler.setPendingPlayPauseTask(callerInfo, keyEvent);
+ return true;
+ }
+ break;
+ default:
+ // If another key is pressed within double tap timeout, make play/pause as a single tap to
+ // handle media keys in order.
+ mediaPlayPauseKeyHandler.flush();
+ break;
}
+ if (!isMediaNotificationControllerConnected()) {
+ if (keyCode == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE && doubleTapCompleted) {
+ // Double tap completion for legacy when media notification controller is disabled.
+ sessionLegacyStub.onSkipToNext();
+ return true;
+ } else if (callerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION) {
+ sessionLegacyStub.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
+ return true;
+ }
+ // This is an unhandled framework event. Return false to let the framework resolve by calling
+ // `MediaSessionCompat.Callback.onXyz()`.
+ return false;
+ }
+ // Send from media notification controller.
+ return applyMediaButtonKeyEvent(keyEvent, doubleTapCompleted);
+ }
+
+ private boolean applyMediaButtonKeyEvent(KeyEvent keyEvent, boolean doubleTapCompleted) {
+ ControllerInfo controllerInfo = checkNotNull(instance.getMediaNotificationControllerInfo());
Runnable command;
- switch (keyEvent.getKeyCode()) {
+ int keyCode = keyEvent.getKeyCode();
+ if ((keyCode == KEYCODE_MEDIA_PLAY_PAUSE || keyCode == KEYCODE_MEDIA_PLAY)
+ && doubleTapCompleted) {
+ keyCode = KEYCODE_MEDIA_NEXT;
+ }
+ switch (keyCode) {
case KEYCODE_MEDIA_PLAY_PAUSE:
command =
getPlayerWrapper().getPlayWhenReady()
@@ -1653,6 +1714,56 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
}
}
+ /**
+ * A handler for double click detection.
+ *
+ *
All methods must be called on the application thread.
+ */
+ private class MediaPlayPauseKeyHandler extends Handler {
+
+ @Nullable private Runnable playPauseTask;
+
+ public MediaPlayPauseKeyHandler(Looper applicationLooper) {
+ super(applicationLooper);
+ }
+
+ public void setPendingPlayPauseTask(ControllerInfo controllerInfo, KeyEvent keyEvent) {
+ playPauseTask =
+ () -> {
+ if (isMediaNotificationController(controllerInfo)) {
+ applyMediaButtonKeyEvent(keyEvent, /* doubleTapCompleted= */ false);
+ } else {
+ sessionLegacyStub.handleMediaPlayPauseOnHandler(
+ checkNotNull(controllerInfo.getRemoteUserInfo()));
+ }
+ playPauseTask = null;
+ };
+ postDelayed(playPauseTask, ViewConfiguration.getDoubleTapTimeout());
+ }
+
+ @Nullable
+ public Runnable clearPendingPlayPauseTask() {
+ if (playPauseTask != null) {
+ removeCallbacks(playPauseTask);
+ Runnable task = playPauseTask;
+ playPauseTask = null;
+ return task;
+ }
+ return null;
+ }
+
+ public boolean hasPendingPlayPauseTask() {
+ return playPauseTask != null;
+ }
+
+ public void flush() {
+ @Nullable Runnable task = clearPendingPlayPauseTask();
+ if (task != null) {
+ postOrRun(this, task);
+ }
+ }
+ }
+
private class PlayerInfoChangedHandler extends Handler {
private static final int MSG_PLAYER_INFO_CHANGED = 1;
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 b63abdad8d..cc0b17b340 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java
@@ -65,7 +65,6 @@ import android.support.v4.media.session.MediaSessionCompat.QueueItem;
import android.support.v4.media.session.PlaybackStateCompat;
import android.text.TextUtils;
import android.view.KeyEvent;
-import android.view.ViewConfiguration;
import androidx.annotation.DoNotInline;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
@@ -126,9 +125,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private final MediaSessionManager sessionManager;
private final ControllerLegacyCbForBroadcast controllerLegacyCbForBroadcast;
private final ConnectionTimeoutHandler connectionTimeoutHandler;
- private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSessionCompat sessionCompat;
- private final String appPackageName;
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
@Nullable private final ComponentName broadcastReceiverComponentName;
@Nullable private VolumeProviderCompat volumeProviderCompat;
@@ -141,11 +138,8 @@ import org.checkerframework.checker.initialization.qual.Initialized;
public MediaSessionLegacyStub(MediaSessionImpl session, Uri sessionUri, Handler handler) {
sessionImpl = session;
Context context = sessionImpl.getContext();
- appPackageName = context.getPackageName();
sessionManager = MediaSessionManager.getSessionManager(context);
controllerLegacyCbForBroadcast = new ControllerLegacyCbForBroadcast();
- mediaPlayPauseKeyHandler =
- new MediaPlayPauseKeyHandler(session.getApplicationHandler().getLooper());
connectedControllersManager = new ConnectedControllersManager<>(session);
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS;
connectionTimeoutHandler =
@@ -318,41 +312,16 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
@Override
- public boolean onMediaButtonEvent(Intent mediaButtonEvent) {
- @Nullable KeyEvent keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
- if (keyEvent == null || keyEvent.getAction() != KeyEvent.ACTION_DOWN) {
- return false;
- }
- RemoteUserInfo remoteUserInfo = sessionCompat.getCurrentControllerInfo();
- int keyCode = keyEvent.getKeyCode();
- switch (keyCode) {
- case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
- case KeyEvent.KEYCODE_HEADSETHOOK:
- // Double tap detection only for media button events from external sources (for instance
- // Bluetooth). Media button events from the app package are coming from the notification
- // below targetApiLevel 33.
- if (!appPackageName.equals(remoteUserInfo.getPackageName())
- && keyEvent.getRepeatCount() == 0) {
- if (mediaPlayPauseKeyHandler.hasPendingMediaPlayPauseKey()) {
- mediaPlayPauseKeyHandler.clearPendingMediaPlayPauseKey();
- onSkipToNext();
- } else {
- mediaPlayPauseKeyHandler.addPendingMediaPlayPauseKey(remoteUserInfo);
- }
- } else {
- // Consider long-press as a single tap. Handle immediately.
- handleMediaPlayPauseOnHandler(remoteUserInfo);
- }
- return true;
- default:
- // If another key is pressed within double tap timeout, consider the pending
- // pending play/pause as a single tap to handle media keys in order.
- if (mediaPlayPauseKeyHandler.hasPendingMediaPlayPauseKey()) {
- handleMediaPlayPauseOnHandler(remoteUserInfo);
- }
- break;
- }
- return false;
+ public boolean onMediaButtonEvent(Intent intent) {
+ return sessionImpl.onMediaButtonEvent(
+ new ControllerInfo(
+ sessionCompat.getCurrentControllerInfo(),
+ ControllerInfo.LEGACY_CONTROLLER_VERSION,
+ ControllerInfo.LEGACY_CONTROLLER_INTERFACE_VERSION,
+ /* trusted= */ false,
+ /* cb= */ null,
+ /* connectionHints= */ Bundle.EMPTY),
+ intent);
}
private void maybeUpdateFlags(PlayerWrapper playerWrapper) {
@@ -366,8 +335,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
}
- private void handleMediaPlayPauseOnHandler(RemoteUserInfo remoteUserInfo) {
- mediaPlayPauseKeyHandler.clearPendingMediaPlayPauseKey();
+ /* package */ void handleMediaPlayPauseOnHandler(RemoteUserInfo remoteUserInfo) {
dispatchSessionTaskWithPlayerCommand(
COMMAND_PLAY_PAUSE,
controller ->
@@ -1435,34 +1403,6 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
}
- private class MediaPlayPauseKeyHandler extends Handler {
-
- private static final int MSG_DOUBLE_TAP_TIMED_OUT = 1002;
-
- public MediaPlayPauseKeyHandler(Looper looper) {
- super(looper);
- }
-
- @Override
- public void handleMessage(Message msg) {
- RemoteUserInfo remoteUserInfo = (RemoteUserInfo) msg.obj;
- handleMediaPlayPauseOnHandler(remoteUserInfo);
- }
-
- public void addPendingMediaPlayPauseKey(RemoteUserInfo remoteUserInfo) {
- Message msg = obtainMessage(MSG_DOUBLE_TAP_TIMED_OUT, remoteUserInfo);
- sendMessageDelayed(msg, ViewConfiguration.getDoubleTapTimeout());
- }
-
- public void clearPendingMediaPlayPauseKey() {
- removeMessages(MSG_DOUBLE_TAP_TIMED_OUT);
- }
-
- public boolean hasPendingMediaPlayPauseKey() {
- return hasMessages(MSG_DOUBLE_TAP_TIMED_OUT);
- }
- }
-
private static String getBitmapLoadErrorMessage(Throwable throwable) {
return "Failed to load bitmap: " + throwable.getMessage();
}
diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java
index 7c30eebfaa..ec81b7083b 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionService.java
@@ -22,6 +22,7 @@ import static androidx.media3.common.util.Util.postOrRun;
import android.app.ForegroundServiceStartNotAllowedException;
import android.app.Service;
+import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
@@ -39,6 +40,7 @@ import androidx.annotation.RequiresApi;
import androidx.collection.ArrayMap;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media.MediaSessionManager;
+import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
@@ -425,9 +427,19 @@ public abstract class MediaSessionService extends Service {
}
addSession(session);
}
- if (!session.getImpl().onMediaButtonEvent(intent)) {
- Log.w(TAG, "Ignoring unrecognized media button intent.");
- }
+ MediaSessionImpl sessionImpl = session.getImpl();
+ sessionImpl
+ .getApplicationHandler()
+ .post(
+ () -> {
+ ControllerInfo callerInfo = sessionImpl.getMediaNotificationControllerInfo();
+ if (callerInfo == null) {
+ callerInfo = createFallbackMediaButtonCaller(intent);
+ }
+ if (!sessionImpl.onMediaButtonEvent(callerInfo, intent)) {
+ Log.d(TAG, "Ignored unrecognized media button intent.");
+ }
+ });
} else if (session != null && actionFactory.isCustomAction(intent)) {
@Nullable String customAction = actionFactory.getCustomAction(intent);
if (customAction == null) {
@@ -439,6 +451,24 @@ public abstract class MediaSessionService extends Service {
return START_STICKY;
}
+ private static ControllerInfo createFallbackMediaButtonCaller(Intent mediaButtonIntent) {
+ @Nullable ComponentName componentName = mediaButtonIntent.getComponent();
+ String packageName =
+ componentName != null
+ ? componentName.getPackageName()
+ : "androidx.media3.session.MediaSessionService";
+ return new ControllerInfo(
+ new MediaSessionManager.RemoteUserInfo(
+ packageName,
+ MediaSessionManager.RemoteUserInfo.UNKNOWN_PID,
+ MediaSessionManager.RemoteUserInfo.UNKNOWN_UID),
+ MediaLibraryInfo.VERSION_INT,
+ MediaControllerStub.VERSION_INT,
+ /* trusted= */ false,
+ /* cb= */ null,
+ /* connectionHints= */ Bundle.EMPTY);
+ }
+
/**
* Called when the service is no longer used and is being removed.
*
diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java
index 65ebd75a8b..b5689477d6 100644
--- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java
+++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackWithMediaControllerCompatTest.java
@@ -21,6 +21,7 @@ import static androidx.media3.common.Player.COMMAND_PREPARE;
import static androidx.media3.common.Player.STATE_ENDED;
import static androidx.media3.common.Player.STATE_IDLE;
import static androidx.media3.common.Player.STATE_READY;
+import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.session.SessionResult.RESULT_ERROR_INVALID_STATE;
import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME;
@@ -46,6 +47,7 @@ import androidx.media.AudioManagerCompat;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.DeviceInfo;
+import androidx.media3.common.ForwardingPlayer;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Player;
import androidx.media3.common.Rating;
@@ -71,7 +73,6 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.After;
-import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
@@ -93,6 +94,8 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
@Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG);
+ @Rule public final MediaSessionTestRule mediaSessionTestRule = new MediaSessionTestRule();
+
private Context context;
private TestHandler handler;
private MediaSession session;
@@ -615,37 +618,47 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
}
@Test
- public void dispatchMediaButtonEvent_playWithEmptyTimeline_callsPreparesPlayerCorrectly()
+ public void dispatchMediaButtonEvent_playWithEmptyTimeline_callsPlaybackResumptionPrepareAndPlay()
throws Exception {
ArrayList mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
- session =
- new MediaSession.Builder(context, player)
- .setId("sendMediaButtonEvent")
- .setCallback(
- new MediaSession.Callback() {
- @Override
- public ListenableFuture
- onPlaybackResumption(MediaSession mediaSession, ControllerInfo controller) {
- return Futures.immediateFuture(
- new MediaSession.MediaItemsWithStartPosition(
- mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 123L));
- }
- })
- .build();
+ AtomicReference session = new AtomicReference<>();
+ CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(session, player);
+ session.set(
+ mediaSessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, callerCollectorPlayer)
+ .setId("dispatchMediaButtonEvent")
+ .setCallback(
+ new MediaSession.Callback() {
+ @Override
+ public ListenableFuture
+ onPlaybackResumption(
+ MediaSession mediaSession, ControllerInfo controller) {
+ return Futures.immediateFuture(
+ new MediaSession.MediaItemsWithStartPosition(
+ mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 123L));
+ }
+ })
+ .build()));
controller =
new RemoteMediaControllerCompat(
- context, session.getSessionCompat().getSessionToken(), /* waitForConnection= */ true);
+ context,
+ session.get().getSessionCompat().getSessionToken(),
+ /* waitForConnection= */ true);
KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY);
- session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
- player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ session.get().getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
.isTrue();
assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
assertThat(player.startMediaItemIndex).isEqualTo(1);
assertThat(player.startPositionMs).isEqualTo(123L);
assertThat(player.mediaItems).isEqualTo(mediaItems);
+ assertThat(callerCollectorPlayer.callers).hasSize(3);
+ for (ControllerInfo controllerInfo : callerCollectorPlayer.callers) {
+ assertThat(session.get().isMediaNotificationController(controllerInfo)).isFalse();
+ }
}
@Test
@@ -739,7 +752,59 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
@Test
public void
- dispatchMediaButtonEvent_playWithEmptyTimelineCallbackFailure_callsHandlePlayButtonAction()
+ dispatchMediaButtonEvent_playWithEmptyTimelineWithMediaNotificationController_callsPlaybackResumptionPrepareAndPlay()
+ throws Exception {
+ ArrayList mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
+ AtomicReference session = new AtomicReference<>();
+ CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(session, player);
+ session.set(
+ mediaSessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, callerCollectorPlayer)
+ .setId("dispatchMediaButtonEvent")
+ .setCallback(
+ new MediaSession.Callback() {
+ @Override
+ public ListenableFuture
+ onPlaybackResumption(
+ MediaSession mediaSession, ControllerInfo controller) {
+ return Futures.immediateFuture(
+ new MediaSession.MediaItemsWithStartPosition(
+ mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 123L));
+ }
+ })
+ .build()));
+ controller =
+ new RemoteMediaControllerCompat(
+ context,
+ session.get().getSessionCompat().getSessionToken(),
+ /* waitForConnection= */ true);
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY);
+ Bundle connectionHints = new Bundle();
+ connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true);
+ new MediaController.Builder(
+ ApplicationProvider.getApplicationContext(), session.get().getToken())
+ .setConnectionHints(connectionHints)
+ .buildAsync()
+ .get();
+
+ session.get().getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.startMediaItemIndex).isEqualTo(1);
+ assertThat(player.startPositionMs).isEqualTo(123L);
+ assertThat(player.mediaItems).isEqualTo(mediaItems);
+ assertThat(callerCollectorPlayer.callers).hasSize(3);
+ for (ControllerInfo controllerInfo : callerCollectorPlayer.callers) {
+ assertThat(session.get().isMediaNotificationController(controllerInfo)).isTrue();
+ }
+ }
+
+ @Test
+ public void
+ dispatchMediaButtonEvent_playWithEmptyTimelinePlaybackResumptionFailure_callsHandlePlayButtonAction()
throws Exception {
player.mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
player.startMediaItemIndex = 1;
@@ -781,28 +846,20 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
player.timeline = new PlaylistTimeline(player.mediaItems);
player.startMediaItemIndex = 1;
player.startPositionMs = 321L;
- session =
- new MediaSession.Builder(context, player)
- .setId("sendMediaButtonEvent")
- .setCallback(
- new MediaSession.Callback() {
- @Override
- public ListenableFuture
- onPlaybackResumption(MediaSession mediaSession, ControllerInfo controller) {
- Assert.fail();
- return Futures.immediateFuture(
- new MediaSession.MediaItemsWithStartPosition(
- MediaTestUtils.createMediaItems(/* size= */ 10),
- /* startIndex= */ 9,
- /* startPositionMs= */ 123L));
- }
- })
- .build();
+ AtomicReference session = new AtomicReference<>();
+ CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(session, player);
+ session.set(
+ mediaSessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, callerCollectorPlayer)
+ .setId("dispatchMediaButtonEvent")
+ .build()));
controller =
new RemoteMediaControllerCompat(
- context, session.getSessionCompat().getSessionToken(), /* waitForConnection= */ true);
+ context,
+ session.get().getSessionCompat().getSessionToken(),
+ /* waitForConnection= */ true);
- session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
+ session.get().getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
@@ -811,6 +868,50 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
assertThat(player.startMediaItemIndex).isEqualTo(1);
assertThat(player.startPositionMs).isEqualTo(321L);
assertThat(player.mediaItems).hasSize(3);
+ assertThat(callerCollectorPlayer.callers).hasSize(2);
+ for (ControllerInfo controllerInfo : callerCollectorPlayer.callers) {
+ assertThat(session.get().isMediaNotificationController(controllerInfo)).isFalse();
+ }
+ }
+
+ @Test
+ public void
+ dispatchMediaButtonEvent_playWithNonEmptyTimelineWithMediaNotificationController_callsHandlePlayButtonAction()
+ throws Exception {
+ KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY);
+ player.mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
+ player.timeline = new PlaylistTimeline(player.mediaItems);
+ AtomicReference session = new AtomicReference<>();
+ CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(session, player);
+ session.set(
+ mediaSessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, callerCollectorPlayer)
+ .setId("dispatchMediaButtonEvent")
+ .build()));
+ Bundle connectionHints = new Bundle();
+ connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true);
+ new MediaController.Builder(
+ ApplicationProvider.getApplicationContext(), session.get().getToken())
+ .setConnectionHints(connectionHints)
+ .buildAsync()
+ .get();
+ controller =
+ new RemoteMediaControllerCompat(
+ context,
+ session.get().getSessionCompat().getSessionToken(),
+ /* waitForConnection= */ true);
+
+ session.get().getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ assertThat(player.mediaItems).hasSize(3);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isFalse();
+ assertThat(callerCollectorPlayer.callers).hasSize(2);
+ for (ControllerInfo controllerInfo : callerCollectorPlayer.callers) {
+ assertThat(session.get().isMediaNotificationController(controllerInfo)).isTrue();
+ }
}
@Test
@@ -1807,4 +1908,33 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
return MediaSession.ConnectionResult.reject();
}
}
+
+ private static class CallerCollectorPlayer extends ForwardingPlayer {
+ private final List callers;
+ private final AtomicReference mediaSession;
+
+ public CallerCollectorPlayer(AtomicReference mediaSession, MockPlayer player) {
+ super(player);
+ this.mediaSession = mediaSession;
+ callers = new ArrayList<>();
+ }
+
+ @Override
+ public void setMediaItems(List mediaItems, int startIndex, long startPositionMs) {
+ callers.add(checkNotNull(mediaSession.get().getControllerForCurrentRequest()));
+ super.setMediaItems(mediaItems, startIndex, startPositionMs);
+ }
+
+ @Override
+ public void prepare() {
+ callers.add(checkNotNull(mediaSession.get().getControllerForCurrentRequest()));
+ super.prepare();
+ }
+
+ @Override
+ public void play() {
+ callers.add(checkNotNull(mediaSession.get().getControllerForCurrentRequest()));
+ super.play();
+ }
+ }
}
diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java
index 48f250cb4d..e6bd1febe0 100644
--- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java
+++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionKeyEventTest.java
@@ -16,6 +16,8 @@
package androidx.media3.session;
import static androidx.media.MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER;
+import static androidx.media3.common.Player.STATE_ENDED;
+import static androidx.media3.session.MediaSession.ControllerInfo.LEGACY_CONTROLLER_VERSION;
import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME;
import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS;
import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS;
@@ -26,7 +28,9 @@ import static org.junit.Assume.assumeTrue;
import android.content.Context;
import android.media.AudioManager;
import android.media.MediaPlayer;
+import android.os.Bundle;
import android.view.KeyEvent;
+import androidx.media3.common.ForwardingPlayer;
import androidx.media3.common.Player;
import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerInfo;
@@ -37,6 +41,8 @@ import androidx.media3.test.session.common.TestHandler;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
+import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.CountDownLatch;
import org.junit.After;
import org.junit.Assume;
@@ -69,6 +75,7 @@ public class MediaSessionKeyEventTest {
private MediaSession session;
private MockPlayer player;
private TestSessionCallback sessionCallback;
+ private CallerCollectorPlayer callerCollectorPlayer;
@Before
public void setUp() throws Exception {
@@ -78,10 +85,14 @@ public class MediaSessionKeyEventTest {
Context context = ApplicationProvider.getApplicationContext();
audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
handler = threadTestRule.getHandler();
- player = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build();
-
+ player =
+ new MockPlayer.Builder().setMediaItems(1).setApplicationLooper(handler.getLooper()).build();
sessionCallback = new TestSessionCallback();
- session = new MediaSession.Builder(context, player).setCallback(sessionCallback).build();
+ callerCollectorPlayer = new CallerCollectorPlayer(player);
+ session =
+ new MediaSession.Builder(context, callerCollectorPlayer)
+ .setCallback(sessionCallback)
+ .build();
// Here's the requirement for an app to receive media key events via MediaSession.
// - SDK < 26: Player should be playing for receiving key events
@@ -160,6 +171,92 @@ public class MediaSessionKeyEventTest {
player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS);
}
+ @Test
+ public void
+ fastForwardKeyEvent_mediaNotificationControllerConnected_callFromNotificationController()
+ throws Exception {
+ Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
+ MediaController controller = connectMediaNotificationController();
+ dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, /* doubleTap= */ false);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS);
+ assertThat(callerCollectorPlayer.callers).hasSize(1);
+ assertThat(callerCollectorPlayer.callers.get(0).getControllerVersion())
+ .isNotEqualTo(LEGACY_CONTROLLER_VERSION);
+ assertThat(callerCollectorPlayer.callers.get(0).getPackageName())
+ .isEqualTo("androidx.media3.test.session");
+ assertThat(callerCollectorPlayer.callers.get(0).getConnectionHints().size()).isEqualTo(1);
+ assertThat(
+ callerCollectorPlayer
+ .callers
+ .get(0)
+ .getConnectionHints()
+ .getBoolean(
+ MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER,
+ /* defaultValue= */ false))
+ .isTrue();
+ threadTestRule.getHandler().postAndSync(controller::release);
+ }
+
+ @Test
+ public void
+ fastForwardKeyEvent_mediaNotificationControllerNotConnected_callFromLegacyFallbackController()
+ throws Exception {
+ Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
+
+ dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, false);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS);
+ List controllers = callerCollectorPlayer.callers;
+ assertThat(controllers).hasSize(1);
+ assertThat(controllers.get(0).getControllerVersion()).isEqualTo(LEGACY_CONTROLLER_VERSION);
+ assertThat(controllers.get(0).getConnectionHints().size()).isEqualTo(0);
+ assertThat(controllers.get(0).getPackageName())
+ .isEqualTo(getExpectedControllerPackageName(controllers.get(0)));
+ }
+
+ @Test
+ public void rewindKeyEvent_mediaNotificationControllerConnected_callFromNotificationController()
+ throws Exception {
+ Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
+ MediaController controller = connectMediaNotificationController();
+
+ dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_REWIND, false);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_BACK, TIMEOUT_MS);
+ List controllers = callerCollectorPlayer.callers;
+ assertThat(controllers).hasSize(1);
+ assertThat(controllers.get(0).getPackageName()).isEqualTo("androidx.media3.test.session");
+ assertThat(controllers.get(0).getControllerVersion()).isNotEqualTo(LEGACY_CONTROLLER_VERSION);
+ assertThat(controllers.get(0).getConnectionHints().size()).isEqualTo(1);
+ assertThat(
+ controllers
+ .get(0)
+ .getConnectionHints()
+ .getBoolean(
+ MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER,
+ /* defaultValue= */ false))
+ .isTrue();
+ threadTestRule.getHandler().postAndSync(controller::release);
+ }
+
+ @Test
+ public void
+ rewindKeyEvent_mediaNotificationControllerNotConnected_callFromLegacyFallbackController()
+ throws Exception {
+ Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
+
+ dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_REWIND, false);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_BACK, TIMEOUT_MS);
+ List controllers = callerCollectorPlayer.callers;
+ assertThat(controllers).hasSize(1);
+ assertThat(controllers.get(0).getControllerVersion()).isEqualTo(LEGACY_CONTROLLER_VERSION);
+ assertThat(controllers.get(0).getConnectionHints().size()).isEqualTo(0);
+ assertThat(controllers.get(0).getPackageName())
+ .isEqualTo(getExpectedControllerPackageName(controllers.get(0)));
+ }
+
@Test
public void stopKeyEvent() throws Exception {
Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
@@ -210,7 +307,7 @@ public class MediaSessionKeyEventTest {
handler.postAndSync(
() -> {
player.playWhenReady = true;
- player.playbackState = Player.STATE_ENDED;
+ player.playbackState = STATE_ENDED;
});
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false);
@@ -233,6 +330,36 @@ public class MediaSessionKeyEventTest {
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);
}
+ @Test
+ public void playPauseKeyEvent_doubleTapOnPlayPause_seekNext() throws Exception {
+ Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
+ handler.postAndSync(
+ () -> {
+ player.playWhenReady = true;
+ player.playbackState = Player.STATE_READY;
+ });
+
+ dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, /* doubleTap= */ true);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS);
+ }
+
+ private MediaController connectMediaNotificationController() throws Exception {
+ return threadTestRule
+ .getHandler()
+ .postAndSync(
+ () -> {
+ Bundle connectionHints = new Bundle();
+ connectionHints.putBoolean(
+ MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, /* value= */ true);
+ return new MediaController.Builder(
+ ApplicationProvider.getApplicationContext(), session.getToken())
+ .setConnectionHints(connectionHints)
+ .buildAsync()
+ .get();
+ });
+ }
+
private void dispatchMediaKeyEvent(int keyCode, boolean doubleTap) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
@@ -242,30 +369,56 @@ public class MediaSessionKeyEventTest {
}
}
- private static class TestSessionCallback implements MediaSession.Callback {
+ private static String getExpectedControllerPackageName(ControllerInfo controllerInfo) {
+ if (controllerInfo.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION) {
+ return SUPPORT_APP_PACKAGE_NAME;
+ }
+ // Legacy controllers
+ if (Util.SDK_INT < 21 || Util.SDK_INT >= 28) {
+ // Above API 28: package of the app using AudioManager.
+ // Below 21: package of the owner of the session. Note: This is specific to this test setup
+ // where `ApplicationProvider.getContext().packageName == SUPPORT_APP_PACKAGE_NAME`.
+ return SUPPORT_APP_PACKAGE_NAME;
+ } else if (Util.SDK_INT >= 24) {
+ // API 24 - 27: KeyEvent from system service has the package name "android".
+ return "android";
+ } else {
+ // API 21 - 23: Fallback set by MediaSessionCompat#getCurrentControllerInfo
+ return LEGACY_CONTROLLER;
+ }
+ }
- private static final String EXPECTED_CONTROLLER_PACKAGE_NAME =
- getExpectedControllerPackageName();
+ private static class TestSessionCallback implements MediaSession.Callback {
@Override
public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) {
- if (EXPECTED_CONTROLLER_PACKAGE_NAME.equals(controller.getPackageName())) {
+ if (session.isMediaNotificationController(controller)
+ || getExpectedControllerPackageName(controller).equals(controller.getPackageName())) {
return MediaSession.Callback.super.onConnect(session, controller);
}
return MediaSession.ConnectionResult.reject();
}
+ }
- private static String getExpectedControllerPackageName() {
- if (Util.SDK_INT >= 28 || Util.SDK_INT < 21) {
- return SUPPORT_APP_PACKAGE_NAME;
- } else if (Util.SDK_INT >= 24) {
- // KeyEvent from system service has the package name "android".
- return "android";
- } else {
- // In API 21+, MediaSessionCompat#getCurrentControllerInfo always returns fake info.
- return LEGACY_CONTROLLER;
- }
+ private class CallerCollectorPlayer extends ForwardingPlayer {
+ private final List callers;
+
+ public CallerCollectorPlayer(Player player) {
+ super(player);
+ callers = new ArrayList<>();
+ }
+
+ @Override
+ public void seekForward() {
+ callers.add(session.getControllerForCurrentRequest());
+ super.seekForward();
+ }
+
+ @Override
+ public void seekBack() {
+ callers.add(session.getControllerForCurrentRequest());
+ super.seekBack();
}
}
}
diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java
index 71a355df51..49156c7844 100644
--- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java
+++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionTest.java
@@ -19,6 +19,7 @@ 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;
@@ -513,7 +514,7 @@ public class MediaSessionTest {
session.set(
sessionTestRule.ensureReleaseAfterTest(
new MediaSession.Builder(context, callerCollectorPlayer)
- .setId("getSessionCompatToken_returnsCompatibleWithMediaControllerCompat")
+ .setId("onMediaButtonEvent")
.setCallback(
new MediaSession.Callback() {
@Override
@@ -535,14 +536,41 @@ public class MediaSessionTest {
.buildAsync()
.get();
- MediaSessionImpl impl = session.get().getImpl();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PLAY))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_REWIND))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_NEXT))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_STOP))).isTrue();
+ threadTestRule
+ .getHandler()
+ .postAndSync(
+ () -> {
+ MediaSessionImpl impl = session.get().getImpl();
+ ControllerInfo controllerInfo = createMediaButtonCaller();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PLAY)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PAUSE)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_REWIND)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_NEXT)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_STOP)))
+ .isTrue();
+ });
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);
@@ -566,7 +594,7 @@ public class MediaSessionTest {
session.set(
sessionTestRule.ensureReleaseAfterTest(
new MediaSession.Builder(context, callerCollectorPlayer)
- .setId("getSessionCompatToken_returnsCompatibleWithMediaControllerCompat")
+ .setId("onMediaButtonEvent")
.setCallback(
new MediaSession.Callback() {
@Override
@@ -583,19 +611,46 @@ public class MediaSessionTest {
.build()));
MediaSessionImpl impl = session.get().getImpl();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PLAY))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_REWIND))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_NEXT))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))).isTrue();
- assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_STOP))).isTrue();
+ threadTestRule
+ .getHandler()
+ .postAndSync(
+ () -> {
+ ControllerInfo controllerInfo = createMediaButtonCaller();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PLAY)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PAUSE)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_REWIND)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_NEXT)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS)))
+ .isTrue();
+ assertThat(
+ impl.onMediaButtonEvent(
+ controllerInfo, getMediaButtonIntent(KEYCODE_MEDIA_STOP)))
+ .isTrue();
+ });
- // Fallback code path through platform session when MediaSessionImpl doesn't handle the event.
+ // Fallback through the framework session when media notification controller in disabled.
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS);
- player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS);
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_BACK, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_STOP, TIMEOUT_MS);
@@ -609,12 +664,113 @@ public class MediaSessionTest {
}
}
+ @Test
+ public void
+ onMediaButtonEvent_appOverridesCallback_notificationControllerNotConnected_callsWhatAppCalls()
+ throws Exception {
+ List controllers = new ArrayList<>();
+ CountDownLatch latch = new CountDownLatch(1);
+ MediaSession session =
+ sessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, player)
+ .setId("onMediaButtonEvent")
+ .setCallback(
+ new MediaSession.Callback() {
+ @Override
+ public MediaSession.ConnectionResult onConnect(
+ MediaSession session, ControllerInfo controller) {
+ if (TextUtils.equals(
+ getControllerCallerPackageName(controller),
+ controller.getPackageName())) {
+ return MediaSession.Callback.super.onConnect(session, controller);
+ }
+ return MediaSession.ConnectionResult.reject();
+ }
+
+ @Override
+ public boolean onMediaButtonEvent(
+ MediaSession session, ControllerInfo controllerInfo, Intent intent) {
+ session.getPlayer().seekToNext();
+ controllers.add(controllerInfo);
+ latch.countDown();
+ return true;
+ }
+ })
+ .build());
+ MediaSessionImpl impl = session.getImpl();
+
+ ControllerInfo controllerInfo = createMediaButtonCaller();
+ threadTestRule
+ .getHandler()
+ .postAndSync(
+ () -> {
+ Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY_PAUSE);
+ assertThat(impl.onMediaButtonEvent(controllerInfo, intent)).isTrue();
+ });
+
+ assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_NEXT, TIMEOUT_MS);
+ assertThat(controllers).hasSize(1);
+ assertThat(session.isMediaNotificationController(controllers.get(0))).isFalse();
+ }
+
+ @Test
+ public void
+ onMediaButtonEvent_appOverridesCallback_notificationControllerConnected_callsWhatAppCalls()
+ throws Exception {
+ List controllers = new ArrayList<>();
+ MediaSession session =
+ sessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, player)
+ .setId("onMediaButtonEvent")
+ .setCallback(
+ new MediaSession.Callback() {
+ @Override
+ public boolean onMediaButtonEvent(
+ MediaSession session, ControllerInfo controllerInfo, Intent intent) {
+ if (DefaultActionFactory.getKeyEvent(intent).getKeyCode()
+ == KEYCODE_MEDIA_PLAY) {
+ player.seekForward();
+ controllers.add(controllerInfo);
+ return true;
+ }
+ return MediaSession.Callback.super.onMediaButtonEvent(
+ session, controllerInfo, intent);
+ }
+ })
+ .build());
+ Bundle connectionHints = new Bundle();
+ connectionHints.putBoolean(MediaNotificationManager.KEY_MEDIA_NOTIFICATION_MANAGER, true);
+ new MediaController.Builder(ApplicationProvider.getApplicationContext(), session.getToken())
+ .setConnectionHints(connectionHints)
+ .buildAsync()
+ .get();
+
+ boolean isEventHandled =
+ threadTestRule
+ .getHandler()
+ .postAndSync(
+ () ->
+ session
+ .getImpl()
+ .onMediaButtonEvent(
+ session.getMediaNotificationControllerInfo(),
+ getMediaButtonIntent(KEYCODE_MEDIA_PLAY)));
+
+ assertThat(isEventHandled).isTrue();
+ // App changed default behaviour
+ player.awaitMethodCalled(MockPlayer.METHOD_SEEK_FORWARD, TIMEOUT_MS);
+ assertThat(controllers).hasSize(1);
+ assertThat(session.isMediaNotificationController(controllers.get(0))).isTrue();
+ }
+
@Test
public void onMediaButtonEvent_noKeyEvent_returnsFalse() {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.removeExtra(Intent.EXTRA_KEY_EVENT);
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -631,7 +787,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.removeExtra(Intent.EXTRA_KEY_EVENT);
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -642,7 +799,8 @@ public class MediaSessionTest {
intent.removeExtra(Intent.EXTRA_KEY_EVENT);
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_UP, KEYCODE_MEDIA_PAUSE));
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -660,7 +818,8 @@ public class MediaSessionTest {
intent.removeExtra(Intent.EXTRA_KEY_EVENT);
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_UP, KEYCODE_MEDIA_PAUSE));
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -670,7 +829,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setAction("notAMediaButtonAction");
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -687,7 +847,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setAction("notAMediaButtonAction");
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -697,7 +858,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setComponent(new ComponentName("a.package", "a.class"));
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -715,7 +877,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setComponent(new ComponentName("a.package", "a.class"));
- boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent);
+ boolean isEventHandled =
+ session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse();
}
@@ -750,6 +913,19 @@ public class MediaSessionTest {
: MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER;
}
+ private static ControllerInfo createMediaButtonCaller() {
+ return new ControllerInfo(
+ new MediaSessionManager.RemoteUserInfo(
+ "RANDOM_MEDIA_BUTTON_CALLER_PACKAGE",
+ MediaSessionManager.RemoteUserInfo.UNKNOWN_PID,
+ MediaSessionManager.RemoteUserInfo.UNKNOWN_UID),
+ MediaLibraryInfo.VERSION_INT,
+ MediaControllerStub.VERSION_INT,
+ /* trusted= */ false,
+ /* cb= */ null,
+ /* connectionHints= */ Bundle.EMPTY);
+ }
+
private static class CallerCollectorPlayer extends ForwardingPlayer {
private final List callingControllers;
private final AtomicReference session;