From e48dec5f2c5397bce53aac3835eefd025f8786c6 Mon Sep 17 00:00:00 2001 From: bachinger Date: Thu, 4 May 2023 20:17:20 +0000 Subject: [PATCH] Add MediaButtonReceiver for Media3 The media button has API support with `Callback.getPlaybackResumption()` that apps need to override to provide a playlist to resume playback with. Issue: androidx/media#167 PiperOrigin-RevId: 529495845 --- RELEASENOTES.md | 4 + .../media3/session/MediaButtonReceiver.java | 240 ++++++++++++++++++ .../androidx/media3/session/MediaSession.java | 15 ++ .../media3/session/MediaSessionImpl.java | 85 ++++++- .../session/MediaSessionLegacyStub.java | 11 +- .../media3/session/MediaSessionStub.java | 15 +- .../session/MediaSessionCallbackTest.java | 92 ++++++- ...CallbackWithMediaControllerCompatTest.java | 100 ++++++++ .../androidx/media3/session/MockPlayer.java | 2 +- 9 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 libraries/session/src/main/java/androidx/media3/session/MediaButtonReceiver.java 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