/* * Copyright 2019 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.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 java.lang.annotation.ElementType.TYPE_USE; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.IBinder; import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.Nullable; import androidx.media.MediaSessionManager.RemoteUserInfo; import androidx.media3.common.Bundleable; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaSession.ControllerInfo; import com.google.common.collect.ImmutableList; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Superclass to be extended by services hosting {@link MediaLibrarySession media library sessions}. * *

It enables applications to browse media content provided by an application, ask the * application to start playback, and control the playback. * *

To extend this class, declare the intent filter in your {@code AndroidManifest.xml}. * *

{@code
 * 
 *   
 *     
 *   
 * 
 * }
* *

You may also declare {@code android.media.browse.MediaBrowserService} for compatibility with * {@link android.support.v4.media.MediaBrowserCompat} so that this service can handle the case. * * @see MediaSessionService */ public abstract class MediaLibraryService extends MediaSessionService { /** The action for {@link Intent} filter that must be declared by the service. */ public static final String SERVICE_INTERFACE = "androidx.media3.session.MediaLibraryService"; /** * An extended {@link MediaSession} for the {@link MediaLibraryService}. Build an instance with * {@link Builder} and return it from {@link MediaSessionService#onGetSession(ControllerInfo)}. * *

Backward compatibility with legacy media browser APIs

* *

A library session supports connection from both {@link MediaBrowser} and {@link * android.support.v4.media.MediaBrowserCompat}, but the {@link ControllerInfo} may not be * precise. Here are the details. * * * * * * * * * * * * * * * * * * * * * * * * * * * *
Summary when controller info isn't precise
SDK version{@link ControllerInfo#getPackageName()}
for legacy browser
{@link ControllerInfo#getUid()}
for legacy browser
{@code SDK_INT < 21}Actual package name via {@link Context#getPackageName()}Actual UID
* {@code 21 <= SDK_INT < 28}
* for {@link Callback#onConnect onConnect}
* and {@link Callback#onGetLibraryRoot onGetLibraryRoot} *
Actual package name via {@link Context#getPackageName()}Actual UID
* {@code 21 <= SDK_INT < 28}
* for other {@link Callback callbacks} *
{@link RemoteUserInfo#LEGACY_CONTROLLER}Negative value
{@code 28 <= SDK_INT}Actual package name via {@link Context#getPackageName()}Actual UID
*/ public static final class MediaLibrarySession extends MediaSession { /** * An extended {@link MediaSession.Callback} for the {@link MediaLibrarySession}. * *

When you return {@link LibraryResult} with {@link MediaItem media items}, each item must * have valid {@link MediaItem#mediaId} and specify {@link MediaMetadata#folderType} and {@link * MediaMetadata#isPlayable} in its {@link MediaItem#mediaMetadata}. */ public interface Callback extends MediaSession.Callback { @Override default ConnectionResult onConnect(MediaSession session, ControllerInfo controller) { SessionCommands sessionCommands = new SessionCommands.Builder().addAllLibraryCommands().addAllSessionCommands().build(); Player.Commands playerCommands = new Player.Commands.Builder().addAllCommands().build(); return ConnectionResult.accept(sessionCommands, playerCommands); } /** * Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link * MediaBrowser#getLibraryRoot(LibraryParams)}. * *

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} may differ from the given {@link LibraryParams params} * if the session can't provide a root that matches with the {@code params}. * *

To allow browsing the media library, return a {@link LibraryResult} with {@link * LibraryResult#RESULT_SUCCESS} and a root {@link MediaItem} with a valid {@link * MediaItem#mediaId}. The media id is required for the browser to get the children under the * root. * *

Interoperability: If this callback is called because a legacy {@link * android.support.v4.media.MediaBrowserCompat} has requested a {@link * androidx.media.MediaBrowserServiceCompat.BrowserRoot}, then the main thread may be blocked * until the returned future is done. If your service may be queried by a legacy {@link * android.support.v4.media.MediaBrowserCompat}, you should ensure that the future completes * quickly to avoid blocking the main thread for a long period of time. * * @param session The session for this event. * @param browser The browser information. * @param params The optional parameters passed by the browser. * @return A pending result that will be resolved with a root media item. * @see SessionCommand#COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT */ default ListenableFuture> onGetLibraryRoot( MediaLibrarySession session, ControllerInfo browser, @Nullable LibraryParams params) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } /** * Called when a {@link MediaBrowser} requests a {@link MediaItem} by {@link * MediaBrowser#getItem(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)}. * *

To allow getting the item, return a {@link LibraryResult} with {@link * LibraryResult#RESULT_SUCCESS} and a {@link MediaItem} with a valid {@link * MediaItem#mediaId}. * * @param session The session for this event. * @param browser The browser information. * @param mediaId The non-empty media id of the requested item. * @return A pending result that will be resolved with a media item. * @see SessionCommand#COMMAND_CODE_LIBRARY_GET_ITEM */ default ListenableFuture> onGetItem( MediaLibrarySession session, ControllerInfo browser, String mediaId) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } /** * Called when a {@link MediaBrowser} requests the child {@link MediaItem media items} of the * given parent id by {@link MediaBrowser#getChildren(String, int, int, LibraryParams)}. * *

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}. * *

To allow getting the children, return a {@link LibraryResult} with {@link * LibraryResult#RESULT_SUCCESS} and a list of {@link MediaItem media items}. Return an empty * list for no children rather than using error codes. * * @param session The session for this event * @param browser The browser information. * @param parentId The non-empty parent id. * @param page The page number to get the paginated result starting from {@code 0}. * @param pageSize The page size to get the paginated result. Will be greater than {@code 0}. * @param params The optional parameters passed by the browser. * @return A pending result that will be resolved with a list of media items. * @see SessionCommand#COMMAND_CODE_LIBRARY_GET_CHILDREN */ default ListenableFuture>> onGetChildren( MediaLibrarySession session, ControllerInfo browser, String parentId, @IntRange(from = 0) int page, @IntRange(from = 1) int pageSize, @Nullable LibraryParams params) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } /** * Called when a {@link MediaBrowser} subscribes to the given parent id by {@link * MediaBrowser#subscribe(String, LibraryParams)}. * *

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}. * *

Interoperability: This will be called by {@link * android.support.v4.media.MediaBrowserCompat#subscribe}, but won't be called by {@link * android.media.browse.MediaBrowser#subscribe}. * * @param session The session for this event. * @param browser The browser information. * @param parentId The non-empty parent id. * @param params The optional parameters passed by the browser. * @return A pending result that will be resolved with a result code. * @see SessionCommand#COMMAND_CODE_LIBRARY_SUBSCRIBE */ default ListenableFuture> onSubscribe( MediaLibrarySession session, ControllerInfo browser, String parentId, @Nullable LibraryParams params) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } /** * 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)}. * *

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}. * * @param session The session for this event. * @param browser The browser information. * @param parentId The non-empty parent id. * @return A pending result that will be resolved with a result code. * @see SessionCommand#COMMAND_CODE_LIBRARY_UNSUBSCRIBE */ default ListenableFuture> onUnsubscribe( MediaLibrarySession session, ControllerInfo browser, String parentId) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } /** * Called when a {@link MediaBrowser} requests a search with {@link * MediaBrowser#search(String, LibraryParams)}. * *

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}. * *

Return {@link LibraryResult} with a result code for the search and notify the number of * search result ({@link MediaItem media items}) through {@link * #notifySearchResultChanged(ControllerInfo, String, int, LibraryParams)}. {@link * MediaBrowser} will ask the search result afterwards through {@link * #onGetSearchResult(MediaLibrarySession, ControllerInfo, String, int, int, LibraryParams)}. * * @param session The session for this event. * @param browser The browser information. * @param query The non-empty search query. * @param params The optional parameters passed by the browser. * @return A pending result that will be resolved with a result code. * @see SessionCommand#COMMAND_CODE_LIBRARY_SEARCH */ default ListenableFuture> onSearch( MediaLibrarySession session, ControllerInfo browser, String query, @Nullable LibraryParams params) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } /** * Called when a {@link MediaBrowser} requests a search result with {@link * MediaBrowser#getSearchResult(String, int, int, LibraryParams)}. * *

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}. * *

To allow getting the search result, return a {@link LibraryResult} with {@link * LibraryResult#RESULT_SUCCESS} and a list of {@link MediaItem media items}. Return an empty * list for no children rather than using error codes. * *

Typically, the {@code query} is requested through {@link #onSearch(MediaLibrarySession, * ControllerInfo, String, LibraryParams)} before, but it may not especially when {@link * android.support.v4.media.MediaBrowserCompat#search} is used. * * @param session The session for this event. * @param browser The browser information. * @param query The non-empty search query. * @param page The page number to get the paginated result starting from {@code 0}. * @param pageSize The page size to get the paginated result. Will be greater than {@code 0}. * @param params The optional parameters passed by the browser. * @return A pending result that will be resolved with a list of media items. * @see SessionCommand#COMMAND_CODE_LIBRARY_GET_SEARCH_RESULT */ default ListenableFuture>> onGetSearchResult( MediaLibrarySession session, ControllerInfo browser, String query, @IntRange(from = 0) int page, @IntRange(from = 1) int pageSize, @Nullable LibraryParams params) { return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_NOT_SUPPORTED)); } } /** * A builder for {@link MediaLibrarySession}. * *

Any incoming requests from the {@link MediaBrowser} will be handled on the application * thread of the underlying {@link Player}. */ // Note: Don't override #setSessionCallback() because the callback can be set by the // constructor. public static final class Builder extends BuilderBase { /** * Creates a builder for {@link MediaLibrarySession}. * * @param service The {@link MediaLibraryService} that instantiates the {@link * MediaLibrarySession}. * @param player The underlying player to perform playback and handle transport controls. * @param callback The callback to handle requests from {@link MediaBrowser}. * @throws IllegalArgumentException if {@link Player#canAdvertiseSession()} returns false. */ // Builder requires MediaLibraryService instead of Context just to ensure that the // builder can be only instantiated within the MediaLibraryService. // Ideally it's better to make it inner class of service to enforce, but it violates API // guideline that Builders should be the inner class of the building target. public Builder(MediaLibraryService service, Player player, Callback callback) { super(service, player, callback); } /** * Sets a {@link PendingIntent} to launch an {@link android.app.Activity} for the {@link * MediaLibrarySession}. This can be used as a quick link to an ongoing media screen. * * @param pendingIntent The pending intent. * @return The builder to allow chaining. */ @Override public Builder setSessionActivity(PendingIntent pendingIntent) { return super.setSessionActivity(pendingIntent); } /** * Sets an ID of the {@link MediaLibrarySession}. If not set, an empty string will be used. * *

