diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ca1e418b2f..4b9b5c5ea4 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -78,6 +78,13 @@ all supported API levels. * Fix bug where `MediaController.getCurrentPosition()` is not advancing when connected to a legacy `MediaSessionCompat`. + * Add `MediaLibrarySession.getSubscribedControllers(mediaId)` for + convenience. + * Override `MediaLibrarySession.Callback.onSubscribe()` to assert the + availability of the parent Id for which the controller subscribes. If + successful, the subscription is accepted and `notifyChildrenChanged()` + is called immediately to inform the browser + ([#561](https://github.com/androidx/media/issues/561)). * UI: * Downloads: * OkHttp Extension: diff --git a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt index fa27dbe2e9..c81bac520b 100644 --- a/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt +++ b/demos/session/src/main/java/androidx/media3/demo/session/PlaybackService.kt @@ -165,21 +165,6 @@ class PlaybackService : MediaLibraryService() { return Futures.immediateFuture(LibraryResult.ofItem(item, /* params= */ null)) } - override fun onSubscribe( - session: MediaLibrarySession, - browser: ControllerInfo, - parentId: String, - params: LibraryParams? - ): ListenableFuture> { - val children = - MediaItemTree.getChildren(parentId) - ?: return Futures.immediateFuture( - LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) - ) - session.notifyChildrenChanged(browser, parentId, children.size, params) - return Futures.immediateFuture(LibraryResult.ofVoid()) - } - override fun onGetChildren( session: MediaLibrarySession, browser: ControllerInfo, diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaBrowser.java b/libraries/session/src/main/java/androidx/media3/session/MediaBrowser.java index 8bdacfbcee..1507ea183e 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaBrowser.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaBrowser.java @@ -187,16 +187,19 @@ public final class MediaBrowser extends MediaController { public interface Listener extends MediaController.Listener { /** - * Called when there's change in the parent's children after you've subscribed to the parent + * Called when there's a change in the parent's children after you've subscribed to the parent * with {@link #subscribe}. * - *

This method is called when the library service called {@link - * MediaLibraryService.MediaLibrarySession#notifyChildrenChanged} for the parent. + *

This method is called when the app calls {@link + * MediaLibraryService.MediaLibrarySession#notifyChildrenChanged} for the parent, or it is + * called by the library immediately after calling {@link MediaBrowser#subscribe(String, + * LibraryParams)}. * * @param browser The browser for this event. * @param parentId The non-empty parent id that you've specified with {@link #subscribe(String, * LibraryParams)}. - * @param itemCount The number of children. + * @param itemCount The number of children, or {@link Integer#MAX_VALUE} if the number of items + * is unknown. * @param params The optional parameters from the library service. Can be differ from the {@code * params} that you've specified with {@link #subscribe(String, LibraryParams)}. */ diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java index 2f6d536cdc..7be92db023 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibraryService.java @@ -19,6 +19,8 @@ import static androidx.media3.common.util.Assertions.checkArgument; import static androidx.media3.common.util.Assertions.checkNotEmpty; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED; +import static androidx.media3.session.LibraryResult.RESULT_SUCCESS; +import static androidx.media3.session.LibraryResult.ofVoid; import android.app.PendingIntent; import android.content.Context; @@ -220,16 +222,28 @@ public abstract class MediaLibraryService extends MediaSessionService { * Called when a {@link MediaBrowser} subscribes to the given parent id by {@link * MediaBrowser#subscribe(String, LibraryParams)}. * + *

See {@link #getSubscribedControllers(String)} also. + * + *

By default, the library calls {@link Callback#onGetItem(MediaLibrarySession, + * ControllerInfo, String)} for the {@code parentId} that the browser requests to subscribe + * to. If onGetItem returns {@link LibraryResult#RESULT_SUCCESS} with a {@linkplain + * MediaMetadata#isBrowsable browsable item}, the subscription is accepted and {@link + * MediaLibrarySession#notifyChildrenChanged(ControllerInfo, String, int, LibraryParams)} is + * immediately called with an itemCount of {@link Integer#MAX_VALUE}. In all other cases, the + * subscription is rejected and a result value different to {@link + * LibraryResult#RESULT_SUCCESS} is returned from this method. + * + *

To implement a different behavior, an app can safely override this method without + * calling super. Return a result code {@link LibraryResult#RESULT_SUCCESS} to accept the + * subscription, or return a result different to {@link LibraryResult#RESULT_SUCCESS} to + * prevent controllers from subscribing. + * *

Return a {@link ListenableFuture} to send a {@link LibraryResult} back to the browser * asynchronously. You can also return a {@link LibraryResult} directly by using Guava's * {@link Futures#immediateFuture(Object)}. * - *

The {@link LibraryResult#params} should be the same as the given {@link LibraryParams - * params}. - * - *

It's your responsibility to keep subscriptions and call {@link - * MediaLibrarySession#notifyChildrenChanged(ControllerInfo, String, int, LibraryParams)} when - * the children of the parent are changed until it's {@link #onUnsubscribe unsubscribed}. + *

The {@link LibraryResult#params} returned to the caller should be the same as the {@link + * LibraryParams params} passed into this method. * *

Interoperability: This will be called by {@link * android.support.v4.media.MediaBrowserCompat#subscribe}, but won't be called by {@link @@ -247,17 +261,44 @@ public abstract class MediaLibraryService extends MediaSessionService { ControllerInfo browser, String parentId, @Nullable LibraryParams params) { - return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); + return Util.transformFutureAsync( + onGetItem(session, browser, parentId), + result -> { + if (result.resultCode != RESULT_SUCCESS + || result.value == null + || result.value.mediaMetadata.isBrowsable == null + || !result.value.mediaMetadata.isBrowsable) { + // Reject subscription if no browsable item for the parent media ID is returned. + return Futures.immediateFuture( + LibraryResult.ofError( + result.resultCode != RESULT_SUCCESS + ? result.resultCode + : LibraryResult.RESULT_ERROR_BAD_VALUE)); + } + if (browser.getControllerVersion() != ControllerInfo.LEGACY_CONTROLLER_VERSION) { + // For legacy browsers, android.service.media.MediaBrowserService already calls + // MediaLibraryServiceLegacyStub.onLoadChildren() to send the current media items + // to the browser callback. So we call back Media3 browsers only. + session.notifyChildrenChanged( + browser, parentId, /* itemCount= */ Integer.MAX_VALUE, params); + } + return Futures.immediateFuture(ofVoid()); + }); } /** - * Called when a {@link MediaBrowser} unsubscribes from the given parent id by {@link + * Called when a {@link MediaBrowser} unsubscribes from the given parent ID by {@link * MediaBrowser#unsubscribe(String)}. * *

Return a {@link ListenableFuture} to send a {@link LibraryResult} back to the browser * asynchronously. You can also return a {@link LibraryResult} directly by using Guava's * {@link Futures#immediateFuture(Object)}. * + *

Apps normally don't need to implement this method, because the library maintains the + * subscribed controllers internally and an app can use {@link + * #getSubscribedControllers(String)} to get subscribed controllers for which to call {@link + * #notifyChildrenChanged}. + * *

Interoperability: This will be called by {@link * android.support.v4.media.MediaBrowserCompat#unsubscribe}, but won't be called by {@link * android.media.browse.MediaBrowser#unsubscribe}. @@ -270,7 +311,7 @@ public abstract class MediaLibraryService extends MediaSessionService { */ default ListenableFuture> onUnsubscribe( MediaLibrarySession session, ControllerInfo browser, String parentId) { - return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); + return Futures.immediateFuture(LibraryResult.ofVoid()); } /** @@ -554,16 +595,31 @@ public abstract class MediaLibraryService extends MediaSessionService { } /** - * Notifies a browser that is {@link Callback#onSubscribe subscribing} to the change to a - * parent's children. If the browser isn't subscribing to the parent, nothing will happen. + * Returns the controllers that are currently subscribed to the given {@code mediaId}. * - *

This only tells the number of child {@link MediaItem media items}. {@link - * Callback#onGetChildren} will be called by the browser afterwards to get the list of {@link - * MediaItem media items}. + *

Use the returned {@linkplain ControllerInfo controller infos} to call {@link + * #notifyChildrenChanged} in case the children of the media item with the given media ID have + * changed and the connected controller should fetch them again. + * + *

Note that calling {@link #notifyChildrenChanged} for a controller that didn't subscribe to + * the media ID results in a no-op. + * + * @param mediaId The ID of the media item for which to get subscribed controllers. + * @return A list with the subscribed controllers, may be empty. + */ + @UnstableApi + public ImmutableList getSubscribedControllers(String mediaId) { + return getImpl().getSubscribedControllers(mediaId); + } + + /** + * Notifies a browser that is {@link Callback#onSubscribe subscribed} to a browsable media item + * that the children of the item have changed. This method is also called immediately after + * subscribing was successful. * * @param browser The browser to notify. * @param parentId The non-empty id of the parent with changes to its children. - * @param itemCount The number of children. + * @param itemCount The number of children, or {@link Integer#MAX_VALUE} if unknown. * @param params The parameters given by {@link Callback#onSubscribe}. */ public void notifyChildrenChanged( 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 d2bbba5e15..b25fda59be 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaLibrarySessionImpl.java @@ -17,8 +17,6 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkState; -import static androidx.media3.common.util.Assertions.checkStateNotNull; -import static androidx.media3.common.util.Util.postOrRun; import static androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED; import static androidx.media3.session.LibraryResult.RESULT_ERROR_SESSION_AUTHENTICATION_EXPIRED; import static androidx.media3.session.LibraryResult.RESULT_SUCCESS; @@ -32,25 +30,25 @@ import android.content.Context; import android.os.Bundle; import android.os.RemoteException; import android.support.v4.media.session.MediaSessionCompat; -import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; -import androidx.collection.ArrayMap; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.util.BitmapLoader; import androidx.media3.common.util.Log; +import androidx.media3.common.util.Util; import androidx.media3.session.MediaLibraryService.LibraryParams; import androidx.media3.session.MediaLibraryService.MediaLibrarySession; import androidx.media3.session.MediaSession.ControllerCb; import androidx.media3.session.MediaSession.ControllerInfo; +import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; 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; import java.util.Set; @@ -61,12 +59,10 @@ import java.util.concurrent.Future; /* package */ class MediaLibrarySessionImpl extends MediaSessionImpl { private static final String RECENT_LIBRARY_ROOT_MEDIA_ID = "androidx.media3.session.recent.root"; - private final MediaLibrarySession instance; private final MediaLibrarySession.Callback callback; - - @GuardedBy("lock") - private final ArrayMap> subscriptions; + private final HashMultimap parentIdToSubscribedControllers; + private final HashMultimap controllerToSubscribedParentIds; /** Creates an instance. */ public MediaLibrarySessionImpl( @@ -93,7 +89,8 @@ import java.util.concurrent.Future; playIfSuppressed); this.instance = instance; this.callback = callback; - subscriptions = new ArrayMap<>(); + parentIdToSubscribedControllers = HashMultimap.create(); + controllerToSubscribedParentIds = HashMultimap.create(); } @Override @@ -116,48 +113,6 @@ import java.util.concurrent.Future; && legacyStub.getConnectedControllersManager().isConnected(controller); } - public void notifyChildrenChanged( - String parentId, int itemCount, @Nullable LibraryParams params) { - dispatchRemoteControllerTaskWithoutReturn( - (callback, seq) -> { - if (isSubscribed(callback, parentId)) { - callback.onChildrenChanged(seq, parentId, itemCount, params); - } - }); - } - - public void notifyChildrenChanged( - ControllerInfo browser, String parentId, int itemCount, @Nullable LibraryParams params) { - if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) { - ControllerInfo systemUiBrowser = getSystemUiControllerInfo(); - if (systemUiBrowser == null) { - return; - } - browser = systemUiBrowser; - } - dispatchRemoteControllerTaskWithoutReturn( - browser, - (callback, seq) -> { - if (!isSubscribed(callback, parentId)) { - return; - } - callback.onChildrenChanged(seq, parentId, itemCount, params); - }); - } - - public void notifySearchResultChanged( - ControllerInfo browser, String query, int itemCount, @Nullable LibraryParams params) { - if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) { - ControllerInfo systemUiBrowser = getSystemUiControllerInfo(); - if (systemUiBrowser == null) { - return; - } - browser = systemUiBrowser; - } - dispatchRemoteControllerTaskWithoutReturn( - browser, (callback, seq) -> callback.onSearchResultChanged(seq, query, itemCount, params)); - } - public ListenableFuture> onGetLibraryRootOnHandler( ControllerInfo browser, @Nullable LibraryParams params) { if (params != null && params.isRecent && isSystemUiController(browser)) { @@ -185,7 +140,7 @@ import java.util.concurrent.Future; maybeUpdateLegacyErrorState(result); } }, - (Runnable r) -> postOrRun(getApplicationHandler(), r)); + this::postOrRunOnApplicationHandler); return future; } @@ -228,7 +183,7 @@ import java.util.concurrent.Future; verifyResultItems(result, pageSize); } }, - (Runnable r) -> postOrRun(getApplicationHandler(), r)); + this::postOrRunOnApplicationHandler); return future; } @@ -243,21 +198,16 @@ import java.util.concurrent.Future; maybeUpdateLegacyErrorState(result); } }, - (Runnable r) -> postOrRun(getApplicationHandler(), r)); + this::postOrRunOnApplicationHandler); return future; } public ListenableFuture> onSubscribeOnHandler( ControllerInfo browser, String parentId, @Nullable LibraryParams params) { - ControllerCb controller = checkStateNotNull(browser.getControllerCb()); - synchronized (lock) { - @Nullable Set subscription = subscriptions.get(controller); - if (subscription == null) { - subscription = new HashSet<>(); - subscriptions.put(controller, subscription); - } - subscription.add(parentId); - } + + ControllerCb controllerCb = checkNotNull(browser.getControllerCb()); + controllerToSubscribedParentIds.put(controllerCb, parentId); + parentIdToSubscribedControllers.put(parentId, browser); // Call callbacks after adding it to the subscription list because library session may want // to call notifyChildrenChanged() in the callback. @@ -270,31 +220,65 @@ import java.util.concurrent.Future; instance, resolveControllerInfoForCallback(browser), parentId, params), "onSubscribe must return non-null future"); - // When error happens, remove from the subscription list. future.addListener( () -> { @Nullable LibraryResult result = tryGetFutureResult(future); if (result == null || result.resultCode != RESULT_SUCCESS) { - removeSubscription(controller, parentId); + // Remove subscription in case of an error. + removeSubscription(browser, parentId); } }, - MoreExecutors.directExecutor()); + this::postOrRunOnApplicationHandler); return future; } + public ImmutableList getSubscribedControllers(String mediaId) { + return ImmutableList.copyOf(parentIdToSubscribedControllers.get(mediaId)); + } + + private boolean isSubscribed(ControllerCb controllerCb, String parentId) { + return controllerToSubscribedParentIds.containsEntry(controllerCb, parentId); + } + public ListenableFuture> onUnsubscribeOnHandler( ControllerInfo browser, String parentId) { ListenableFuture> future = callback.onUnsubscribe(instance, resolveControllerInfoForCallback(browser), parentId); - future.addListener( - () -> removeSubscription(checkStateNotNull(browser.getControllerCb()), parentId), - MoreExecutors.directExecutor()); - + () -> removeSubscription(browser, parentId), this::postOrRunOnApplicationHandler); return future; } + public void notifyChildrenChanged( + String parentId, int itemCount, @Nullable LibraryParams params) { + dispatchRemoteControllerTaskWithoutReturn( + (callback, seq) -> { + if (isSubscribed(callback, parentId)) { + callback.onChildrenChanged(seq, parentId, itemCount, params); + } + }); + } + + public void notifyChildrenChanged( + ControllerInfo browser, String parentId, int itemCount, @Nullable LibraryParams params) { + if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) { + ControllerInfo systemUiBrowser = getSystemUiControllerInfo(); + if (systemUiBrowser == null) { + return; + } + browser = systemUiBrowser; + } + dispatchRemoteControllerTaskWithoutReturn( + browser, + (callback, seq) -> { + if (!isSubscribed(callback, parentId)) { + return; + } + callback.onChildrenChanged(seq, parentId, itemCount, params); + }); + } + public ListenableFuture> onSearchOnHandler( ControllerInfo browser, String query, @Nullable LibraryParams params) { ListenableFuture> future = @@ -306,7 +290,7 @@ import java.util.concurrent.Future; maybeUpdateLegacyErrorState(result); } }, - (Runnable r) -> postOrRun(getApplicationHandler(), r)); + this::postOrRunOnApplicationHandler); return future; } @@ -327,10 +311,33 @@ import java.util.concurrent.Future; verifyResultItems(result, pageSize); } }, - (Runnable r) -> postOrRun(getApplicationHandler(), r)); + this::postOrRunOnApplicationHandler); return future; } + public void notifySearchResultChanged( + ControllerInfo browser, String query, int itemCount, @Nullable LibraryParams params) { + if (isMediaNotificationControllerConnected() && isMediaNotificationController(browser)) { + ControllerInfo systemUiBrowser = getSystemUiControllerInfo(); + if (systemUiBrowser == null) { + return; + } + browser = systemUiBrowser; + } + dispatchRemoteControllerTaskWithoutReturn( + browser, (callback, seq) -> callback.onSearchResultChanged(seq, query, itemCount, params)); + } + + @Override + public void onDisconnectedOnHandler(ControllerInfo controller) { + ControllerCb controllerCb = checkNotNull(controller.getControllerCb()); + Set subscriptions = controllerToSubscribedParentIds.get(controllerCb); + for (String parentId : ImmutableSet.copyOf(subscriptions)) { + removeSubscription(controller, parentId); + } + super.onDisconnectedOnHandler(controller); + } + @Override @Nullable protected MediaLibraryServiceLegacyStub getLegacyBrowserService() { @@ -358,16 +365,6 @@ import java.util.concurrent.Future; } } - private boolean isSubscribed(ControllerCb callback, String parentId) { - synchronized (lock) { - @Nullable Set subscriptions = this.subscriptions.get(callback); - if (subscriptions == null || !subscriptions.contains(parentId)) { - return false; - } - } - return true; - } - private void maybeUpdateLegacyErrorState(LibraryResult result) { PlayerWrapper playerWrapper = getPlayerWrapper(); if (result.resultCode == RESULT_ERROR_SESSION_AUTHENTICATION_EXPIRED @@ -410,16 +407,14 @@ import java.util.concurrent.Future; } } - private void removeSubscription(ControllerCb controllerCb, String parentId) { - synchronized (lock) { - @Nullable Set subscription = subscriptions.get(controllerCb); - if (subscription != null) { - subscription.remove(parentId); - if (subscription.isEmpty()) { - subscriptions.remove(controllerCb); - } - } - } + private void removeSubscription(ControllerInfo controllerInfo, String parentId) { + ControllerCb controllerCb = checkNotNull(controllerInfo.getControllerCb()); + parentIdToSubscribedControllers.remove(parentId, controllerInfo); + controllerToSubscribedParentIds.remove(controllerCb, parentId); + } + + private void postOrRunOnApplicationHandler(Runnable runnable) { + Util.postOrRun(getApplicationHandler(), runnable); } private ListenableFuture>> 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 ded22c8e88..0901b28f96 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionImpl.java @@ -97,7 +97,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private static final SessionResult RESULT_WHEN_CLOSED = new SessionResult(RESULT_INFO_SKIPPED); - protected final Object lock = new Object(); + private final Object lock = new Object(); private final Uri sessionUri; private final PlayerInfoChangedHandler onPlayerInfoChangedHandler; diff --git a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaBrowserConstants.java b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaBrowserConstants.java index 9ef0441900..5ae2024cdc 100644 --- a/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaBrowserConstants.java +++ b/libraries/test_session_common/src/main/java/androidx/media3/test/session/common/MediaBrowserConstants.java @@ -56,16 +56,16 @@ public class MediaBrowserConstants { public static final List SEARCH_RESULT = new ArrayList<>(); public static final int SEARCH_RESULT_COUNT = 50; - public static final String SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL = - "subscribe_id_notify_children_changed_to_all"; - public static final String SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE = - "subscribe_id_notify_children_changed_to_one"; - public static final String SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL_WITH_NON_SUBSCRIBED_ID = - "subscribe_id_notify_children_changed_to_all_with_non_subscribed_id"; - public static final String SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE_WITH_NON_SUBSCRIBED_ID = - "subscribe_id_notify_children_changed_to_one_with_non_subscribed_id"; - public static final int NOTIFY_CHILDREN_CHANGED_ITEM_COUNT = 101; - public static final Bundle NOTIFY_CHILDREN_CHANGED_EXTRAS = TestUtils.createTestBundle(); + public static final String SUBSCRIBE_PARENT_ID_1 = "subscribe_parent_id_1"; + public static final String SUBSCRIBE_PARENT_ID_2 = "subscribe_parent_id_2"; + public static final String EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_MEDIA_ID = + "notify_children_changed_media_id"; + public static final String EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_ITEM_COUNT = + "notify_children_changed_item_count"; + public static final String EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_DELAY_MS = + "notify_children_changed_delay"; + public static final String EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_BROADCAST = + "notify_children_changed_broadcast"; public static final String CUSTOM_ACTION = "customAction"; public static final Bundle CUSTOM_ACTION_EXTRAS = new Bundle(); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaBrowserListenerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaBrowserListenerTest.java index 5884f6c6ba..ef069a1e6c 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaBrowserListenerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaBrowserListenerTest.java @@ -19,22 +19,18 @@ import static androidx.media3.session.LibraryResult.RESULT_ERROR_BAD_VALUE; import static androidx.media3.session.LibraryResult.RESULT_SUCCESS; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_COMPLETION_STATUS; import static androidx.media3.session.MediaConstants.EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED; +import static androidx.media3.session.MockMediaLibraryService.createNotifyChildrenChangedBundle; import static androidx.media3.test.session.common.CommonConstants.MOCK_MEDIA3_LIBRARY_SERVICE; import static androidx.media3.test.session.common.MediaBrowserConstants.CUSTOM_ACTION_ASSERT_PARAMS; import static androidx.media3.test.session.common.MediaBrowserConstants.LONG_LIST_COUNT; -import static androidx.media3.test.session.common.MediaBrowserConstants.NOTIFY_CHILDREN_CHANGED_EXTRAS; -import static androidx.media3.test.session.common.MediaBrowserConstants.NOTIFY_CHILDREN_CHANGED_ITEM_COUNT; import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_EXTRAS; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_EXTRAS_KEY; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_EXTRAS_VALUE; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_ID; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL_WITH_NON_SUBSCRIBED_ID; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE_WITH_NON_SUBSCRIBED_ID; +import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_PARENT_ID_1; +import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_PARENT_ID_2; import static androidx.media3.test.session.common.TestUtils.LONG_TIMEOUT_MS; -import static androidx.media3.test.session.common.TestUtils.NO_RESPONSE_TIMEOUT_MS; import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -48,6 +44,8 @@ import androidx.media3.test.session.common.TestUtils; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -431,7 +429,7 @@ public class MediaBrowserListenerTest extends MediaControllerListenerTest { @Test public void onChildrenChanged_calledWhenSubscribed() throws Exception { // This test uses MediaLibrarySession.notifyChildrenChanged(). - String expectedParentId = SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL; + String expectedParentId = SUBSCRIBE_PARENT_ID_1; CountDownLatch latch = new CountDownLatch(1); AtomicReference parentIdRef = new AtomicReference<>(); @@ -453,7 +451,7 @@ public class MediaBrowserListenerTest extends MediaControllerListenerTest { LibraryResult result = threadTestRule .getHandler() - .postAndSync(() -> browser.subscribe(expectedParentId, null)) + .postAndSync(() -> browser.subscribe(expectedParentId, /* params= */ null)) .get(TIMEOUT_MS, MILLISECONDS); assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); @@ -461,27 +459,23 @@ public class MediaBrowserListenerTest extends MediaControllerListenerTest { // notifyChildrenChanged() in its onSubscribe() method. assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); assertThat(parentIdRef.get()).isEqualTo(expectedParentId); - assertThat(itemCountRef.get()).isEqualTo(NOTIFY_CHILDREN_CHANGED_ITEM_COUNT); - MediaTestUtils.assertLibraryParamsEquals(paramsRef.get(), NOTIFY_CHILDREN_CHANGED_EXTRAS); + assertThat(itemCountRef.get()).isEqualTo(Integer.MAX_VALUE); } @Test - public void onChildrenChanged_calledWhenSubscribed2() throws Exception { - // This test uses MediaLibrarySession.notifyChildrenChanged(ControllerInfo). - String expectedParentId = SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE; + public void onChildrenChanged_calledWhenSubscribedAndWithDelay() throws Exception { + String expectedParentId = SUBSCRIBE_PARENT_ID_2; - CountDownLatch latch = new CountDownLatch(1); - AtomicReference parentIdRef = new AtomicReference<>(); - AtomicInteger itemCountRef = new AtomicInteger(); - AtomicReference paramsRef = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(2); + List parentIds = new ArrayList<>(); + List itemCounts = new ArrayList<>(); MediaBrowser.Listener browserListenerProxy = new MediaBrowser.Listener() { @Override public void onChildrenChanged( MediaBrowser browser, String parentId, int itemCount, LibraryParams params) { - parentIdRef.set(parentId); - itemCountRef.set(itemCount); - paramsRef.set(params); + parentIds.add(parentId); + itemCounts.add(itemCount); latch.countDown(); } }; @@ -490,75 +484,108 @@ public class MediaBrowserListenerTest extends MediaControllerListenerTest { LibraryResult result = threadTestRule .getHandler() - .postAndSync(() -> browser.subscribe(expectedParentId, null)) + .postAndSync( + () -> { + // Bundle to request to call onChildrenChanged() after a given delay. + Bundle requestNotifyChildren = + createNotifyChildrenChangedBundle( + expectedParentId, + /* itemCount= */ 12, + /* delayMs= */ 250L, + /* broadcast= */ false); + return browser.subscribe( + expectedParentId, + new LibraryParams.Builder().setExtras(requestNotifyChildren).build()); + }) .get(TIMEOUT_MS, MILLISECONDS); assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); - // The MediaLibrarySession in MockMediaLibraryService is supposed to call - // notifyChildrenChanged(ControllerInfo) in its onSubscribe() method. assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); - assertThat(parentIdRef.get()).isEqualTo(expectedParentId); - assertThat(itemCountRef.get()).isEqualTo(NOTIFY_CHILDREN_CHANGED_ITEM_COUNT); - MediaTestUtils.assertLibraryParamsEquals(paramsRef.get(), NOTIFY_CHILDREN_CHANGED_EXTRAS); + assertThat(parentIds).containsExactly(expectedParentId, expectedParentId); + assertThat(itemCounts).containsExactly(Integer.MAX_VALUE, 12); } @Test public void onChildrenChanged_notCalledWhenNotSubscribed() throws Exception { - // This test uses MediaLibrarySession.notifyChildrenChanged(). - String subscribedMediaId = SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL_WITH_NON_SUBSCRIBED_ID; - CountDownLatch latch = new CountDownLatch(1); - - MediaBrowser.Listener browserListenerProxy = + String mediaId1 = SUBSCRIBE_PARENT_ID_1; + String mediaId2 = SUBSCRIBE_PARENT_ID_2; + List notifiedParentIds = new ArrayList<>(); + List notifiedItemCounts = new ArrayList<>(); + CountDownLatch childrenChangedLatch = new CountDownLatch(4); + CountDownLatch disconnectLatch = new CountDownLatch(2); + MediaBrowser.Listener browserListener = new MediaBrowser.Listener() { @Override public void onChildrenChanged( MediaBrowser browser, String parentId, int itemCount, LibraryParams params) { - latch.countDown(); + notifiedParentIds.add(parentId); + notifiedItemCounts.add(itemCount); + childrenChangedLatch.countDown(); } - }; - MediaBrowser browser = createBrowser(null, browserListenerProxy); - LibraryResult result = - threadTestRule - .getHandler() - .postAndSync(() -> browser.subscribe(subscribedMediaId, null)) - .get(TIMEOUT_MS, MILLISECONDS); - assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); - - // The MediaLibrarySession in MockMediaLibraryService is supposed to call - // notifyChildrenChanged() in its onSubscribe() method, but with a different media ID. - // Therefore, onChildrenChanged() should not be called. - assertThat(latch.await(NO_RESPONSE_TIMEOUT_MS, MILLISECONDS)).isFalse(); - } - - @Test - public void onChildrenChanged_notCalledWhenNotSubscribed2() throws Exception { - // This test uses MediaLibrarySession.notifyChildrenChanged(ControllerInfo). - String subscribedMediaId = SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE_WITH_NON_SUBSCRIBED_ID; - CountDownLatch latch = new CountDownLatch(1); - - MediaBrowser.Listener browserListenerProxy = - new MediaBrowser.Listener() { @Override - public void onChildrenChanged( - MediaBrowser browser, String parentId, int itemCount, LibraryParams params) { - latch.countDown(); + public void onDisconnected(MediaController controller) { + disconnectLatch.countDown(); } }; - - MediaBrowser browser = createBrowser(null, browserListenerProxy); - LibraryResult result = + MediaBrowser browser1 = createBrowser(/* connectionHints= */ null, browserListener); + MediaBrowser browser2 = createBrowser(/* connectionHints= */ null, browserListener); + // Subscribe both browsers each to a different media IDs and request a second update after a + // delay. + LibraryResult subscriptionResult1 = threadTestRule .getHandler() - .postAndSync(() -> browser.subscribe(subscribedMediaId, null)) + .postAndSync( + () -> { + Bundle requestNotifyChildren = + createNotifyChildrenChangedBundle( + mediaId1, + /* itemCount= */ 123, + /* delayMs= */ 200L, + /* broadcast= */ true); + return browser1.subscribe( + mediaId1, + new LibraryParams.Builder().setExtras(requestNotifyChildren).build()); + }) .get(TIMEOUT_MS, MILLISECONDS); - assertThat(result.resultCode).isEqualTo(RESULT_SUCCESS); + assertThat(subscriptionResult1.resultCode).isEqualTo(RESULT_SUCCESS); + LibraryResult result2 = + threadTestRule + .getHandler() + .postAndSync( + () -> { + Bundle requestNotifyChildren = + createNotifyChildrenChangedBundle( + mediaId2, + /* itemCount= */ 567, + /* delayMs= */ 200L, + /* broadcast= */ true); + return browser2.subscribe( + mediaId2, + new LibraryParams.Builder().setExtras(requestNotifyChildren).build()); + }) + .get(TIMEOUT_MS, MILLISECONDS); + assertThat(result2.resultCode).isEqualTo(RESULT_SUCCESS); - // The MediaLibrarySession in MockMediaLibraryService is supposed to call - // notifyChildrenChanged(ControllerInfo) in its onSubscribe() method, but - // with a different media ID. - // Therefore, onChildrenChanged() should not be called. - assertThat(latch.await(NO_RESPONSE_TIMEOUT_MS, MILLISECONDS)).isFalse(); + assertThat(childrenChangedLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(notifiedParentIds) + .containsExactly( + mediaId1, // callback when subscribing browser1 + mediaId2, // callback when subscribing browser2 + mediaId1, // callback on first delayed notification + mediaId2) // callback on second delayed notification + .inOrder(); + assertThat(notifiedItemCounts) + .containsExactly(Integer.MAX_VALUE, Integer.MAX_VALUE, 123, 567) + .inOrder(); + threadTestRule + .getHandler() + .postAndSync( + () -> { + browser1.release(); + browser2.release(); + }); + assertThat(disconnectLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); } private void setExpectedLibraryParam(MediaBrowser browser, LibraryParams params) diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibraryServiceTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibraryServiceTest.java index 48f94c1a61..d975326414 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibraryServiceTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaLibraryServiceTest.java @@ -20,6 +20,7 @@ import static com.google.common.truth.Truth.assertThat; import android.content.ComponentName; import android.content.Context; +import android.os.Bundle; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; @@ -100,7 +101,8 @@ public class MediaLibraryServiceTest { return session.get(); }); // Create the remote browser to start the service. - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(token); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(token, /* connectionHints= */ Bundle.EMPTY); // Get the started service instance after creation. MockMediaLibraryService service = (MockMediaLibraryService) testServiceRegistry.getServiceInstance(); 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 924bf4fa14..7361fe5b1e 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 @@ -15,11 +15,15 @@ */ package androidx.media3.session; +import static androidx.media3.session.LibraryResult.RESULT_ERROR_NOT_SUPPORTED; +import static androidx.media3.session.LibraryResult.RESULT_ERROR_SESSION_SETUP_REQUIRED; +import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_PARENT_ID_1; import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.content.Context; +import android.os.Bundle; import androidx.annotation.Nullable; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; @@ -37,7 +41,9 @@ import com.google.common.collect.Lists; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.ClassRule; import org.junit.Rule; @@ -77,11 +83,13 @@ public class MediaLibrarySessionCallbackTest { } @Test - public void onSubscribe() throws Exception { - String testParentId = "testSubscribeId"; + public void onSubscribeUnsubscribe() throws Exception { + String testParentId = "testSubscribeUnsubscribeId"; LibraryParams testParams = MediaTestUtils.createLibraryParams(); - - CountDownLatch latch = new CountDownLatch(1); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference libraryParamsRef = new AtomicReference<>(); + List subscribedControllers = new ArrayList<>(); + List parentIds = new ArrayList<>(); MediaLibrarySession.Callback sessionCallback = new MediaLibrarySession.Callback() { @Override @@ -90,24 +98,154 @@ public class MediaLibrarySessionCallbackTest { ControllerInfo browser, String parentId, @Nullable LibraryParams params) { - assertThat(parentId).isEqualTo(testParentId); - MediaTestUtils.assertLibraryParamsEquals(testParams, params); + parentIds.add(parentId); + libraryParamsRef.set(params); + subscribedControllers.addAll(session.getSubscribedControllers(parentId)); latch.countDown(); return Futures.immediateFuture(LibraryResult.ofVoid(params)); } - }; + @Override + public ListenableFuture> onUnsubscribe( + MediaLibrarySession session, ControllerInfo browser, String parentId) { + parentIds.add(parentId); + subscribedControllers.addAll(session.getSubscribedControllers(parentId)); + latch.countDown(); + return Futures.immediateFuture(LibraryResult.ofVoid()); + } + }; MockMediaLibraryService service = new MockMediaLibraryService(); service.attachBaseContext(context); - MediaLibrarySession session = sessionTestRule.ensureReleaseAfterTest( new MediaLibrarySession.Builder(service, player, sessionCallback) .setId("testOnSubscribe") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean("onSubscribeTestBrowser", true); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), connectionHints); + browser.subscribe(testParentId, testParams); + browser.unsubscribe(testParentId); + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(parentIds).containsExactly(testParentId, testParentId); + MediaTestUtils.assertLibraryParamsEquals(testParams, libraryParamsRef.get()); + assertThat(subscribedControllers).hasSize(2); + assertThat( + subscribedControllers + .get(0) + .getConnectionHints() + .getBoolean("onSubscribeTestBrowser", /* defaultValue= */ false)) + .isTrue(); + assertThat( + subscribedControllers + .get(1) + .getConnectionHints() + .getBoolean("onSubscribeTestBrowser", /* defaultValue= */ false)) + .isTrue(); + // After unsubscribing the list of subscribed controllers is empty. + assertThat(session.getSubscribedControllers(testParentId)).isEmpty(); + } + + @Test + public void onSubscribe_returnsNonSuccessResult_subscribedControllerNotRegistered() + throws Exception { + String testParentId = "onSubscribe_returnsNoSuccessResult_subscribedControllerNotRegistered"; + LibraryParams testParams = MediaTestUtils.createLibraryParams(); + CountDownLatch latch = new CountDownLatch(1); + List subscribedControllers = new ArrayList<>(); + MediaLibrarySession.Callback sessionCallback = + new MediaLibrarySession.Callback() { + @Override + public ListenableFuture> onSubscribe( + MediaLibrarySession session, + ControllerInfo browser, + String parentId, + @Nullable LibraryParams params) { + latch.countDown(); + subscribedControllers.addAll(session.getSubscribedControllers(parentId)); + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); + } + }; + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder(service, player, sessionCallback) + .setId("testOnSubscribe") + .build()); + Bundle connectionHints = new Bundle(); + connectionHints.putBoolean("onSubscribeTestBrowser", true); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), connectionHints); + + browser.subscribe(testParentId, testParams); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + // Inside the callback the subscribed controller is available even when not returning + // `RESULT_SUCCESS`. It will be removed after the result has been received. + assertThat(subscribedControllers).hasSize(1); + assertThat( + subscribedControllers + .get(0) + .getConnectionHints() + .getBoolean("onSubscribeTestBrowser", /* defaultValue= */ false)) + .isTrue(); + // After subscribing the list of subscribed controllers is empty, because the callback returns a + // result different to `RESULT_SUCCESS`. + assertThat(session.getSubscribedControllers(testParentId)).isEmpty(); + } + + @Test + public void onSubscribe_onGetItemNotImplemented_errorNotSupported() throws Exception { + String testParentId = SUBSCRIBE_PARENT_ID_1; + LibraryParams testParams = MediaTestUtils.createLibraryParams(); + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder(service, player, new MediaLibrarySession.Callback() {}) + .setId("testOnSubscribe") + .build()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); + + int resultCode = browser.subscribe(testParentId, testParams).resultCode; + + assertThat(session.getSubscribedControllers(testParentId)).isEmpty(); + assertThat(resultCode).isEqualTo(RESULT_ERROR_NOT_SUPPORTED); + assertThat(session.getSubscribedControllers(testParentId)).isEmpty(); + } + + @Test + public void onSubscribe_onGetItemNotSucceeded_correctErrorCodeReported() throws Exception { + LibraryParams testParams = MediaTestUtils.createLibraryParams(); + MockMediaLibraryService service = new MockMediaLibraryService(); + service.attachBaseContext(context); + MediaLibrarySession session = + sessionTestRule.ensureReleaseAfterTest( + new MediaLibrarySession.Builder( + service, + player, + new MediaLibrarySession.Callback() { + @Override + public ListenableFuture> onGetItem( + MediaLibrarySession session, ControllerInfo browser, String mediaId) { + return Futures.immediateFuture( + LibraryResult.ofError(RESULT_ERROR_SESSION_SETUP_REQUIRED)); + } + }) + .setId("testOnSubscribe") + .build()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); + + int resultCode = browser.subscribe(SUBSCRIBE_PARENT_ID_1, testParams).resultCode; + + assertThat(resultCode).isEqualTo(RESULT_ERROR_SESSION_SETUP_REQUIRED); + assertThat(session.getSubscribedControllers(SUBSCRIBE_PARENT_ID_1)).isEmpty(); } @Test @@ -115,12 +253,15 @@ public class MediaLibrarySessionCallbackTest { String testParentId = "testUnsubscribeId"; CountDownLatch latch = new CountDownLatch(1); + List subscribedControllers = new ArrayList<>(); + List parentIds = new ArrayList<>(); MediaLibrarySession.Callback sessionCallback = new MediaLibrarySession.Callback() { @Override public ListenableFuture> onUnsubscribe( MediaLibrarySession session, ControllerInfo browser, String parentId) { - assertThat(parentId).isEqualTo(testParentId); + parentIds.add(parentId); + subscribedControllers.addAll(session.getSubscribedControllers(parentId)); latch.countDown(); return Futures.immediateFuture(LibraryResult.ofVoid()); } @@ -134,9 +275,14 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, sessionCallback) .setId("testOnUnsubscribe") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser( + session.getToken(), /* connectionHints= */ Bundle.EMPTY); browser.unsubscribe(testParentId); assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(parentIds).containsExactly(testParentId); + // The browser wasn't subscribed. + assertThat(subscribedControllers).isEmpty(); } @Test @@ -167,7 +313,8 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, callback) .setId("onGetChildren_callForRecentRootNonSystemUiPackageName_notIntercepted") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); LibraryResult libraryRoot = browser.getLibraryRoot(new LibraryParams.Builder().setRecent(true).build()); @@ -212,7 +359,8 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, callback) .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); LibraryResult> recentItem = browser.getChildren( @@ -254,7 +402,8 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, callback) .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); LibraryResult> recentItem = browser.getChildren( @@ -291,7 +440,8 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, callback) .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); LibraryResult> recentItem = browser.getChildren( @@ -329,7 +479,8 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, callback) .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); LibraryResult> recentItem = browser.getChildren( @@ -372,7 +523,8 @@ public class MediaLibrarySessionCallbackTest { new MediaLibrarySession.Builder(service, player, callback) .setId("onGetChildren_systemUiCallForRecentItems_returnsRecentItems") .build()); - RemoteMediaBrowser browser = controllerTestRule.createRemoteBrowser(session.getToken()); + RemoteMediaBrowser browser = + controllerTestRule.createRemoteBrowser(session.getToken(), Bundle.EMPTY); LibraryResult> recentItem = browser.getChildren( diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/RemoteControllerTestRule.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/RemoteControllerTestRule.java index ba9e4b4e6f..55cf4c92ee 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/RemoteControllerTestRule.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/RemoteControllerTestRule.java @@ -93,10 +93,10 @@ public final class RemoteControllerTestRule extends ExternalResource { * Creates {@link RemoteMediaBrowser} from {@link SessionToken} with default options waiting for * connection. */ - public RemoteMediaBrowser createRemoteBrowser(SessionToken token) throws RemoteException { + public RemoteMediaBrowser createRemoteBrowser(SessionToken token, Bundle connectionHints) + throws RemoteException { RemoteMediaBrowser browser = - new RemoteMediaBrowser( - context, token, /* waitForConnection= */ true, /* connectionHints= */ null); + new RemoteMediaBrowser(context, token, /* waitForConnection= */ true, connectionHints); controllers.add(browser); return browser; } diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MockMediaLibraryService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MockMediaLibraryService.java index 5192822e71..8888ad26ef 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MockMediaLibraryService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MockMediaLibraryService.java @@ -16,28 +16,31 @@ package androidx.media3.session; import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.session.LibraryResult.RESULT_ERROR_BAD_VALUE; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_COMPLETION_STATUS; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_INTENT_COMPAT; import static androidx.media3.session.MediaConstants.EXTRAS_KEY_ERROR_RESOLUTION_ACTION_LABEL_COMPAT; import static androidx.media3.session.MediaConstants.EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED; import static androidx.media3.session.MediaConstants.EXTRA_KEY_ROOT_CHILDREN_BROWSABLE_ONLY; -import static androidx.media3.session.MediaTestUtils.assertLibraryParamsEquals; import static androidx.media3.test.session.common.CommonConstants.SUPPORT_APP_PACKAGE_NAME; import static androidx.media3.test.session.common.MediaBrowserConstants.CUSTOM_ACTION; import static androidx.media3.test.session.common.MediaBrowserConstants.CUSTOM_ACTION_ASSERT_PARAMS; import static androidx.media3.test.session.common.MediaBrowserConstants.CUSTOM_ACTION_EXTRAS; +import static androidx.media3.test.session.common.MediaBrowserConstants.EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_BROADCAST; +import static androidx.media3.test.session.common.MediaBrowserConstants.EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_DELAY_MS; +import static androidx.media3.test.session.common.MediaBrowserConstants.EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_ITEM_COUNT; +import static androidx.media3.test.session.common.MediaBrowserConstants.EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_MEDIA_ID; import static androidx.media3.test.session.common.MediaBrowserConstants.GET_CHILDREN_RESULT; import static androidx.media3.test.session.common.MediaBrowserConstants.LONG_LIST_COUNT; import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_BROWSABLE_ITEM; import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_ITEM_WITH_METADATA; import static androidx.media3.test.session.common.MediaBrowserConstants.MEDIA_ID_GET_PLAYABLE_ITEM; -import static androidx.media3.test.session.common.MediaBrowserConstants.NOTIFY_CHILDREN_CHANGED_EXTRAS; -import static androidx.media3.test.session.common.MediaBrowserConstants.NOTIFY_CHILDREN_CHANGED_ITEM_COUNT; import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID; import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID_AUTH_EXPIRED_ERROR; import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID_AUTH_EXPIRED_ERROR_KEY_ERROR_RESOLUTION_ACTION_LABEL; import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID_ERROR; import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID_LONG_LIST; +import static androidx.media3.test.session.common.MediaBrowserConstants.PARENT_ID_NO_CHILDREN; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_EXTRAS; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_ID; import static androidx.media3.test.session.common.MediaBrowserConstants.ROOT_ID_SUPPORTS_BROWSABLE_CHILDREN_ONLY; @@ -48,10 +51,8 @@ import static androidx.media3.test.session.common.MediaBrowserConstants.SEARCH_Q import static androidx.media3.test.session.common.MediaBrowserConstants.SEARCH_RESULT; import static androidx.media3.test.session.common.MediaBrowserConstants.SEARCH_RESULT_COUNT; import static androidx.media3.test.session.common.MediaBrowserConstants.SEARCH_TIME_IN_MS; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL_WITH_NON_SUBSCRIBED_ID; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE; -import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE_WITH_NON_SUBSCRIBED_ID; +import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_PARENT_ID_1; +import static androidx.media3.test.session.common.MediaBrowserConstants.SUBSCRIBE_PARENT_ID_2; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.app.PendingIntent; @@ -70,6 +71,7 @@ import androidx.media3.common.util.Log; import androidx.media3.common.util.Util; import androidx.media3.session.MediaSession.ControllerInfo; import androidx.media3.test.session.common.CommonConstants; +import androidx.media3.test.session.common.TestHandler; import androidx.media3.test.session.common.TestUtils; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; @@ -77,6 +79,7 @@ import com.google.common.util.concurrent.ListenableFuture; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.concurrent.Executors; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; @@ -108,8 +111,6 @@ public class MockMediaLibraryService extends MediaLibraryService { .build(); public static final LibraryParams ROOT_PARAMS = new LibraryParams.Builder().setExtras(ROOT_EXTRAS).build(); - private static final LibraryParams NOTIFY_CHILDREN_CHANGED_PARAMS = - new LibraryParams.Builder().setExtras(NOTIFY_CHILDREN_CHANGED_EXTRAS).build(); private static final String TAG = "MockMediaLibrarySvc2"; @@ -121,11 +122,11 @@ public class MockMediaLibraryService extends MediaLibraryService { private static LibraryParams expectedParams; @Nullable private static byte[] testArtworkData; - private final AtomicInteger boundControllerCount; private final ConditionVariable allControllersUnbound; @Nullable MediaLibrarySession session; + @Nullable TestHandler handler; @Nullable HandlerThread handlerThread; public MockMediaLibraryService() { @@ -159,6 +160,7 @@ public class MockMediaLibraryService extends MediaLibraryService { super.onCreate(); handlerThread = new HandlerThread(TAG); handlerThread.start(); + handler = new TestHandler(handlerThread.getLooper()); } @Override @@ -231,6 +233,16 @@ public class MockMediaLibraryService extends MediaLibraryService { } } + public static Bundle createNotifyChildrenChangedBundle( + String mediaId, int itemCount, long delayMs, boolean broadcast) { + Bundle bundle = new Bundle(); + bundle.putString(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_MEDIA_ID, mediaId); + bundle.putInt(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_ITEM_COUNT, itemCount); + bundle.putLong(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_DELAY_MS, delayMs); + bundle.putBoolean(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_BROADCAST, broadcast); + return bundle; + } + private class TestLibrarySessionCallback implements MediaLibrarySession.Callback { @Override @@ -293,8 +305,13 @@ public class MockMediaLibraryService extends MediaLibraryService { @Override public ListenableFuture> onGetItem( MediaLibrarySession session, ControllerInfo browser, String mediaId) { + if (mediaId.startsWith(SUBSCRIBE_PARENT_ID_1)) { + return Futures.immediateFuture( + LibraryResult.ofItem(createBrowsableMediaItem(mediaId), /* params= */ null)); + } switch (mediaId) { case MEDIA_ID_GET_BROWSABLE_ITEM: + case SUBSCRIBE_PARENT_ID_2: return Futures.immediateFuture( LibraryResult.ofItem(createBrowsableMediaItem(mediaId), /* params= */ null)); case MEDIA_ID_GET_PLAYABLE_ITEM: @@ -306,7 +323,7 @@ public class MockMediaLibraryService extends MediaLibraryService { LibraryResult.ofItem(createMediaItemWithMetadata(mediaId), /* params= */ null)); default: // fall out } - return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)); + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_BAD_VALUE)); } @Override @@ -318,19 +335,21 @@ public class MockMediaLibraryService extends MediaLibraryService { int pageSize, @Nullable LibraryParams params) { assertLibraryParams(params); - if (PARENT_ID.equals(parentId)) { + if (Objects.equals(parentId, PARENT_ID_NO_CHILDREN)) { + return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params)); + } else if (Objects.equals(parentId, PARENT_ID)) { return Futures.immediateFuture( LibraryResult.ofItemList( getPaginatedResult(GET_CHILDREN_RESULT, page, pageSize), params)); - } else if (PARENT_ID_LONG_LIST.equals(parentId)) { + } else if (Objects.equals(parentId, PARENT_ID_LONG_LIST)) { List list = new ArrayList<>(LONG_LIST_COUNT); for (int i = 0; i < LONG_LIST_COUNT; i++) { list.add(createPlayableMediaItem(TestUtils.getMediaIdInFakeTimeline(i))); } return Futures.immediateFuture(LibraryResult.ofItemList(list, params)); - } else if (PARENT_ID_ERROR.equals(parentId)) { - return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)); - } else if (PARENT_ID_AUTH_EXPIRED_ERROR.equals(parentId)) { + } else if (Objects.equals(parentId, PARENT_ID_ERROR)) { + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_BAD_VALUE)); + } else if (Objects.equals(parentId, PARENT_ID_AUTH_EXPIRED_ERROR)) { Bundle bundle = new Bundle(); Intent signInIntent = new Intent("action"); int flags = Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0; @@ -346,8 +365,37 @@ public class MockMediaLibraryService extends MediaLibraryService { LibraryResult.RESULT_ERROR_SESSION_AUTHENTICATION_EXPIRED, new LibraryParams.Builder().setExtras(bundle).build())); } - // Includes the case of PARENT_ID_NO_CHILDREN. - return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params)); + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_BAD_VALUE, params)); + } + + @Override + public ListenableFuture> onSubscribe( + MediaLibrarySession session, + ControllerInfo browser, + String parentId, + @Nullable LibraryParams params) { + if (params != null) { + String mediaId = params.extras.getString(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_MEDIA_ID, null); + long delayMs = params.extras.getLong(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_DELAY_MS, 0L); + if (mediaId != null && delayMs > 0) { + int itemCount = + params.extras.getInt( + EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_ITEM_COUNT, Integer.MAX_VALUE); + boolean broadcast = + params.extras.getBoolean(EXTRAS_KEY_NOTIFY_CHILDREN_CHANGED_BROADCAST, false); + // Post a delayed update as requested. + handler.postDelayed( + () -> { + if (broadcast) { + session.notifyChildrenChanged(mediaId, itemCount, params); + } else { + session.notifyChildrenChanged(browser, mediaId, itemCount, params); + } + }, + delayMs); + } + } + return MediaLibrarySession.Callback.super.onSubscribe(session, browser, parentId, params); } @Override @@ -406,46 +454,10 @@ public class MockMediaLibraryService extends MediaLibraryService { return Futures.immediateFuture(LibraryResult.ofItemList(ImmutableList.of(), params)); } else { // SEARCH_QUERY_ERROR will be handled here. - return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)); + return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_BAD_VALUE)); } } - @Override - public ListenableFuture> onSubscribe( - MediaLibrarySession session, - ControllerInfo browser, - String parentId, - LibraryParams params) { - assertLibraryParams(params); - String unsubscribedId = "unsubscribedId"; - switch (parentId) { - case SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL: - MockMediaLibraryService.this.session.notifyChildrenChanged( - parentId, NOTIFY_CHILDREN_CHANGED_ITEM_COUNT, NOTIFY_CHILDREN_CHANGED_PARAMS); - return Futures.immediateFuture(LibraryResult.ofVoid(params)); - case SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE: - MockMediaLibraryService.this.session.notifyChildrenChanged( - browser, - parentId, - NOTIFY_CHILDREN_CHANGED_ITEM_COUNT, - NOTIFY_CHILDREN_CHANGED_PARAMS); - return Futures.immediateFuture(LibraryResult.ofVoid(params)); - case SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ALL_WITH_NON_SUBSCRIBED_ID: - MockMediaLibraryService.this.session.notifyChildrenChanged( - unsubscribedId, NOTIFY_CHILDREN_CHANGED_ITEM_COUNT, NOTIFY_CHILDREN_CHANGED_PARAMS); - return Futures.immediateFuture(LibraryResult.ofVoid(params)); - case SUBSCRIBE_ID_NOTIFY_CHILDREN_CHANGED_TO_ONE_WITH_NON_SUBSCRIBED_ID: - MockMediaLibraryService.this.session.notifyChildrenChanged( - browser, - unsubscribedId, - NOTIFY_CHILDREN_CHANGED_ITEM_COUNT, - NOTIFY_CHILDREN_CHANGED_PARAMS); - return Futures.immediateFuture(LibraryResult.ofVoid(params)); - default: // fall out - } - return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE)); - } - @Override public ListenableFuture onCustomCommand( MediaSession session, @@ -471,7 +483,7 @@ public class MockMediaLibraryService extends MediaLibraryService { private void assertLibraryParams(@Nullable LibraryParams params) { synchronized (MockMediaLibraryService.class) { if (assertLibraryParams) { - assertLibraryParamsEquals(expectedParams, params); + MediaTestUtils.assertLibraryParamsEquals(expectedParams, params); } } }