Use MediaSessionImpl.onMediaButtonEvent() to dispatch key events

This change moves the handling of any media button event into
`MediaSessionImpl.onMediaButtonEvent(intent)`. This includes
the double click handling from `MediaSessionLegacyStub`.

The advantage is that everything is in one place which allows
to offer `MediaSession.Callback.onMediaButtonEvent` with which
an app can override the default implementation and handle media
buttons in a custom way.

Media button events can originate from various places:

- Delivered to `MediaSessionService.onStartCommand(Intent)`
  - A `PendingIntent` from the notification below API 33
  - An `Intent` sent to the `MediaButtonReceiver` by the system dispatched
    to the service
- Delivered to `MediaSessionCompat.Callback.onMediaButtonEvent(Intent)`
  implemented by `MediaSessionLegacyStub` during the session is active
  - Bluetooth (headset/remote control)
  - Apps/system using `AudioManager.dispatchKeyEvent(KeyEvent)`
  - Apps/system using `MediaControllerCompat.dispatchKeyEvent(keyEvent)`

Issue: androidx/media#12
Issue: androidx/media#159
Issue: androidx/media#216
Issue: androidx/media#249

#minor-release

PiperOrigin-RevId: 575231251
This commit is contained in:
bachinger 2023-10-20 08:52:55 -07:00 committed by Copybara-Service
parent a8ab9e2c70
commit a79d44edc5
8 changed files with 748 additions and 179 deletions

View file

