diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java index 6759d78b44..9aae93f671 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java @@ -24,6 +24,8 @@ import static androidx.media3.session.LibraryResult.RESULT_ERROR_SESSION_AUTHENT import static androidx.media3.session.LibraryResult.RESULT_SUCCESS; import static androidx.media3.session.MediaConstants.ERROR_CODE_AUTHENTICATION_EXPIRED_COMPAT; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT; +import static java.lang.Math.max; +import static java.lang.Math.min; import android.app.PendingIntent; import android.content.Context; @@ -43,9 +45,11 @@ import androidx.media3.session.MediaLibraryService.MediaLibrarySession; import androidx.media3.session.MediaSession.ControllerCb; import androidx.media3.session.MediaSession.ControllerInfo; 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.util.HashSet; import java.util.List; import java.util.Objects; @@ -169,9 +173,14 @@ import java.util.concurrent.Future; int pageSize, @Nullable LibraryParams params) { if (Objects.equals(parentId, RECENT_LIBRARY_ROOT_MEDIA_ID)) { - // Advertise support for playback resumption, if enabled. - return !canResumePlaybackOnStart() - ? Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)) + if (!canResumePlaybackOnStart()) { + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); + } + // Advertise support for playback resumption. If STATE_IDLE, the request arrives at boot time + // to get the full item data to build a notification. If not STATE_IDLE we don't need to + // deliver the full media item, so we do the minimal viable effort. + return getPlayerWrapper().getPlaybackState() == Player.STATE_IDLE + ? getRecentMediaItemAtDeviceBootTime(browser, params) : Futures.immediateFuture( LibraryResult.ofItemList( ImmutableList.of( @@ -386,4 +395,38 @@ import java.util.concurrent.Future; } } } + + private ListenableFuture>> + getRecentMediaItemAtDeviceBootTime( + ControllerInfo controller, @Nullable LibraryParams params) { + SettableFuture>> settableFuture = + SettableFuture.create(); + ListenableFuture future = + callback.onPlaybackResumption(instance, controller); + Futures.addCallback( + future, + new FutureCallback() { + @Override + public void onSuccess(MediaSession.MediaItemsWithStartPosition playlist) { + if (playlist.mediaItems.isEmpty()) { + settableFuture.set( + LibraryResult.ofError(LibraryResult.RESULT_ERROR_INVALID_STATE, params)); + return; + } + int sanitizedStartIndex = + max(0, min(playlist.startIndex, playlist.mediaItems.size() - 1)); + settableFuture.set( + LibraryResult.ofItemList( + ImmutableList.of(playlist.mediaItems.get(sanitizedStartIndex)), params)); + } + + @Override + public void onFailure(Throwable t) { + settableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN, params)); + Log.e(TAG, "Failed fetching recent media item at boot time: " + t.getMessage(), t); + } + }, + MoreExecutors.directExecutor()); + return settableFuture; + } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibrarySessionCallbackTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibrarySessionCallbackTest.java index 3eedbacb6c..924bf4fa14 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibrarySessionCallbackTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibrarySessionCallbackTest.java @@ -23,6 +23,7 @@ import android.content.Context; import androidx.annotation.Nullable; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; +import androidx.media3.common.Player; import androidx.media3.session.MediaLibraryService.LibraryParams; import androidx.media3.session.MediaLibraryService.MediaLibrarySession; import androidx.media3.session.MediaSession.ControllerInfo; @@ -176,13 +177,23 @@ public class MediaLibrarySessionCallbackTest { } @Test - public void onGetChildren_systemUiCallForRecentItems_returnsRecentItems() throws Exception { + public void onGetChildren_systemUiCallForRecentItemsWhenIdle_callsOnPlaybackResumption() + throws Exception { ArrayList mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); MockMediaLibraryService service = new MockMediaLibraryService(); service.attachBaseContext(context); - CountDownLatch latch = new CountDownLatch(1); + CountDownLatch latch = new CountDownLatch(2); MediaLibrarySession.Callback callback = new MediaLibrarySession.Callback() { + @Override + public ListenableFuture onPlaybackResumption( + MediaSession mediaSession, ControllerInfo controller) { + latch.countDown(); + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + mediaItems, /* startIndex= */ 1, /* startPositionMs= */ 1000L)); + } + @Override public ListenableFuture>> onGetChildren( MediaLibrarySession session, @@ -203,6 +214,166 @@ public class MediaLibrarySessionCallbackTest { .build()); RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + LibraryResult> recentItem = + browser.getChildren( + "androidx.media3.session.recent.root", + /* page= */ 0, + /* pageSize= */ 100, + /* params= */ null); + // Load children of a non recent root that must not be intercepted. + LibraryResult> children = + browser.getChildren("children", /* page= */ 0, /* pageSize= */ 100, /* params= */ null); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(recentItem.resultCode).isEqualTo(LibraryResult.RESULT_SUCCESS); + assertThat(Lists.transform(recentItem.value, (item) -> item.mediaId)) + .containsExactly("mediaItem_2"); + assertThat(children.value).isEqualTo(mediaItems); + } + + @Test + public void + onGetChildren_systemUiCallForRecentItemsWhenIdleWithEmptyResumptionPlaylist_resultInvalidState() + throws Exception { + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + CountDownLatch latch = new CountDownLatch(1); + MediaLibrarySession.Callback callback = + new MediaLibrarySession.Callback() { + @Override + public ListenableFuture onPlaybackResumption( + MediaSession mediaSession, ControllerInfo controller) { + latch.countDown(); + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + ImmutableList.of(), /* startIndex= */ 11, /* startPositionMs= */ 1000L)); + } + }; + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder(service, player, callback) + .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") + .build()); + RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + + LibraryResult> recentItem = + browser.getChildren( + "androidx.media3.session.recent.root", + /* page= */ 0, + /* pageSize= */ 100, + /* params= */ null); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(recentItem.resultCode).isEqualTo(LibraryResult.RESULT_ERROR_INVALID_STATE); + } + + @Test + public void + onGetChildren_systemUiCallForRecentItemsWhenIdleStartIndexTooHigh_setToLastItemItemInList() + throws Exception { + ArrayList mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + CountDownLatch latch = new CountDownLatch(1); + MediaLibrarySession.Callback callback = + new MediaLibrarySession.Callback() { + @Override + public ListenableFuture onPlaybackResumption( + MediaSession mediaSession, ControllerInfo controller) { + latch.countDown(); + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + mediaItems, /* startIndex= */ 11, /* startPositionMs= */ 1000L)); + } + }; + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder(service, player, callback) + .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") + .build()); + RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + + LibraryResult> recentItem = + browser.getChildren( + "androidx.media3.session.recent.root", + /* page= */ 0, + /* pageSize= */ 100, + /* params= */ null); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(recentItem.resultCode).isEqualTo(LibraryResult.RESULT_SUCCESS); + assertThat(Lists.transform(recentItem.value, (item) -> item.mediaId)) + .containsExactly("mediaItem_3"); + } + + @Test + public void onGetChildren_systemUiCallForRecentItemsWhenIdleStartIndexNegative_setToZero() + throws Exception { + ArrayList mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + CountDownLatch latch = new CountDownLatch(1); + MediaLibrarySession.Callback callback = + new MediaLibrarySession.Callback() { + @Override + public ListenableFuture onPlaybackResumption( + MediaSession mediaSession, ControllerInfo controller) { + latch.countDown(); + return Futures.immediateFuture( + new MediaSession.MediaItemsWithStartPosition( + mediaItems, /* startIndex= */ -11, /* startPositionMs= */ 1000L)); + } + }; + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder(service, player, callback) + .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") + .build()); + RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + + LibraryResult> recentItem = + browser.getChildren( + "androidx.media3.session.recent.root", + /* page= */ 0, + /* pageSize= */ 100, + /* params= */ null); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(recentItem.resultCode).isEqualTo(LibraryResult.RESULT_SUCCESS); + assertThat(Lists.transform(recentItem.value, (item) -> item.mediaId)) + .containsExactly("mediaItem_1"); + } + + @Test + public void onGetChildren_systemUiCallForRecentItemsWhenNotIdle_returnsRecentItems() + throws Exception { + ArrayList mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + CountDownLatch latch = new CountDownLatch(1); + MediaLibrarySession.Callback callback = + new MediaLibrarySession.Callback() { + @Override + public ListenableFuture>> onGetChildren( + MediaLibrarySession session, + ControllerInfo browser, + String parentId, + int page, + int pageSize, + @Nullable LibraryParams params) { + latch.countDown(); + return Futures.immediateFuture( + LibraryResult.ofItemList(mediaItems, /* params= */ null)); + } + }; + player.playbackState = Player.STATE_READY; + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder(service, player, callback) + .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") + .build()); + RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + LibraryResult> recentItem = browser.getChildren( "androidx.media3.session.recent.root",