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;