@ -44,6 +44,8 @@
(([#339](https://github.com/androidx/media/issues/339)). (([#339](https://github.com/androidx/media/issues/339)).
* Use `DataSourceBitmapLoader` by default instead of `SimpleBitmapLoader` * Use `DataSourceBitmapLoader` by default instead of `SimpleBitmapLoader`
([#271](https://github.com/androidx/media/issues/271),[#327](https://github.com/androidx/media/issues/327)). ([#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: * UI:
* Downloads: * Downloads:
* OkHttp Extension: * OkHttp Extension:

View file

@ -24,6 +24,7 @@ import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
@ -1444,6 +1445,32 @@ public class MediaSession {
MediaSession mediaSession, ControllerInfo controller) { MediaSession mediaSession, ControllerInfo controller) {
return Futures.immediateFailedFuture(new UnsupportedOperationException()); return Futures.immediateFailedFuture(new UnsupportedOperationException());
} }
/**
* Called when a media button event has been received by the session.
*
* <p>Media3 handles media button events internally. An app can override the default behaviour
* by overriding this method.
*
* <p>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.
*
* <p>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. */ /** Representation of a list of {@linkplain MediaItem media items} and where to start playing. */

View file

@ -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.Player.COMMAND_SET_MEDIA_ITEM;
import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkStateNotNull; 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.common.util.Util.postOrRun;
import static androidx.media3.session.MediaSessionStub.UNKNOWN_SEQUENCE_NUMBER; import static androidx.media3.session.MediaSessionStub.UNKNOWN_SEQUENCE_NUMBER;
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED; import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
@ -52,6 +51,7 @@ import android.os.RemoteException;
import android.os.SystemClock; import android.os.SystemClock;
import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.MediaSessionCompat;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.ViewConfiguration;
import androidx.annotation.CheckResult; import androidx.annotation.CheckResult;
import androidx.annotation.FloatRange; import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy; import androidx.annotation.GuardedBy;
@ -116,6 +116,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private final Uri sessionUri; private final Uri sessionUri;
private final PlayerInfoChangedHandler onPlayerInfoChangedHandler; private final PlayerInfoChangedHandler onPlayerInfoChangedHandler;
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSession.Callback callback; private final MediaSession.Callback callback;
private final Context context; private final Context context;
private final MediaSessionStub sessionStub; private final MediaSessionStub sessionStub;
@ -161,28 +162,30 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
BitmapLoader bitmapLoader, BitmapLoader bitmapLoader,
boolean playIfSuppressed, boolean playIfSuppressed,
boolean isPeriodicPositionUpdateEnabled) { boolean isPeriodicPositionUpdateEnabled) {
this.context = context;
this.instance = instance; 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") @SuppressWarnings("nullness:assignment")
@Initialized @Initialized
MediaSessionImpl thisRef = this; MediaSessionImpl thisRef = this;
sessionStub = new MediaSessionStub(thisRef); sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity;
this.customLayout = customLayout;
mainHandler = new Handler(Looper.getMainLooper()); mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper()); Looper applicationLooper = player.getApplicationLooper();
this.callback = callback; applicationHandler = new Handler(applicationLooper);
this.bitmapLoader = bitmapLoader;
this.playIfSuppressed = playIfSuppressed;
this.isPeriodicPositionUpdateEnabled = isPeriodicPositionUpdateEnabled;
playerInfo = PlayerInfo.DEFAULT; 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. // Build Uri that differentiate sessions across the creation/destruction in PendingIntent.
// Here's the reason why Session ID / SessionToken aren't suitable here. // Here's the reason why Session ID / SessionToken aren't suitable here.
// - Session ID // - Session ID
@ -280,6 +283,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
closed = true; closed = true;
} }
mediaPlayPauseKeyHandler.clearPendingPlayPauseTask();
applicationHandler.removeCallbacksAndMessages(null); applicationHandler.removeCallbacksAndMessages(null);
try { try {
postOrRun( postOrRun(
@ -1080,7 +1084,16 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
(callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo)); (callback, seq) -> callback.onDeviceInfoChanged(seq, playerInfo.deviceInfo));
} }
/* package */ boolean onMediaButtonEvent(Intent intent) { /**
* Returns true if the media button event was handled, false otherwise.
*
* <p>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); KeyEvent keyEvent = DefaultActionFactory.getKeyEvent(intent);
ComponentName intentComponent = intent.getComponent(); ComponentName intentComponent = intent.getComponent();
if (!Objects.equals(intent.getAction(), Intent.ACTION_MEDIA_BUTTON) 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) { || keyEvent.getAction() != KeyEvent.ACTION_DOWN) {
return false; return false;
} }
ControllerInfo controllerInfo = getMediaNotificationControllerInfo();
if (controllerInfo == null) { verifyApplicationThread();
if (intentComponent != null) { if (callback.onMediaButtonEvent(instance, callerInfo, intent)) {
// Fallback to legacy if this is a media button event sent to one of our components. // Event handled by app callback.
return getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent) return true;
|| SDK_INT < 21; }
} // Double tap detection.
return false; 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; 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: case KEYCODE_MEDIA_PLAY_PAUSE:
command = command =
getPlayerWrapper().getPlayWhenReady() getPlayerWrapper().getPlayWhenReady()
@ -1653,6 +1714,56 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
} }
} }
/**
* A handler for double click detection.
*
* <p>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 class PlayerInfoChangedHandler extends Handler {
private static final int MSG_PLAYER_INFO_CHANGED = 1; private static final int MSG_PLAYER_INFO_CHANGED = 1;

View file

@ -65,7 +65,6 @@ import android.support.v4.media.session.MediaSessionCompat.QueueItem;
import android.support.v4.media.session.PlaybackStateCompat; import android.support.v4.media.session.PlaybackStateCompat;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.ViewConfiguration;
import androidx.annotation.DoNotInline; import androidx.annotation.DoNotInline;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi; import androidx.annotation.RequiresApi;
@ -126,9 +125,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private final MediaSessionManager sessionManager; private final MediaSessionManager sessionManager;
private final ControllerLegacyCbForBroadcast controllerLegacyCbForBroadcast; private final ControllerLegacyCbForBroadcast controllerLegacyCbForBroadcast;
private final ConnectionTimeoutHandler connectionTimeoutHandler; private final ConnectionTimeoutHandler connectionTimeoutHandler;
private final MediaPlayPauseKeyHandler mediaPlayPauseKeyHandler;
private final MediaSessionCompat sessionCompat; private final MediaSessionCompat sessionCompat;
private final String appPackageName;
@Nullable private final MediaButtonReceiver runtimeBroadcastReceiver; @Nullable private final MediaButtonReceiver runtimeBroadcastReceiver;
@Nullable private final ComponentName broadcastReceiverComponentName; @Nullable private final ComponentName broadcastReceiverComponentName;
@Nullable private VolumeProviderCompat volumeProviderCompat; @Nullable private VolumeProviderCompat volumeProviderCompat;
@ -141,11 +138,8 @@ import org.checkerframework.checker.initialization.qual.Initialized;
public MediaSessionLegacyStub(MediaSessionImpl session, Uri sessionUri, Handler handler) { public MediaSessionLegacyStub(MediaSessionImpl session, Uri sessionUri, Handler handler) {
sessionImpl = session; sessionImpl = session;
Context context = sessionImpl.getContext(); Context context = sessionImpl.getContext();
appPackageName = context.getPackageName();
sessionManager = MediaSessionManager.getSessionManager(context); sessionManager = MediaSessionManager.getSessionManager(context);
controllerLegacyCbForBroadcast = new ControllerLegacyCbForBroadcast(); controllerLegacyCbForBroadcast = new ControllerLegacyCbForBroadcast();
mediaPlayPauseKeyHandler =
new MediaPlayPauseKeyHandler(session.getApplicationHandler().getLooper());
connectedControllersManager = new ConnectedControllersManager<>(session); connectedControllersManager = new ConnectedControllersManager<>(session);
connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS; connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS;
connectionTimeoutHandler = connectionTimeoutHandler =
@ -318,41 +312,16 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} }
@Override @Override
public boolean onMediaButtonEvent(Intent mediaButtonEvent) { public boolean onMediaButtonEvent(Intent intent) {
@Nullable KeyEvent keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); return sessionImpl.onMediaButtonEvent(
if (keyEvent == null || keyEvent.getAction() != KeyEvent.ACTION_DOWN) { new ControllerInfo(
return false; sessionCompat.getCurrentControllerInfo(),
} ControllerInfo.LEGACY_CONTROLLER_VERSION,
RemoteUserInfo remoteUserInfo = sessionCompat.getCurrentControllerInfo(); ControllerInfo.LEGACY_CONTROLLER_INTERFACE_VERSION,
int keyCode = keyEvent.getKeyCode(); /* trusted= */ false,
switch (keyCode) { /* cb= */ null,
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: /* connectionHints= */ Bundle.EMPTY),
case KeyEvent.KEYCODE_HEADSETHOOK: intent);
// 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;
} }
private void maybeUpdateFlags(PlayerWrapper playerWrapper) { private void maybeUpdateFlags(PlayerWrapper playerWrapper) {
@ -366,8 +335,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} }
} }
private void handleMediaPlayPauseOnHandler(RemoteUserInfo remoteUserInfo) { /* package */ void handleMediaPlayPauseOnHandler(RemoteUserInfo remoteUserInfo) {
mediaPlayPauseKeyHandler.clearPendingMediaPlayPauseKey();
dispatchSessionTaskWithPlayerCommand( dispatchSessionTaskWithPlayerCommand(
COMMAND_PLAY_PAUSE, COMMAND_PLAY_PAUSE,
controller -> 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) { private static String getBitmapLoadErrorMessage(Throwable throwable) {
return "Failed to load bitmap: " + throwable.getMessage(); return "Failed to load bitmap: " + throwable.getMessage();
} }

View file

@ -22,6 +22,7 @@ import static androidx.media3.common.util.Util.postOrRun;
import android.app.ForegroundServiceStartNotAllowedException; import android.app.ForegroundServiceStartNotAllowedException;
import android.app.Service; import android.app.Service;
import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
@ -39,6 +40,7 @@ import androidx.annotation.RequiresApi;
import androidx.collection.ArrayMap; import androidx.collection.ArrayMap;
import androidx.media.MediaBrowserServiceCompat; import androidx.media.MediaBrowserServiceCompat;
import androidx.media.MediaSessionManager; import androidx.media.MediaSessionManager;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
@ -425,9 +427,19 @@ public abstract class MediaSessionService extends Service {
} }
addSession(session); addSession(session);
} }
if (!session.getImpl().onMediaButtonEvent(intent)) { MediaSessionImpl sessionImpl = session.getImpl();
Log.w(TAG, "Ignoring unrecognized media button intent."); 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)) { } else if (session != null && actionFactory.isCustomAction(intent)) {
@Nullable String customAction = actionFactory.getCustomAction(intent); @Nullable String customAction = actionFactory.getCustomAction(intent);
if (customAction == null) { if (customAction == null) {
@ -439,6 +451,24 @@ public abstract class MediaSessionService extends Service {
return START_STICKY; 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. * Called when the service is no longer used and is being removed.
* *

View file

@ -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_ENDED;
import static androidx.media3.common.Player.STATE_IDLE; import static androidx.media3.common.Player.STATE_IDLE;
import static androidx.media3.common.Player.STATE_READY; 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_ERROR_INVALID_STATE;
import static androidx.media3.session.SessionResult.RESULT_SUCCESS; import static androidx.media3.session.SessionResult.RESULT_SUCCESS;
import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME; 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.AudioAttributes;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.DeviceInfo; import androidx.media3.common.DeviceInfo;
import androidx.media3.common.ForwardingPlayer;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.Rating; import androidx.media3.common.Rating;
@ -71,7 +73,6 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.junit.After; import org.junit.After;
import org.junit.Assert;
import org.junit.Before; import org.junit.Before;
import org.junit.ClassRule; import org.junit.ClassRule;
import org.junit.Rule; import org.junit.Rule;
@ -93,6 +94,8 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
@Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG); @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG);
@Rule public final MediaSessionTestRule mediaSessionTestRule = new MediaSessionTestRule();
private Context context; private Context context;
private TestHandler handler; private TestHandler handler;
private MediaSession session; private MediaSession session;
@ -615,37 +618,47 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
} }
@Test @Test
public void dispatchMediaButtonEvent_playWithEmptyTimeline_callsPreparesPlayerCorrectly() public void dispatchMediaButtonEvent_playWithEmptyTimeline_callsPlaybackResumptionPrepareAndPlay()
throws Exception { throws Exception {
ArrayList<MediaItem> mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); ArrayList<MediaItem> mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
session = AtomicReference<MediaSession> session = new AtomicReference<>();
new MediaSession.Builder(context, player) CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(session, player);
.setId("sendMediaButtonEvent") session.set(
.setCallback( mediaSessionTestRule.ensureReleaseAfterTest(
new MediaSession.Callback() { new MediaSession.Builder(context, callerCollectorPlayer)
@Override .setId("dispatchMediaButtonEvent")
public ListenableFuture<MediaSession.MediaItemsWithStartPosition> .setCallback(
onPlaybackResumption(MediaSession mediaSession, ControllerInfo controller) { new MediaSession.Callback() {
return Futures.immediateFuture( @Override
new MediaSession.MediaItemsWithStartPosition( public ListenableFuture<MediaSession.MediaItemsWithStartPosition>
mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 123L)); onPlaybackResumption(
} MediaSession mediaSession, ControllerInfo controller) {
}) return Futures.immediateFuture(
.build(); new MediaSession.MediaItemsWithStartPosition(
mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 123L));
}
})
.build()));
controller = controller =
new RemoteMediaControllerCompat( 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); KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY);
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent); session.get().getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX)) assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
.isTrue(); .isTrue();
assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
assertThat(player.startMediaItemIndex).isEqualTo(1); assertThat(player.startMediaItemIndex).isEqualTo(1);
assertThat(player.startPositionMs).isEqualTo(123L); assertThat(player.startPositionMs).isEqualTo(123L);
assertThat(player.mediaItems).isEqualTo(mediaItems); assertThat(player.mediaItems).isEqualTo(mediaItems);
assertThat(callerCollectorPlayer.callers).hasSize(3);
for (ControllerInfo controllerInfo : callerCollectorPlayer.callers) {
assertThat(session.get().isMediaNotificationController(controllerInfo)).isFalse();
}
} }
@Test @Test
@ -739,7 +752,59 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
@Test @Test
public void public void
dispatchMediaButtonEvent_playWithEmptyTimelineCallbackFailure_callsHandlePlayButtonAction() dispatchMediaButtonEvent_playWithEmptyTimelineWithMediaNotificationController_callsPlaybackResumptionPrepareAndPlay()
throws Exception {
ArrayList<MediaItem> mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
AtomicReference<MediaSession> 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<MediaSession.MediaItemsWithStartPosition>
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 { throws Exception {
player.mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); player.mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
player.startMediaItemIndex = 1; player.startMediaItemIndex = 1;
@ -781,28 +846,20 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
player.timeline = new PlaylistTimeline(player.mediaItems); player.timeline = new PlaylistTimeline(player.mediaItems);
player.startMediaItemIndex = 1; player.startMediaItemIndex = 1;
player.startPositionMs = 321L; player.startPositionMs = 321L;
session = AtomicReference<MediaSession> session = new AtomicReference<>();
new MediaSession.Builder(context, player) CallerCollectorPlayer callerCollectorPlayer = new CallerCollectorPlayer(session, player);
.setId("sendMediaButtonEvent") session.set(
.setCallback( mediaSessionTestRule.ensureReleaseAfterTest(
new MediaSession.Callback() { new MediaSession.Builder(context, callerCollectorPlayer)
@Override .setId("dispatchMediaButtonEvent")
public ListenableFuture<MediaSession.MediaItemsWithStartPosition> .build()));
onPlaybackResumption(MediaSession mediaSession, ControllerInfo controller) {
Assert.fail();
return Futures.immediateFuture(
new MediaSession.MediaItemsWithStartPosition(
MediaTestUtils.createMediaItems(/* size= */ 10),
/* startIndex= */ 9,
/* startPositionMs= */ 123L));
}
})
.build();
controller = controller =
new RemoteMediaControllerCompat( 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); player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue(); assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
@ -811,6 +868,50 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
assertThat(player.startMediaItemIndex).isEqualTo(1); assertThat(player.startMediaItemIndex).isEqualTo(1);
assertThat(player.startPositionMs).isEqualTo(321L); assertThat(player.startPositionMs).isEqualTo(321L);
assertThat(player.mediaItems).hasSize(3); 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<MediaSession> 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 @Test
@ -1807,4 +1908,33 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
return MediaSession.ConnectionResult.reject(); return MediaSession.ConnectionResult.reject();
} }
} }
private static class CallerCollectorPlayer extends ForwardingPlayer {
private final List<ControllerInfo> callers;
private final AtomicReference<MediaSession> mediaSession;
public CallerCollectorPlayer(AtomicReference<MediaSession> mediaSession, MockPlayer player) {
super(player);
this.mediaSession = mediaSession;
callers = new ArrayList<>();
}
@Override
public void setMediaItems(List<MediaItem> 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();
}
}
} }