Use this if and only if your app supports multiple playback at the same time and also * wants to provide external apps to have finer-grained controls. * * @param id The ID. Must be unique among all {@link MediaSession sessions} per package. * @return The builder to allow chaining. */ @Override public Builder setId(String id) { return super.setId(id); } /** * Sets an extra {@link Bundle} for the {@link MediaLibrarySession}. The {@link * MediaLibrarySession#getToken()} session token} will have the {@link * SessionToken#getExtras() extras}. If not set, an empty {@link Bundle} will be used. * * @param extras The extra {@link Bundle}. * @return The builder to allow chaining. */ @Override public Builder setExtras(Bundle extras) { return super.setExtras(extras); } /** * Builds a {@link MediaLibrarySession}. * * @return A new session. * @throws IllegalStateException if a {@link MediaSession} with the same {@link #setId(String) * ID} already exists in the package. */ @Override public MediaLibrarySession build() { return new MediaLibrarySession(context, id, player, sessionActivity, callback, extras); } } /* package */ MediaLibrarySession( Context context, String id, Player player, @Nullable PendingIntent sessionActivity, MediaSession.Callback callback, Bundle tokenExtras) { super(context, id, player, sessionActivity, callback, tokenExtras); } @Override /* package */ MediaLibrarySessionImpl createImpl( Context context, String id, Player player, @Nullable PendingIntent sessionActivity, MediaSession.Callback callback, Bundle tokenExtras) { return new MediaLibrarySessionImpl( this, context, id, player, sessionActivity, (Callback) callback, tokenExtras); } @Override /* package */ MediaLibrarySessionImpl getImpl() { return (MediaLibrarySessionImpl) super.getImpl(); } /** * 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. * *

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}. * * @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 params The parameters given by {@link Callback#onSubscribe}. */ public void notifyChildrenChanged( ControllerInfo browser, String parentId, @IntRange(from = 0) int itemCount, @Nullable LibraryParams params) { checkArgument(itemCount >= 0); getImpl() .notifyChildrenChanged(checkNotNull(browser), checkNotEmpty(parentId), itemCount, params); } /** * Notifies all browsers that are {@link Callback#onSubscribe subscribing} to the parent of the * change to its children regardless of the {@link LibraryParams params} given by {@link * Callback#onSubscribe}. * * @param parentId The non-empty id of the parent with changes to its children. * @param itemCount The number of children. * @param params The optional parameters. */ // This is for the backward compatibility. public void notifyChildrenChanged( String parentId, @IntRange(from = 0) int itemCount, @Nullable LibraryParams params) { checkArgument(itemCount >= 0); getImpl().notifyChildrenChanged(checkNotEmpty(parentId), itemCount, params); } /** * Notifies a browser of a change to the {@link Callback#onSearch search} result. * * @param browser The browser to notify. * @param query The non-empty search query given by {@link Callback#onSearch}. * @param itemCount The number of items that have been found in the search. * @param params The parameters given by {@link Callback#onSearch}. */ public void notifySearchResultChanged( ControllerInfo browser, String query, @IntRange(from = 0) int itemCount, @Nullable LibraryParams params) { checkArgument(itemCount >= 0); getImpl() .notifySearchResultChanged( checkNotNull(browser), checkNotEmpty(query), itemCount, params); } } /** * Parameters for the interaction between {@link MediaBrowser} and {@link MediaLibrarySession}. * *

