diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 752b15952d..91e437b1d5 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -62,6 +62,10 @@
([#355](https://github.com/androidx/media/issues/355)).
* Fix memory leak of `MediaSessionService` or `MediaLibraryService`
([#346](https://github.com/androidx/media/issues/346)).
+ * Add `androidx.media3.session.MediaButtonReceiver` to enable apps to
+ implement playback resumption with media button events sent by, for
+ example, a Bluetooth headset
+ ([#167](https://github.com/androidx/media/issues/167)).
* UI:
* Add Util methods `shouldShowPlayButton` and
`handlePlayPauseButtonAction` to write custom UI elements with a
diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaButtonReceiver.java b/libraries/session/src/main/java/androidx/media3/session/MediaButtonReceiver.java
new file mode 100644
index 0000000000..40749f9bf6
--- /dev/null
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaButtonReceiver.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.media3.session;
+
+import static androidx.media3.common.util.Assertions.checkNotNull;
+
+import android.app.ForegroundServiceStartNotAllowedException;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Build;
+import android.view.KeyEvent;
+import androidx.annotation.DoNotInline;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.core.content.ContextCompat;
+import androidx.media3.common.util.Log;
+import androidx.media3.common.util.UnstableApi;
+import androidx.media3.common.util.Util;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A media button receiver receives hardware media playback button intent, such as those sent by
+ * wired and wireless headsets.
+ *
+ *
You can add this MediaButtonReceiver to your app by adding it directly to your
+ * AndroidManifest.xml:
+ *
+ *
+ * <receiver
+ * android:name="androidx.media3.session.MediaButtonReceiver"
+ * android:exported="true">
+ * <intent-filter>
+ * <action android:name="android.intent.action.MEDIA_BUTTON" />
+ * </intent-filter>
+ * </receiver>
+ *
+ *
+ * Apps that add this receiver to the manifest, must implement {@link
+ * MediaSession.Callback#onPlaybackResumption} or active automatic playback resumption (Note: If you
+ * choose to make this receiver start your own service that is not a {@link MediaSessionService} or
+ * {@link MediaLibraryService}, then you need to fulfill all requirements around starting a service
+ * in the foreground on all API levels your app should properly work on).
+ *
+ *
Service discovery
+ *
+ * This class assumes you have a {@link Service} in your app's manifest that controls media
+ * playback via a {@link MediaSession}. Once a key event is received by this receiver, it tries to
+ * find a {@link Service} that can handle the action {@link Intent#ACTION_MEDIA_BUTTON}, {@link
+ * MediaSessionService#SERVICE_INTERFACE} or {@link MediaSessionService#SERVICE_INTERFACE}. If an
+ * appropriate service is found, this class starts the service as a foreground service and sends the
+ * key event to the service by an {@link Intent} with action {@link Intent#ACTION_MEDIA_BUTTON}. If
+ * neither is available or more than one valid service is found for one of the actions, an {@link
+ * IllegalStateException} is thrown.
+ *
+ *
Service handling ACTION_MEDIA_BUTTON
+ *
+ * A service can receive a key event by including an intent filter that handles {@code
+ * android.intent.action.MEDIA_BUTTON}.
+ *
+ *
+ * <service android:name="com.example.android.MediaPlaybackService" >
+ * <intent-filter>
+ * <action android:name="android.intent.action.MEDIA_BUTTON" />
+ * </intent-filter>
+ * </service>
+ *
+ *
+ * Service handling action {@link MediaSessionService} or {@link MediaLibraryService}
+ *
+ * If you are using a {@link MediaSessionService} or {@link MediaLibraryService}, the service
+ * interface name is already used as the intent action. In this case, no further configuration is
+ * required.
+ *
+ *
+ * <service android:name="com.example.android.MediaPlaybackService" >
+ * <intent-filter>
+ * <action android:name="androidx.media3.session.MediaLibraryService" />
+ * </intent-filter>
+ * </service>
+ *
+ */
+@UnstableApi
+public class MediaButtonReceiver extends BroadcastReceiver {
+
+ private static final String TAG = "MediaButtonReceiver";
+ private static final String[] ACTIONS = {
+ Intent.ACTION_MEDIA_BUTTON,
+ MediaLibraryService.SERVICE_INTERFACE,
+ MediaSessionService.SERVICE_INTERFACE
+ };
+
+ @Override
+ public final void onReceive(Context context, @Nullable Intent intent) {
+ if (intent == null
+ || !Objects.equals(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)
+ || !intent.hasExtra(Intent.EXTRA_KEY_EVENT)) {
+ android.util.Log.d(TAG, "Ignore unsupported intent: " + intent);
+ return;
+ }
+
+ if (Util.SDK_INT >= 26) {
+ @Nullable
+ KeyEvent keyEvent = checkNotNull(intent.getExtras()).getParcelable(Intent.EXTRA_KEY_EVENT);
+ if (keyEvent != null
+ && keyEvent.getKeyCode() != KeyEvent.KEYCODE_MEDIA_PLAY
+ && keyEvent.getKeyCode() != KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) {
+ // Starting with Android 8 (API 26), the service must be started immediately in the
+ // foreground when being started. Also starting with Android 8, the system sends media
+ // button intents to this receiver only when the session is released or not active, meaning
+ // the service is not running. Hence we only accept a PLAY command here that ensures that
+ // playback is started and the MediaSessionService/MediaLibraryService is put into the
+ // foreground (see https://developer.android.com/guide/topics/media-apps/mediabuttons and
+ // https://developer.android.com/about/versions/oreo/android-8.0-changes#back-all).
+ android.util.Log.w(
+ TAG,
+ "Ignore key event that is not a `play` command on API 26 or above to avoid an"
+ + " 'ForegroundServiceDidNotStartInTimeException'");
+ return;
+ }
+ }
+
+ for (String action : ACTIONS) {
+ ComponentName mediaButtonServiceComponentName = getServiceComponentByAction(context, action);
+ if (mediaButtonServiceComponentName != null) {
+ intent.setComponent(mediaButtonServiceComponentName);
+ try {
+ ContextCompat.startForegroundService(context, intent);
+ } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
+ if (Build.VERSION.SDK_INT >= 31
+ && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
+ onForegroundServiceStartNotAllowedException(
+ intent, Api31.castToForegroundServiceStartNotAllowedException(e));
+ } else {
+ throw e;
+ }
+ }
+ return;
+ }
+ }
+ throw new IllegalStateException(
+ "Could not find any Service that handles any of the actions " + Arrays.toString(ACTIONS));
+ }
+
+ /**
+ * This method is called when an exception is thrown when calling {@link
+ * Context#startForegroundService(Intent)} as a result of receiving a media button event.
+ *
+ * By default, this method only logs the exception and it can be safely overridden. Apps that
+ * find that such a media button event has been legitimately sent, may choose to override this
+ * method and take the opportunity to post a notification from where the user journey can
+ * continue.
+ *
+ *
This exception can be thrown if a broadcast media button event is received and a media
+ * service is found in the manifest that is registered to handle {@link
+ * Intent#ACTION_MEDIA_BUTTON}. If this happens on API 31+ and the app is in the background then
+ * an exception is thrown.
+ *
+ *
With the exception of devices that are running API 20 and below, a media button intent is
+ * only required to be sent to this receiver for a Bluetooth media button event that wants to
+ * restart the service. In such a case the app gets an exemption and is allowed to start the
+ * foreground service. In this case this method will never be called.
+ *
+ *
In all other cases of attempting to start a Media3 service or to send a media button event,
+ * apps must use a {@link MediaBrowser} or {@link MediaController} to bind to the service instead
+ * of broadcasting an intent.
+ *
+ * @param intent The intent that was used {@linkplain Context#startForegroundService(Intent) for
+ * starting the foreground service}.
+ * @param e The exception thrown by the system and caught by this broadcast receiver.
+ */
+ @RequiresApi(31)
+ protected void onForegroundServiceStartNotAllowedException(
+ Intent intent, ForegroundServiceStartNotAllowedException e) {
+ Log.e(
+ TAG,
+ "caught exception when trying to start a foreground service from the "
+ + "background: "
+ + e.getMessage());
+ }
+
+ @SuppressWarnings("QueryPermissionsNeeded") // Needs to be provided in the app manifest.
+ @Nullable
+ private static ComponentName getServiceComponentByAction(Context context, String action) {
+ PackageManager pm = context.getPackageManager();
+ Intent queryIntent = new Intent(action);
+ queryIntent.setPackage(context.getPackageName());
+ List resolveInfos = pm.queryIntentServices(queryIntent, /* flags= */ 0);
+ if (resolveInfos.size() == 1) {
+ ResolveInfo resolveInfo = resolveInfos.get(0);
+ return new ComponentName(resolveInfo.serviceInfo.packageName, resolveInfo.serviceInfo.name);
+ } else if (resolveInfos.isEmpty()) {
+ return null;
+ } else {
+ throw new IllegalStateException(
+ "Expected 1 service that handles " + action + ", found " + resolveInfos.size());
+ }
+ }
+
+ @RequiresApi(31)
+ private static final class Api31 {
+ /**
+ * Returns true if the passed exception is a {@link ForegroundServiceStartNotAllowedException}.
+ */
+ @DoNotInline
+ public static boolean instanceOfForegroundServiceStartNotAllowedException(
+ IllegalStateException e) {
+ return e instanceof ForegroundServiceStartNotAllowedException;
+ }
+
+ /**
+ * Casts the {@link IllegalStateException} to a {@link
+ * ForegroundServiceStartNotAllowedException} and throws an exception if the cast fails.
+ */
+ @DoNotInline
+ public static ForegroundServiceStartNotAllowedException
+ castToForegroundServiceStartNotAllowedException(IllegalStateException e) {
+ return (ForegroundServiceStartNotAllowedException) e;
+ }
+ }
+}
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 32db3ed4ef..f36489335a 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSession.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSession.java
@@ -1234,6 +1234,21 @@ public class MediaSession {
Futures.immediateFuture(
new MediaItemsWithStartPosition(mediaItemList, startIndex, startPositionMs)));
}
+
+ /**
+ * Returns the last recent playlist of the player with which the player should be prepared when
+ * playback resumption from a media button receiver or the System UI notification is requested.
+ *
+ * @param mediaSession The media session for which playback resumption is requested.
+ * @param controller The controller that requests the playback resumption. This is a short
+ * living controller created only for issuing a play command for resuming playback.
+ * @return The {@linkplain MediaItemsWithStartPosition playlist} to resume playback with.
+ */
+ @UnstableApi
+ default ListenableFuture onPlaybackResumption(
+ MediaSession mediaSession, ControllerInfo controller) {
+ return Futures.immediateFailedFuture(new UnsupportedOperationException());
+ }
}
/** Representation of list of 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 ae4f415f96..8225bccdf2 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java
@@ -21,6 +21,7 @@ import static androidx.media3.common.util.Util.postOrRun;
import static androidx.media3.session.SessionResult.RESULT_ERROR_SESSION_DISCONNECTED;
import static androidx.media3.session.SessionResult.RESULT_ERROR_UNKNOWN;
import static androidx.media3.session.SessionResult.RESULT_INFO_SKIPPED;
+import static java.lang.Math.min;
import android.app.PendingIntent;
import android.content.ComponentName;
@@ -43,8 +44,10 @@ import androidx.annotation.CheckResult;
import androidx.annotation.FloatRange;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
+import androidx.core.os.ExecutorCompat;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media3.common.AudioAttributes;
+import androidx.media3.common.C;
import androidx.media3.common.DeviceInfo;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaLibraryInfo;
@@ -63,18 +66,22 @@ import androidx.media3.common.VideoSize;
import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.BitmapLoader;
import androidx.media3.common.util.Log;
+import androidx.media3.common.util.Util;
import androidx.media3.session.MediaSession.ControllerCb;
import androidx.media3.session.MediaSession.ControllerInfo;
import androidx.media3.session.MediaSession.MediaItemsWithStartPosition;
import androidx.media3.session.SequencedFutureManager.SequencedFuture;
import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.SettableFuture;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
import org.checkerframework.checker.initialization.qual.Initialized;
/* package */ class MediaSessionImpl {
@@ -136,6 +143,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
// Should be only accessed on the application looper
private long sessionPositionUpdateDelayMs;
+ @SuppressWarnings("StaticAssignmentInConstructor") // TODO(b/277754694): Remove mutable constants
public MediaSessionImpl(
MediaSession instance,
Context context,
@@ -426,7 +434,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
public MediaSession.ConnectionResult onConnectOnHandler(ControllerInfo controller) {
return checkNotNull(
- callback.onConnect(instance, controller), "onConnect must return non-null future");
+ callback.onConnect(instance, controller), "Callback.onConnect must return non-null future");
}
public void onPostConnectOnHandler(ControllerInfo controller) {
@@ -447,21 +455,21 @@ import org.checkerframework.checker.initialization.qual.Initialized;
ControllerInfo controller, String mediaId, Rating rating) {
return checkNotNull(
callback.onSetRating(instance, controller, mediaId, rating),
- "onSetRating must return non-null future");
+ "Callback.onSetRating must return non-null future");
}
public ListenableFuture onSetRatingOnHandler(
ControllerInfo controller, Rating rating) {
return checkNotNull(
callback.onSetRating(instance, controller, rating),
- "onSetRating must return non-null future");
+ "Callback.onSetRating must return non-null future");
}
public ListenableFuture onCustomCommandOnHandler(
ControllerInfo browser, SessionCommand command, Bundle extras) {
return checkNotNull(
callback.onCustomCommand(instance, browser, command, extras),
- "onCustomCommandOnHandler must return non-null future");
+ "Callback.onCustomCommandOnHandler must return non-null future");
}
public void connectFromService(
@@ -502,14 +510,14 @@ import org.checkerframework.checker.initialization.qual.Initialized;
ControllerInfo controller, List mediaItems) {
return checkNotNull(
callback.onAddMediaItems(instance, controller, mediaItems),
- "onAddMediaItems must return a non-null future");
+ "Callback.onAddMediaItems must return a non-null future");
}
protected ListenableFuture onSetMediaItemsOnHandler(
ControllerInfo controller, List mediaItems, int startIndex, long startPositionMs) {
return checkNotNull(
callback.onSetMediaItems(instance, controller, mediaItems, startIndex, startPositionMs),
- "onSetMediaItems must return a non-null future");
+ "Callback.onSetMediaItems must return a non-null future");
}
protected boolean isReleased() {
@@ -594,6 +602,70 @@ import org.checkerframework.checker.initialization.qual.Initialized;
return true;
}
+ /**
+ * Attempts to prepare and play for playback resumption.
+ *
+ * If playlist data for playback resumption can be successfully obtained, the media items are
+ * set and the player is prepared. {@link Player#play()} is called regardless of success or
+ * failure of playback resumption.
+ *
+ * @param controller The controller requesting playback resumption.
+ * @param player The player to setup for playback resumption.
+ */
+ /* package */ void prepareAndPlayForPlaybackResumption(ControllerInfo controller, Player player) {
+ verifyApplicationThread();
+ @Nullable
+ ListenableFuture future =
+ checkNotNull(
+ callback.onPlaybackResumption(instance, controller),
+ "Callback.onPlaybackResumption must return a non-null future");
+ // Use a direct executor when an immediate future is returned to execute the player setup in the
+ // caller's looper event on the application thread.
+ Executor executor =
+ future.isDone()
+ ? MoreExecutors.directExecutor()
+ : ExecutorCompat.create(getApplicationHandler());
+ Futures.addCallback(
+ future,
+ new FutureCallback() {
+ @Override
+ public void onSuccess(MediaItemsWithStartPosition mediaItemsWithStartPosition) {
+ ImmutableList mediaItems = mediaItemsWithStartPosition.mediaItems;
+ player.setMediaItems(
+ mediaItems,
+ mediaItemsWithStartPosition.startIndex != C.INDEX_UNSET
+ ? min(mediaItems.size() - 1, mediaItemsWithStartPosition.startIndex)
+ : 0,
+ mediaItemsWithStartPosition.startPositionMs);
+ if (player.getPlaybackState() == Player.STATE_IDLE) {
+ player.prepare();
+ }
+ player.play();
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ if (t instanceof UnsupportedOperationException) {
+ Log.w(
+ TAG,
+ "UnsupportedOperationException: Make sure to implement"
+ + " MediaSession.Callback.onPlaybackResumption() if you add a"
+ + " media button receiver to your manifest or if you implement the recent"
+ + " media item contract with your MediaLibraryService.",
+ t);
+ } else {
+ Log.e(
+ TAG,
+ "Failure calling MediaSession.Callback.onPlaybackResumption(): " + t.getMessage(),
+ t);
+ }
+ // Play as requested either way.
+ Util.handlePlayButtonAction(player);
+ }
+ },
+ executor);
+ }
+
private void dispatchRemoteControllerTaskToLegacyStub(RemoteControllerTask task) {
try {
task.run(sessionLegacyStub.getControllerLegacyCbForBroadcast(), /* seq= */ 0);
@@ -727,6 +799,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
}
@Nullable
+ @SuppressWarnings("QueryPermissionsNeeded") // Needs to be provided in the app manifest.
private static ComponentName getServiceComponentByAction(Context context, String action) {
PackageManager pm = context.getPackageManager();
Intent queryIntent = new Intent(action);
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 1699f29c7c..210ba1abde 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionLegacyStub.java
@@ -135,6 +135,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
@Nullable private FutureCallback pendingBitmapLoadCallback;
private int sessionFlags;
+ @SuppressWarnings("PendingIntentMutability") // We can't use SaferPendingIntent
public MediaSessionLegacyStub(
MediaSessionImpl session,
Uri sessionUri,
@@ -215,6 +216,7 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
}
@Nullable
+ @SuppressWarnings("QueryPermissionsNeeded") // Needs to be provided in the app manifest.
private static ComponentName queryPackageManagerForMediaButtonReceiver(Context context) {
PackageManager pm = context.getPackageManager();
Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
@@ -382,7 +384,14 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
COMMAND_PLAY_PAUSE,
controller -> {
if (sessionImpl.onPlayRequested()) {
- Util.handlePlayButtonAction(sessionImpl.getPlayerWrapper());
+ PlayerWrapper playerWrapper = sessionImpl.getPlayerWrapper();
+ if (playerWrapper.getMediaItemCount() == 0) {
+ // The player is in IDLE or ENDED state and has no media items in the playlist yet.
+ // Handle the play command as a playback resumption command to try resume playback.
+ sessionImpl.prepareAndPlayForPlaybackResumption(controller, playerWrapper);
+ } else {
+ Util.handlePlayButtonAction(playerWrapper);
+ }
}
},
sessionCompat.getCurrentControllerInfo());
diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java
index e5cf3808f1..591e9a547f 100644
--- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java
+++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java
@@ -674,6 +674,10 @@ import java.util.concurrent.ExecutionException;
if (caller == null) {
return;
}
+ ControllerInfo controller = connectedControllersManager.getController(caller.asBinder());
+ if (controller == null) {
+ return;
+ }
queueSessionTaskWithPlayerCommand(
caller,
sequenceNumber,
@@ -684,9 +688,16 @@ import java.util.concurrent.ExecutionException;
if (sessionImpl == null || sessionImpl.isReleased()) {
return;
}
-
if (sessionImpl.onPlayRequested()) {
- player.play();
+ if (player.getMediaItemCount() == 0) {
+ // The player is in IDLE or ENDED state and has no media items in the playlist
+ // yet.
+ // Handle the play command as a playback resumption command to try resume
+ // playback.
+ sessionImpl.prepareAndPlayForPlaybackResumption(controller, player);
+ } else {
+ Util.handlePlayButtonAction(player);
+ }
}
}));
}
diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java
index 37ea204459..defc3a8f84 100644
--- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java
+++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionCallbackTest.java
@@ -55,6 +55,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
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;
@@ -214,7 +215,8 @@ public class MediaSessionCallbackTest {
controller.play();
player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
- assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isFalse();
+ // If IDLE, Util.handlePlayButtonAction(player) calls prepare also.
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
assertThat(commands).hasSize(2);
assertThat(commands.get(1)).isEqualTo(Player.COMMAND_PLAY_PAUSE);
}
@@ -810,6 +812,94 @@ public class MediaSessionCallbackTest {
assertThat(player.startPositionMs).isEqualTo(200);
}
+ @Test
+ public void onPlay_withEmptyTimelinePlaybackResumptionOn_callsOnGetPlaybackResumptionPlaylist()
+ throws Exception {
+ List mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
+ MediaSession.Callback callback =
+ new MediaSession.Callback() {
+ @Override
+ public ListenableFuture onPlaybackResumption(
+ MediaSession mediaSession, ControllerInfo controller) {
+ return Futures.immediateFuture(
+ new MediaSession.MediaItemsWithStartPosition(
+ mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 123L));
+ }
+ };
+ MediaSession session =
+ sessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, player).setCallback(callback).build());
+ RemoteMediaController controller =
+ controllerTestRule.createRemoteController(session.getToken());
+
+ controller.play();
+
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isTrue();
+ assertThat(player.startMediaItemIndex).isEqualTo(1);
+ assertThat(player.startPositionMs).isEqualTo(123L);
+ assertThat(player.mediaItems).isEqualTo(mediaItems);
+ }
+
+ @Test
+ public void onPlay_withEmptyTimelineCallbackFailure_callsHandlePlayButtonAction()
+ throws Exception {
+ player.startMediaItemIndex = 7;
+ player.startPositionMs = 321L;
+ MediaSession session =
+ sessionTestRule.ensureReleaseAfterTest(new MediaSession.Builder(context, player).build());
+ RemoteMediaController controller =
+ controllerTestRule.createRemoteController(session.getToken());
+
+ controller.play();
+
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isFalse();
+ assertThat(player.startMediaItemIndex).isEqualTo(7);
+ assertThat(player.startPositionMs).isEqualTo(321L);
+ assertThat(player.mediaItems).isEmpty();
+ }
+
+ @Test
+ public void onPlay_withNonEmptyTimeline_callsHandlePlayButtonAction() throws Exception {
+ player.timeline = new PlaylistTimeline(MediaTestUtils.createMediaItems(/* size= */ 3));
+ player.mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
+ player.startMediaItemIndex = 1;
+ player.startPositionMs = 321L;
+ MediaSession.Callback callback =
+ 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= */ C.TIME_UNSET));
+ }
+ };
+ MediaSession session =
+ sessionTestRule.ensureReleaseAfterTest(
+ new MediaSession.Builder(context, player).setCallback(callback).build());
+ RemoteMediaController controller =
+ controllerTestRule.createRemoteController(session.getToken());
+
+ controller.play();
+
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isFalse();
+ assertThat(player.startMediaItemIndex).isEqualTo(1);
+ assertThat(player.startPositionMs).isEqualTo(321L);
+ assertThat(player.mediaItems).hasSize(3);
+ }
+
@Test
public void onConnect() throws Exception {
AtomicReference connectionHints = new AtomicReference<>();
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 21ad778b88..484ccb9f0a 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
@@ -40,6 +40,7 @@ import android.support.v4.media.RatingCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat.QueueItem;
import android.support.v4.media.session.PlaybackStateCompat;
+import android.view.KeyEvent;
import androidx.media.AudioAttributesCompat;
import androidx.media.AudioManagerCompat;
import androidx.media3.common.AudioAttributes;
@@ -70,6 +71,7 @@ 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;
@@ -617,6 +619,104 @@ public class MediaSessionCallbackWithMediaControllerCompatTest {
assertThat(player.seekMediaItemIndex).isEqualTo(targetIndex);
}
+ @Test
+ public void dispatchMediaButtonEvent_playWithEmptyTimeline_callsPreparesPlayerCorrectly()
+ 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();
+ controller =
+ new RemoteMediaControllerCompat(
+ context, session.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);
+ 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);
+ }
+
+ @Test
+ public void
+ dispatchMediaButtonEvent_playWithEmptyTimelineCallbackFailure_callsHandlePlayButtonAction()
+ throws Exception {
+ player.mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3);
+ player.startMediaItemIndex = 1;
+ player.startPositionMs = 321L;
+ session = new MediaSession.Builder(context, player).setId("sendMediaButtonEvent").build();
+ controller =
+ new RemoteMediaControllerCompat(
+ context, session.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);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isFalse();
+ assertThat(player.startMediaItemIndex).isEqualTo(1);
+ assertThat(player.startPositionMs).isEqualTo(321L);
+ assertThat(player.mediaItems).hasSize(3);
+ }
+
+ @Test
+ public void dispatchMediaButtonEvent_playWithNonEmptyTimeline_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);
+ 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();
+ controller =
+ new RemoteMediaControllerCompat(
+ context, session.getSessionCompat().getSessionToken(), /* waitForConnection= */ true);
+
+ session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
+
+ player.awaitMethodCalled(MockPlayer.METHOD_PLAY, TIMEOUT_MS);
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_PREPARE)).isTrue();
+ assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_SET_MEDIA_ITEMS_WITH_START_INDEX))
+ .isFalse();
+ assertThat(player.startMediaItemIndex).isEqualTo(1);
+ assertThat(player.startPositionMs).isEqualTo(321L);
+ assertThat(player.mediaItems).hasSize(3);
+ }
+
@Test
public void setShuffleMode() throws Exception {
session =
diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java
index b945e5fa87..a4b3cb09cd 100644
--- a/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java
+++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java
@@ -871,7 +871,7 @@ public class MockPlayer implements Player {
@Override
public int getMediaItemCount() {
- throw new UnsupportedOperationException();
+ return timeline.getWindowCount();
}
@Override