View file

@ -16,6 +16,8 @@
package androidx.media3.session; package androidx.media3.session;
import static androidx.media.MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER; 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.CommonConstants.SUPPORT_APP_PACKAGE_NAME;
import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS;
import static androidx.media3.test.session.common.TestUtils.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.content.Context;
import android.media.AudioManager; import android.media.AudioManager;
import android.media.MediaPlayer; import android.media.MediaPlayer;
import android.os.Bundle;
import android.view.KeyEvent; import android.view.KeyEvent;
import androidx.media3.common.ForwardingPlayer;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerInfo; 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.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest; import androidx.test.filters.LargeTest;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import org.junit.After; import org.junit.After;
import org.junit.Assume; import org.junit.Assume;
@ -69,6 +75,7 @@ public class MediaSessionKeyEventTest {
private MediaSession session; private MediaSession session;
private MockPlayer player; private MockPlayer player;
private TestSessionCallback sessionCallback; private TestSessionCallback sessionCallback;
private CallerCollectorPlayer callerCollectorPlayer;
@Before @Before
public void setUp() throws Exception { public void setUp() throws Exception {
@ -78,10 +85,14 @@ public class MediaSessionKeyEventTest {
Context context = ApplicationProvider.getApplicationContext(); Context context = ApplicationProvider.getApplicationContext();
audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
handler = threadTestRule.getHandler(); handler = threadTestRule.getHandler();
player = new MockPlayer.Builder().setApplicationLooper(handler.getLooper()).build(); player =
new MockPlayer.Builder().setMediaItems(1).setApplicationLooper(handler.getLooper()).build();
sessionCallback = new TestSessionCallback(); 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. // Here's the requirement for an app to receive media key events via MediaSession.
// - SDK < 26: Player should be playing for receiving key events // - 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); 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<ControllerInfo> 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<ControllerInfo> 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<ControllerInfo> 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 @Test
public void stopKeyEvent() throws Exception { public void stopKeyEvent() throws Exception {
Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19. Assume.assumeTrue(Util.SDK_INT >= 21); // TODO: b/199064299 - Lower minSdk to 19.
@ -210,7 +307,7 @@ public class MediaSessionKeyEventTest {
handler.postAndSync( handler.postAndSync(
() -> { () -> {
player.playWhenReady = true; player.playWhenReady = true;
player.playbackState = Player.STATE_ENDED; player.playbackState = STATE_ENDED;
}); });
dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false); dispatchMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, false);
@ -233,6 +330,36 @@ public class MediaSessionKeyEventTest {
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); 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) { private void dispatchMediaKeyEvent(int keyCode, boolean doubleTap) {
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode)); audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
audioManager.dispatchMediaKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, 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 = private static class TestSessionCallback implements MediaSession.Callback {
getExpectedControllerPackageName();
@Override @Override
public MediaSession.ConnectionResult onConnect( public MediaSession.ConnectionResult onConnect(
MediaSession session, ControllerInfo controller) { 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.Callback.super.onConnect(session, controller);
} }
return MediaSession.ConnectionResult.reject(); return MediaSession.ConnectionResult.reject();
} }
}
private static String getExpectedControllerPackageName() { private class CallerCollectorPlayer extends ForwardingPlayer {
if (Util.SDK_INT >= 28 || Util.SDK_INT < 21) { private final List<ControllerInfo> callers;
return SUPPORT_APP_PACKAGE_NAME;
} else if (Util.SDK_INT >= 24) { public CallerCollectorPlayer(Player player) {
// KeyEvent from system service has the package name "android". super(player);
return "android"; callers = new ArrayList<>();
} else { }
// In API 21+, MediaSessionCompat#getCurrentControllerInfo always returns fake info.
return LEGACY_CONTROLLER; @Override
} public void seekForward() {
callers.add(session.getControllerForCurrentRequest());
super.seekForward();
}
@Override
public void seekBack() {
callers.add(session.getControllerForCurrentRequest());
super.seekBack();
} }
} }
} }