When a {@link MediaBrowser} specifies the parameters, the {@link MediaLibrarySession} is * recommended to do the best effort to provide a result regarding the parameters, but it's not an * error even though {@link MediaLibrarySession} doesn't return the parameters since they are * optional. */ public static final class LibraryParams implements Bundleable { /** * An extra {@link Bundle} for the private contract between {@link MediaBrowser} and {@link * MediaLibrarySession}. */ @UnstableApi public final Bundle extras; /** * Whether the media items are recently played. * *

When a {@link MediaBrowser} specifies it as {@code true}, the {@link MediaLibrarySession} * is recommended to provide the recently played media items. If so, the implementation must * return the parameter with {@code true} as well. The list of media items is sorted by * relevance, the first being the most recent. * *

When a {@link MediaBrowser} specifies it as {@code false}, the {@link MediaLibrarySession} * doesn't have to care about the recentness of media items. */ public final boolean isRecent; /** * Whether the media items can be played without an internet connection. * *

When a {@link MediaBrowser} specifies it as {@code true}, the {@link MediaLibrarySession} * is recommended to provide offline media items. If so, the implementation must return the * parameter with {@code true} as well. * *

When a {@link MediaBrowser} specifies it as {@code false}, the {@link MediaLibrarySession} * doesn't have to care about the offline playability of media items. */ public final boolean isOffline; /** * Whether the media items are suggested. * *

When a {@link MediaBrowser} specifies it as {@code true}, the {@link MediaLibrarySession} * is recommended to provide suggested media items. If so, the implementation must return the * parameter with {@code true} as well. The list of media items is sorted by relevance, the * first being the top suggestion. * *

When a {@link MediaBrowser} specifies it as {@code false}, the {@link MediaLibrarySession} * doesn't have to care about the suggestion for media items. */ public final boolean isSuggested; private LibraryParams(Bundle extras, boolean recent, boolean offline, boolean suggested) { this.extras = new Bundle(extras); this.isRecent = recent; this.isOffline = offline; this.isSuggested = suggested; } /** A builder for {@link LibraryParams}. */ public static final class Builder { private boolean recent; private boolean offline; private boolean suggested; private Bundle extras; public Builder() { extras = Bundle.EMPTY; } /** Sets whether the media items are recently played. */ public Builder setRecent(boolean recent) { this.recent = recent; return this; } /** Sets whether the media items can be played without an internet connection. */ public Builder setOffline(boolean offline) { this.offline = offline; return this; } /** Sets whether the media items are suggested. */ public Builder setSuggested(boolean suggested) { this.suggested = suggested; return this; } /** Set an extra {@link Bundle}. */ @UnstableApi public Builder setExtras(Bundle extras) { this.extras = checkNotNull(extras); return this; } /** Builds {@link LibraryParams}. */ public LibraryParams build() { return new LibraryParams(extras, recent, offline, suggested); } } // Bundleable implementation. @Documented @Retention(RetentionPolicy.SOURCE) @Target(TYPE_USE) @IntDef({ FIELD_EXTRAS, FIELD_RECENT, FIELD_OFFLINE, FIELD_SUGGESTED, }) private @interface FieldNumber {} private static final int FIELD_EXTRAS = 0; private static final int FIELD_RECENT = 1; private static final int FIELD_OFFLINE = 2; private static final int FIELD_SUGGESTED = 3; @UnstableApi @Override public Bundle toBundle() { Bundle bundle = new Bundle(); bundle.putBundle(keyForField(FIELD_EXTRAS), extras); bundle.putBoolean(keyForField(FIELD_RECENT), isRecent); bundle.putBoolean(keyForField(FIELD_OFFLINE), isOffline); bundle.putBoolean(keyForField(FIELD_SUGGESTED), isSuggested); return bundle; } /** Object that can restore {@link LibraryParams} from a {@link Bundle}. */ @UnstableApi public static final Creator CREATOR = LibraryParams::fromBundle; private static LibraryParams fromBundle(Bundle bundle) { @Nullable Bundle extras = bundle.getBundle(keyForField(FIELD_EXTRAS)); boolean recent = bundle.getBoolean(keyForField(FIELD_RECENT), /* defaultValue= */ false); boolean offline = bundle.getBoolean(keyForField(FIELD_OFFLINE), /* defaultValue= */ false); boolean suggested = bundle.getBoolean(keyForField(FIELD_SUGGESTED), /* defaultValue= */ false); return new LibraryParams(extras == null ? Bundle.EMPTY : extras, recent, offline, suggested); } private static String keyForField(@FieldNumber int field) { return Integer.toString(field, Character.MAX_RADIX); } } @Override @Nullable public IBinder onBind(@Nullable Intent intent) { if (intent == null) { return null; } if (MediaLibraryService.SERVICE_INTERFACE.equals(intent.getAction())) { return getServiceBinder(); } return super.onBind(intent); } /** * {@inheritDoc} * *

It must return a {@link MediaLibrarySession} which is a subclass of {@link MediaSession}. */ @Override @Nullable public abstract MediaLibrarySession onGetSession(ControllerInfo controllerInfo); }