View file

@ -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_NEXT;
import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE; import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE;
import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY; 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_PREVIOUS;
import static android.view.KeyEvent.KEYCODE_MEDIA_REWIND; import static android.view.KeyEvent.KEYCODE_MEDIA_REWIND;
import static android.view.KeyEvent.KEYCODE_MEDIA_STOP; import static android.view.KeyEvent.KEYCODE_MEDIA_STOP;
@ -513,7 +514,7 @@ public class MediaSessionTest {
session.set( session.set(
sessionTestRule.ensureReleaseAfterTest( sessionTestRule.ensureReleaseAfterTest(
new MediaSession.Builder(context, callerCollectorPlayer) new MediaSession.Builder(context, callerCollectorPlayer)
.setId("getSessionCompatToken_returnsCompatibleWithMediaControllerCompat") .setId("onMediaButtonEvent")
.setCallback( .setCallback(
new MediaSession.Callback() { new MediaSession.Callback() {
@Override @Override
@ -535,14 +536,41 @@ public class MediaSessionTest {
.buildAsync() .buildAsync()
.get(); .get();
MediaSessionImpl impl = session.get().getImpl(); threadTestRule
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PLAY))).isTrue(); .getHandler()
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))).isTrue(); .postAndSync(
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD))).isTrue(); () -> {
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_REWIND))).isTrue(); MediaSessionImpl impl = session.get().getImpl();
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_NEXT))).isTrue(); ControllerInfo controllerInfo = createMediaButtonCaller();
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))).isTrue(); assertThat(
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_STOP))).isTrue(); 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_PLAY, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, TIMEOUT_MS);
@ -566,7 +594,7 @@ public class MediaSessionTest {
session.set( session.set(
sessionTestRule.ensureReleaseAfterTest( sessionTestRule.ensureReleaseAfterTest(
new MediaSession.Builder(context, callerCollectorPlayer) new MediaSession.Builder(context, callerCollectorPlayer)
.setId("getSessionCompatToken_returnsCompatibleWithMediaControllerCompat") .setId("onMediaButtonEvent")
.setCallback( .setCallback(
new MediaSession.Callback() { new MediaSession.Callback() {
@Override @Override
@ -583,19 +611,46 @@ public class MediaSessionTest {
.build())); .build()));
MediaSessionImpl impl = session.get().getImpl(); MediaSessionImpl impl = session.get().getImpl();
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PLAY))).isTrue(); threadTestRule
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PAUSE))).isTrue(); .getHandler()
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_FAST_FORWARD))).isTrue(); .postAndSync(
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_REWIND))).isTrue(); () -> {
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_NEXT))).isTrue(); ControllerInfo controllerInfo = createMediaButtonCaller();
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_PREVIOUS))).isTrue(); assertThat(
assertThat(impl.onMediaButtonEvent(getMediaButtonIntent(KEYCODE_MEDIA_STOP))).isTrue(); 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_PLAY, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_PAUSE, 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_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_NEXT, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS); player.awaitMethodCalled(MockPlayer.METHOD_SEEK_TO_PREVIOUS, TIMEOUT_MS);
player.awaitMethodCalled(MockPlayer.METHOD_STOP, 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<ControllerInfo> 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<ControllerInfo> 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 @Test
public void onMediaButtonEvent_noKeyEvent_returnsFalse() { public void onMediaButtonEvent_noKeyEvent_returnsFalse() {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.removeExtra(Intent.EXTRA_KEY_EVENT); intent.removeExtra(Intent.EXTRA_KEY_EVENT);
boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); boolean isEventHandled =
session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse(); assertThat(isEventHandled).isFalse();
} }
@ -631,7 +787,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.removeExtra(Intent.EXTRA_KEY_EVENT); intent.removeExtra(Intent.EXTRA_KEY_EVENT);
boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); boolean isEventHandled =
session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse(); assertThat(isEventHandled).isFalse();
} }
@ -642,7 +799,8 @@ public class MediaSessionTest {
intent.removeExtra(Intent.EXTRA_KEY_EVENT); intent.removeExtra(Intent.EXTRA_KEY_EVENT);
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_UP, KEYCODE_MEDIA_PAUSE)); 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(); assertThat(isEventHandled).isFalse();
} }
@ -660,7 +818,8 @@ public class MediaSessionTest {
intent.removeExtra(Intent.EXTRA_KEY_EVENT); intent.removeExtra(Intent.EXTRA_KEY_EVENT);
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_UP, KEYCODE_MEDIA_PAUSE)); 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(); assertThat(isEventHandled).isFalse();
} }
@ -670,7 +829,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setAction("notAMediaButtonAction"); intent.setAction("notAMediaButtonAction");
boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); boolean isEventHandled =
session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse(); assertThat(isEventHandled).isFalse();
} }
@ -687,7 +847,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setAction("notAMediaButtonAction"); intent.setAction("notAMediaButtonAction");
boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); boolean isEventHandled =
session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse(); assertThat(isEventHandled).isFalse();
} }
@ -697,7 +858,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setComponent(new ComponentName("a.package", "a.class")); intent.setComponent(new ComponentName("a.package", "a.class"));
boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); boolean isEventHandled =
session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse(); assertThat(isEventHandled).isFalse();
} }
@ -715,7 +877,8 @@ public class MediaSessionTest {
Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY); Intent intent = getMediaButtonIntent(KEYCODE_MEDIA_PLAY);
intent.setComponent(new ComponentName("a.package", "a.class")); intent.setComponent(new ComponentName("a.package", "a.class"));
boolean isEventHandled = session.getImpl().onMediaButtonEvent(intent); boolean isEventHandled =
session.getImpl().onMediaButtonEvent(createMediaButtonCaller(), intent);
assertThat(isEventHandled).isFalse(); assertThat(isEventHandled).isFalse();
} }
@ -750,6 +913,19 @@ public class MediaSessionTest {
: MediaSessionManager.RemoteUserInfo.LEGACY_CONTROLLER; : 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 static class CallerCollectorPlayer extends ForwardingPlayer {
private final List<ControllerInfo> callingControllers; private final List<ControllerInfo> callingControllers;
private final AtomicReference<MediaSession> session; private final AtomicReference<MediaSession> session;