/* * 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.media2.session; import static androidx.annotation.RestrictTo.Scope.LIBRARY; import static androidx.media2.session.SessionResult.RESULT_ERROR_NOT_SUPPORTED; import static androidx.media2.session.SessionResult.RESULT_SUCCESS; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.os.IBinder; import android.os.RemoteException; import android.support.v4.media.session.MediaControllerCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; import android.view.KeyEvent; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.core.content.ContextCompat; import androidx.core.util.ObjectsCompat; import androidx.media.MediaSessionManager.RemoteUserInfo; import androidx.media2.common.CallbackMediaItem; import androidx.media2.common.MediaItem; import androidx.media2.common.MediaMetadata; import androidx.media2.common.Rating; import androidx.media2.common.SessionPlayer; import androidx.media2.common.SessionPlayer.BuffState; import androidx.media2.common.SessionPlayer.PlayerResult; import androidx.media2.common.SessionPlayer.PlayerState; import androidx.media2.common.SessionPlayer.TrackInfo; import androidx.media2.common.SubtitleData; import androidx.media2.common.UriMediaItem; import androidx.media2.common.VideoSize; import androidx.media2.session.MediaController.PlaybackInfo; import androidx.media2.session.MediaLibraryService.LibraryParams; import androidx.media2.session.SessionResult.ResultCode; import androidx.versionedparcelable.ParcelField; import androidx.versionedparcelable.VersionedParcelable; import androidx.versionedparcelable.VersionedParcelize; import com.google.common.util.concurrent.ListenableFuture; import java.io.Closeable; import java.util.HashMap; import java.util.List; import java.util.concurrent.Executor; /** * Allows a media app to expose its transport controls and playback information in a process to * other processes including the Android framework and other apps. Common use cases are as follows. * *

* A MediaSession should be created when an app wants to publish media playback information or * handle media keys. In general an app only needs one session for all playback, though multiple * sessions can be created to provide finer grain controls of media. See * Supporting Multiple Sessions for detail. *

* If you want to support background playback, {@link MediaSessionService} is preferred * instead. With it, your playback can be revived even after playback is finished. See * {@link MediaSessionService} for details. *

* Topics covered here: *

    *
  1. Session Lifecycle *
  2. Thread *
  3. Media key events mapping *
  4. Supporting Multiple Sessions *
  5. Backward compatibility with legacy session APIs *
  6. Backward compatibility with legacy controller APIs * *
*

Session Lifecycle

*

* A session can be obtained by {@link Builder}. The owner of the session may pass its session token * to other processes to allow them to create a {@link MediaController} to interact with the * session. *

* When a session receive transport control commands, the session sends the commands directly to * the underlying media player set by {@link Builder} or {@link #updatePlayer}. *

* When an app is finished performing playback it must call {@link #close()} to clean up the session * and notify any controllers. The app is responsible for closing the underlying player after * closing the session. * is closed. *

Thread

*

* {@link MediaSession} objects are thread safe, but should be used on the thread on the looper. *

Media key events mapping

*

* Here's the table of per key event. * * * * * * * * * * * * * * * * * * * *
Key code{@link MediaSession} API
{@link KeyEvent#KEYCODE_MEDIA_PLAY}{@link SessionPlayer#play()}
{@link KeyEvent#KEYCODE_MEDIA_PAUSE}{@link SessionPlayer#pause()}
{@link KeyEvent#KEYCODE_MEDIA_NEXT}{@link SessionPlayer#skipToNextPlaylistItem()}
{@link KeyEvent#KEYCODE_MEDIA_PREVIOUS}{@link SessionPlayer#skipToPreviousPlaylistItem()}
{@link KeyEvent#KEYCODE_MEDIA_STOP}{@link SessionPlayer#pause()} and then * {@link SessionPlayer#seekTo(long)} with 0
{@link KeyEvent#KEYCODE_MEDIA_FAST_FORWARD}{@link SessionCallback#onFastForward}
{@link KeyEvent#KEYCODE_MEDIA_REWIND}{@link SessionCallback#onRewind}
  • {@link KeyEvent#KEYCODE_MEDIA_PLAY_PAUSE}
  • *
  • {@link KeyEvent#KEYCODE_HEADSETHOOK}
  • For a single tap *
    • {@link SessionPlayer#pause()} if * {@link SessionPlayer#PLAYER_STATE_PLAYING}
    • *
    • {@link SessionPlayer#play()} otherwise
    *
  • For a double tap, {@link SessionPlayer#skipToNextPlaylistItem()}
*

Supporting Multiple Sessions

* Generally speaking, multiple sessions aren't necessary for most media apps. One exception is if * your app can play multiple media content at the same time, but only for the playback of * video-only media or remote playback, since * audio focus policy recommends * not playing multiple audio content at the same time. Also keep in mind that multiple media * sessions would make Android Auto and Bluetooth device with display to show your apps multiple * times, because they list up media sessions, not media apps. *

Backward compatibility with legacy session APIs

* An active {@link MediaSessionCompat} is internally created with the MediaSession for the backward * compatibility. It's used to handle incoming connection and command from * {@link MediaControllerCompat}. And helps to utilize existing APIs that are built with legacy * media session APIs. Use {@link #getSessionCompatToken} for getting the token for the underlying * MediaSessionCompat. *

Backward compatibility with legacy controller APIs

* In addition to the {@link MediaController media2 controller} API, session also supports * connection from the legacy controller API - * {@link android.media.session.MediaController framework controller} and * {@link MediaControllerCompat AndroidX controller compat}. * However, {@link ControllerInfo} may not be precise for legacy controller. * See {@link ControllerInfo} for the details. *

* Unknown package name nor UID doesn't mean that you should disallow connection nor commands. For * SDK levels where such issue happen, session tokens could only be obtained by trusted apps (e.g. * Bluetooth, Auto, ...), so it may be better for you to allow them as you did with legacy session. * * @see MediaSessionService */ public class MediaSession implements Closeable { static final String TAG = "MediaSession"; // It's better to have private static lock instead of using MediaSession.class because the // private lock object isn't exposed. private static final Object STATIC_LOCK = new Object(); // Note: This checks the uniqueness of a session ID only in single process. // When the framework becomes able to check the uniqueness, this logic should be removed. @GuardedBy("STATIC_LOCK") private static final HashMap SESSION_ID_TO_SESSION_MAP = new HashMap<>(); private final MediaSessionImpl mImpl; MediaSession(Context context, String id, SessionPlayer player, PendingIntent sessionActivity, Executor callbackExecutor, SessionCallback callback, Bundle tokenExtras) { synchronized (STATIC_LOCK) { if (SESSION_ID_TO_SESSION_MAP.containsKey(id)) { throw new IllegalStateException("Session ID must be unique. ID=" + id); } SESSION_ID_TO_SESSION_MAP.put(id, this); } mImpl = createImpl(context, id, player, sessionActivity, callbackExecutor, callback, tokenExtras); } MediaSessionImpl createImpl(Context context, String id, SessionPlayer player, PendingIntent sessionActivity, Executor callbackExecutor, SessionCallback callback, Bundle tokenExtras) { return new MediaSessionImplBase(this, context, id, player, sessionActivity, callbackExecutor, callback, tokenExtras); } /** * Should be only used by subclass. */ MediaSessionImpl getImpl() { return mImpl; } static MediaSession getSession(Uri sessionUri) { synchronized (STATIC_LOCK) { for (MediaSession session : SESSION_ID_TO_SESSION_MAP.values()) { if (ObjectsCompat.equals(session.getUri(), sessionUri)) { return session; } } } return null; } /** * Updates the underlying {@link SessionPlayer} for this session to dispatch incoming event to. * * @param player a player that handles actual media playback in your app */ public void updatePlayer(@NonNull SessionPlayer player) { if (player == null) { throw new NullPointerException("player shouldn't be null"); } mImpl.updatePlayer(player); } @Override public void close() { try { synchronized (STATIC_LOCK) { SESSION_ID_TO_SESSION_MAP.remove(mImpl.getId()); } mImpl.close(); } catch (Exception e) { // Should not be here. } } /** * @hide */ @RestrictTo(LIBRARY) public boolean isClosed() { return mImpl.isClosed(); } /** * Gets the underlying {@link SessionPlayer}. *

* When the session is closed, it returns the lastly set player. * * @return player. */ @NonNull public SessionPlayer getPlayer() { return mImpl.getPlayer(); } /** * Gets the session ID * * @return */ @NonNull public String getId() { return mImpl.getId(); } /** * Returns the {@link SessionToken} for creating {@link MediaController}. */ @NonNull public SessionToken getToken() { return mImpl.getToken(); } @NonNull Context getContext() { return mImpl.getContext(); } @NonNull Executor getCallbackExecutor() { return mImpl.getCallbackExecutor(); } @NonNull SessionCallback getCallback() { return mImpl.getCallback(); } /** * Returns the list of connected controller. * * @return list of {@link ControllerInfo} */ @NonNull public List getConnectedControllers() { return mImpl.getConnectedControllers(); } /** * Sets ordered list of {@link CommandButton} for controllers to build UI with it. *

* It's up to controller's decision how to represent the layout in its own UI. * Here are some examples. *

* Note: layout[i] means a CommandButton at index i in the given list * * * * * * * * * *
Controller UX layoutLayout example
Row with 3 iconslayout[1] layout[0] layout[2]
Row with 5 iconslayout[3] layout[1] layout[0] * layout[2] layout[4]
Row with 5 icons and an overflow icon, and another expandable row with 5 * extra iconslayout[3] layout[1] layout[0] * layout[2] layout[4]
layout[3] layout[1] layout[0] * layout[2] layout[4]
*

* This API can be called in the * {@link SessionCallback#onConnect(MediaSession, ControllerInfo)}. * * @param controller controller to specify layout. * @param layout ordered list of layout. */ @NonNull public ListenableFuture setCustomLayout( @NonNull ControllerInfo controller, @NonNull List layout) { if (controller == null) { throw new NullPointerException("controller shouldn't be null"); } if (layout == null) { throw new NullPointerException("layout shouldn't be null"); } return mImpl.setCustomLayout(controller, layout); } /** * Sets the new allowed command group for the controller. *

* This is synchronous call. Changes in the allowed commands take effect immediately regardless * of the controller notified about the change through * {@link MediaController.ControllerCallback * #onAllowedCommandsChanged(MediaController, SessionCommandGroup)} * * @param controller controller to change allowed commands * @param commands new allowed commands */ public void setAllowedCommands(@NonNull ControllerInfo controller, @NonNull SessionCommandGroup commands) { if (controller == null) { throw new NullPointerException("controller shouldn't be null"); } if (commands == null) { throw new NullPointerException("commands shouldn't be null"); } mImpl.setAllowedCommands(controller, commands); } /** * Broadcasts a custom command to all connected controllers. *

* This is synchronous call and doesn't wait for result from the controller. Use * {@link #sendCustomCommand(ControllerInfo, SessionCommand, Bundle)} for getting the result. *

* A command is not accepted if it is not a custom command. * * @param command a command * @param args optional argument * @see #sendCustomCommand(ControllerInfo, SessionCommand, Bundle) */ public void broadcastCustomCommand(@NonNull SessionCommand command, @Nullable Bundle args) { if (command == null) { throw new NullPointerException("command shouldn't be null"); } if (command.getCommandCode() != SessionCommand.COMMAND_CODE_CUSTOM) { throw new IllegalArgumentException("command should be a custom command"); } mImpl.broadcastCustomCommand(command, args); } /** * Sends a custom command to a specific controller. *

* A command is not accepted if it is not a custom command. * * @param command a command * @param args optional argument * @see #broadcastCustomCommand(SessionCommand, Bundle) */ @NonNull public ListenableFuture sendCustomCommand( @NonNull ControllerInfo controller, @NonNull SessionCommand command, @Nullable Bundle args) { if (controller == null) { throw new NullPointerException("controller shouldn't be null"); } if (command == null) { throw new NullPointerException("command shouldn't be null"); } if (command.getCommandCode() != SessionCommand.COMMAND_CODE_CUSTOM) { throw new IllegalArgumentException("command should be a custom command"); } return mImpl.sendCustomCommand(controller, command, args); } /** * @hide */ @RestrictTo(LIBRARY) public MediaSessionCompat getSessionCompat() { return mImpl.getSessionCompat(); } /** * Gets the {@link MediaSessionCompat.Token} for the MediaSessionCompat created internally * by this session. * * @return {@link MediaSessionCompat.Token} */ @NonNull public MediaSessionCompat.Token getSessionCompatToken() { return mImpl.getSessionCompat().getSessionToken(); } /** * Sets the timeout for disconnecting legacy controller. * @param timeoutMs timeout in millis * * @hide */ @RestrictTo(LIBRARY) public void setLegacyControllerConnectionTimeoutMs(long timeoutMs) { mImpl.setLegacyControllerConnectionTimeoutMs(timeoutMs); } /** * Handles the controller's connection request from {@link MediaSessionService}. * * @param controller controller aidl * @param packageName controller package name * @param pid controller pid * @param uid controller uid * @param connectionHints controller connection hints */ void handleControllerConnectionFromService(IMediaController controller, int controllerVersion, String packageName, int pid, int uid, @Nullable Bundle connectionHints) { mImpl.connectFromService(controller, controllerVersion, packageName, pid, uid, connectionHints); } IBinder getLegacyBrowerServiceBinder() { return mImpl.getLegacyBrowserServiceBinder(); } @NonNull private Uri getUri() { return mImpl.getUri(); } /** * Callback to be called for all incoming commands from {@link MediaController}s. *

* If it's not set, the session will accept all controllers and all incoming commands by * default. */ public abstract static class SessionCallback { ForegroundServiceEventCallback mForegroundServiceEventCallback; /** * Called when a controller is created for this session. Return allowed commands for * controller. By default it allows all connection requests and commands. *

* You can reject the connection by return {@code null}. In that case, the controller * receives {@link MediaController.ControllerCallback#onDisconnected(MediaController)} and * cannot be used. *

* The controller hasn't connected yet in this method, so calls to the controller * (e.g. {@link #sendCustomCommand}, {@link #setCustomLayout}) would be ignored. Override * {@link #onPostConnect} for the custom initialization for the controller instead. * * @param session the session for this event * @param controller controller information. * @return allowed commands. Can be {@code null} to reject connection. * @see #onPostConnect(MediaSession, ControllerInfo) */ @Nullable public SessionCommandGroup onConnect(@NonNull MediaSession session, @NonNull ControllerInfo controller) { SessionCommandGroup commands = new SessionCommandGroup.Builder() .addAllPredefinedCommands(SessionCommand.COMMAND_VERSION_CURRENT) .build(); return commands; } /** * Called immediately after a controller is connected. This is a convenient method to add * custom initialization between the session and a controller. *

* Note that calls to the controller (e.g. {@link #sendCustomCommand}, * {@link #setCustomLayout}) work here but don't work in {@link #onConnect} because the * controller hasn't connected yet in {@link #onConnect}. * * @param session the session for this event * @param controller controller information. */ public void onPostConnect(@NonNull MediaSession session, @NonNull ControllerInfo controller) { } /** * Called when a controller is disconnected. *

* Interoperability: For legacy controller, this is called when the controller doesn't send * any command for a while. It's because there were no explicit disconnect API in legacy * controller API. * * @param session the session for this event * @param controller controller information */ public void onDisconnected(@NonNull MediaSession session, @NonNull ControllerInfo controller) {} /** * Called when a controller sent a command which will be sent directly to one of the * following: *

*

* Return {@link SessionResult#RESULT_SUCCESS} to proceed the command. If something * else is returned, command wouldn't be sent and the controller would receive the code with * it. * * @param session the session for this event * @param controller controller information. * @param command a command. This method will be called for every single command. * @return {@code RESULT_SUCCESS} if you want to proceed with incoming command. * Another code for ignore. * @see SessionCommand#COMMAND_CODE_PLAYER_PLAY * @see SessionCommand#COMMAND_CODE_PLAYER_PAUSE * @see SessionCommand#COMMAND_CODE_PLAYER_SKIP_TO_NEXT_PLAYLIST_ITEM * @see SessionCommand#COMMAND_CODE_PLAYER_SKIP_TO_PREVIOUS_PLAYLIST_ITEM * @see SessionCommand#COMMAND_CODE_PLAYER_PREPARE * @see SessionCommand#COMMAND_CODE_PLAYER_SEEK_TO * @see SessionCommand#COMMAND_CODE_PLAYER_SKIP_TO_PLAYLIST_ITEM * @see SessionCommand#COMMAND_CODE_PLAYER_SET_SHUFFLE_MODE * @see SessionCommand#COMMAND_CODE_PLAYER_SET_REPEAT_MODE * @see SessionCommand#COMMAND_CODE_PLAYER_ADD_PLAYLIST_ITEM * @see SessionCommand#COMMAND_CODE_PLAYER_REMOVE_PLAYLIST_ITEM * @see SessionCommand#COMMAND_CODE_PLAYER_REPLACE_PLAYLIST_ITEM * @see SessionCommand#COMMAND_CODE_PLAYER_GET_PLAYLIST * @see SessionCommand#COMMAND_CODE_PLAYER_SET_PLAYLIST * @see SessionCommand#COMMAND_CODE_PLAYER_GET_PLAYLIST_METADATA * @see SessionCommand#COMMAND_CODE_PLAYER_UPDATE_LIST_METADATA * @see SessionCommand#COMMAND_CODE_VOLUME_SET_VOLUME * @see SessionCommand#COMMAND_CODE_VOLUME_ADJUST_VOLUME */ @ResultCode public int onCommandRequest(@NonNull MediaSession session, @NonNull ControllerInfo controller, @NonNull SessionCommand command) { return RESULT_SUCCESS; } /** * Called when a controller has sent a command with a {@link MediaItem} to add a new media * item to this session. Being specific, this will be called for following APIs. *

    *
  1. {@link MediaController#addPlaylistItem(int, String)} *
  2. {@link MediaController#replacePlaylistItem(int, String)} *
  3. {@link MediaController#setPlaylist(List, MediaMetadata)} *
  4. {@link MediaController#setMediaItem(String)} *
* Override this to translate incoming {@code mediaId} to a {@link MediaItem} to be * understood by your player. For example, a player may only understand * {@link androidx.media2.common.FileMediaItem}, {@link UriMediaItem}, * and {@link CallbackMediaItem}. Check the documentation of the player that you're using. *

* If the given media ID is valid, you should return the media item with the given media ID. * If the ID doesn't match, an {@link RuntimeException} will be thrown. * You may return {@code null} if the given item is invalid. Here's the behavior when it * happens. * * * * * * * *
Controller command Behavior when {@code null} is returned
addPlaylistItem Ignore
replacePlaylistItem Ignore
setPlaylistIgnore {@code null} items, and build a list with non-{@code null} items. Call * {@link SessionPlayer#setPlaylist(List, MediaMetadata)} with the list
setMediaItem Ignore
*

* This will be called on the same thread where {@link #onCommandRequest} and commands with * the media controller will be executed. *

* Default implementation returns the {@code null}. * * @param session the session for this event * @param controller controller information * @param mediaId non-empty media id for creating item with * @return translated media item for player with the mediaId. Can be {@code null} to ignore. * @see MediaMetadata#METADATA_KEY_MEDIA_ID */ @Nullable public MediaItem onCreateMediaItem(@NonNull MediaSession session, @NonNull ControllerInfo controller, @NonNull String mediaId) { return null; } /** * Called when a controller set rating of a media item through * {@link MediaController#setRating(String, Rating)}. *

* To allow setting user rating for a {@link MediaItem}, the media item's metadata * should have {@link Rating} with the key {@link MediaMetadata#METADATA_KEY_USER_RATING}, * in order to provide possible rating style for controller. Controller will follow the * rating style. * * @param session the session for this event * @param controller controller information * @param mediaId non-empty media id * @param rating new rating from the controller * @see SessionCommand#COMMAND_CODE_SESSION_SET_RATING */ @ResultCode public int onSetRating(@NonNull MediaSession session, @NonNull ControllerInfo controller, @NonNull String mediaId, @NonNull Rating rating) { return RESULT_ERROR_NOT_SUPPORTED; } /** * Called when a controller requested to set the specific media item(s) represented by a URI * through {@link MediaController#setMediaUri(Uri, Bundle)}. *

* The implementation should create proper {@link MediaItem media item(s)} for the given * {@code uri} and call {@link SessionPlayer#setMediaItem} or * {@link SessionPlayer#setPlaylist}. *

* When {@link MediaControllerCompat} is connected and sends commands with following * methods, the {@code uri} would have the following patterns: * * * * * * * * * * * * * * * *
MethodUri pattern
{@link MediaControllerCompat.TransportControls#prepareFromUri prepareFromUri} * The {@code uri} passed as argument
{@link MediaControllerCompat.TransportControls#prepareFromMediaId prepareFromMediaId} * {@code androidx://media2-session/prepareFromMediaId?id=[mediaId]}
{@link MediaControllerCompat.TransportControls#prepareFromSearch prepareFromSearch} * {@code androidx://media2-session/prepareFromSearch?query=[query]}
{@link MediaControllerCompat.TransportControls#playFromUri playFromUri} * The {@code uri} passed as argument
{@link MediaControllerCompat.TransportControls#playFromMediaId playFromMediaId} * {@code androidx://media2-session/playFromMediaId?id=[mediaId]}
{@link MediaControllerCompat.TransportControls#playFromSearch playFromSearch} * {@code androidx://media2-session/playFromSearch?query=[query]}
*

* {@link SessionPlayer#prepare()} or {@link SessionPlayer#play()} would be followed if * this is called by above methods. * * @param session the session for this event * @param controller controller information * @param uri uri * @param extras optional extra bundle */ @ResultCode public int onSetMediaUri(@NonNull MediaSession session, @NonNull ControllerInfo controller, @NonNull Uri uri, @Nullable Bundle extras) { return RESULT_ERROR_NOT_SUPPORTED; } /** * Called when a controller sent a custom command through * {@link MediaController#sendCustomCommand(SessionCommand, Bundle)}. *

* Interoperability: This would be also called by {@link * android.support.v4.media.MediaBrowserCompat * #sendCustomAction(String, Bundle, CustomActionCallback)}. If so, extra from * sendCustomAction will be considered as args and customCommand would have null extra. * * @param session the session for this event * @param controller controller information * @param customCommand custom command. * @param args optional arguments * @return result of handling custom command. A runtime exception will be thrown if * {@code null} is returned. * @see SessionCommand#COMMAND_CODE_CUSTOM */ @NonNull public SessionResult onCustomCommand(@NonNull MediaSession session, @NonNull ControllerInfo controller, @NonNull SessionCommand customCommand, @Nullable Bundle args) { return new SessionResult(RESULT_ERROR_NOT_SUPPORTED, null); } /** * Called when a controller called {@link MediaController#fastForward()}. *

* It can be implemented in many ways. For example, it can be implemented by seeking forward * once, series of seeking forward, or increasing playback speed. * * @param session the session for this event * @param controller controller information * @see SessionCommand#COMMAND_CODE_SESSION_FAST_FORWARD */ @ResultCode public int onFastForward(@NonNull MediaSession session, @NonNull ControllerInfo controller) { return RESULT_ERROR_NOT_SUPPORTED; } /** * Called when a controller called {@link MediaController#rewind()}. *

* It can be implemented in many ways. For example, it can be implemented by seeking * backward once, series of seeking backward, or decreasing playback speed. * * @param session the session for this event * @param controller controller information * @see SessionCommand#COMMAND_CODE_SESSION_REWIND */ @ResultCode public int onRewind(@NonNull MediaSession session, @NonNull ControllerInfo controller) { return RESULT_ERROR_NOT_SUPPORTED; } /** * Called when a controller called {@link MediaController#skipForward()}. *

* It's recommended to seek forward within the current media item, but its detail may vary. * For example, it can be implemented by seeking forward for the fixed amount of seconds, or * seeking forward to the nearest bookmark. * * @param session the session for this event * @param controller controller information * @see SessionCommand#COMMAND_CODE_SESSION_SKIP_FORWARD */ @ResultCode public int onSkipForward(@NonNull MediaSession session, @NonNull ControllerInfo controller) { return RESULT_ERROR_NOT_SUPPORTED; } /** * Called when a controller called {@link MediaController#skipBackward()}. *

* It's recommended to seek backward within the current media item, but its detail may vary. * For example, it can be implemented by seeking backward for the fixed amount of seconds, * or seeking backward to the nearest bookmark. * * @param session the session for this event * @param controller controller information * @see SessionCommand#COMMAND_CODE_SESSION_SKIP_BACKWARD */ @ResultCode public int onSkipBackward(@NonNull MediaSession session, @NonNull ControllerInfo controller) { return RESULT_ERROR_NOT_SUPPORTED; } /** * Called when the player state is changed. Used internally for setting the * {@link MediaSessionService} as foreground/background. */ final void onPlayerStateChanged(MediaSession session, @PlayerState int state) { if (mForegroundServiceEventCallback != null) { mForegroundServiceEventCallback.onPlayerStateChanged(session, state); } } final void onSessionClosed(MediaSession session) { if (mForegroundServiceEventCallback != null) { mForegroundServiceEventCallback.onSessionClosed(session); } } void setForegroundServiceEventCallback(ForegroundServiceEventCallback callback) { mForegroundServiceEventCallback = callback; } abstract static class ForegroundServiceEventCallback { public void onPlayerStateChanged(MediaSession session, @PlayerState int state) {} public void onSessionClosed(MediaSession session) {} } } /** * Builder for {@link MediaSession}. *

* Any incoming event from the {@link MediaController} will be handled on the callback executor. * If it's not set, {@link ContextCompat#getMainExecutor(Context)} will be used by default. */ public static final class Builder extends BuilderBase { public Builder(@NonNull Context context, @NonNull SessionPlayer player) { super(context, player); } @Override @NonNull public Builder setSessionActivity(@Nullable PendingIntent pi) { return super.setSessionActivity(pi); } @Override @NonNull public Builder setId(@NonNull String id) { return super.setId(id); } @Override @NonNull public Builder setSessionCallback(@NonNull Executor executor, @NonNull SessionCallback callback) { return super.setSessionCallback(executor, callback); } @Override @NonNull public Builder setExtras(@NonNull Bundle extras) { return super.setExtras(extras); } @Override @NonNull public MediaSession build() { if (mCallbackExecutor == null) { mCallbackExecutor = ContextCompat.getMainExecutor(mContext); } if (mCallback == null) { mCallback = new SessionCallback() {}; } return new MediaSession(mContext, mId, mPlayer, mSessionActivity, mCallbackExecutor, mCallback, mExtras); } } /** * Information of a controller. */ public static final class ControllerInfo { @SuppressWarnings("UnusedVariable") private final int mControllerVersion; private final RemoteUserInfo mRemoteUserInfo; private final boolean mIsTrusted; private final ControllerCb mControllerCb; private final Bundle mConnectionHints; /** * @param remoteUserInfo remote user info * @param version connected controller version * @param trusted {@code true} if trusted, {@code false} otherwise * @param cb ControllerCb. Can be {@code null} only when a MediaBrowserCompat connects to * MediaSessionService and ControllerInfo is needed for * SessionCallback#onConnected(). * @param connectionHints a session-specific argument sent from the controller for the * connection. The contents of this bundle may affect the * connection result. */ ControllerInfo(@NonNull RemoteUserInfo remoteUserInfo, int version, boolean trusted, @Nullable ControllerCb cb, @Nullable Bundle connectionHints) { mRemoteUserInfo = remoteUserInfo; mControllerVersion = version; mIsTrusted = trusted; mControllerCb = cb; if (connectionHints == null || MediaUtils.doesBundleHaveCustomParcelable(connectionHints)) { mConnectionHints = null; } else { mConnectionHints = connectionHints; } } RemoteUserInfo getRemoteUserInfo() { return mRemoteUserInfo; } /** * Gets the package name. Can be * {@link androidx.media.MediaSessionManager.RemoteUserInfo#LEGACY_CONTROLLER} for * interoperability. *

* Interoperability: Package name may not be precisely obtained for legacy controller API on * older device. Here are details. * * * * * * * *
SDK version when package name isn't precise{@code ControllerInfo#getPackageName()} for legacy controller
{@code SDK_VERSION} < {@code 21}Actual package name via {@link PackageManager#getNameForUid} with UID.
* It's sufficient for most cases, but doesn't precisely distinguish caller if it * uses shared user ID.
{@code 21} ≤ {@code SDK_VERSION} < {@code 24}{@link RemoteUserInfo#LEGACY_CONTROLLER LEGACY_CONTROLLER}
* * @return package name of the controller. Can be * {@link RemoteUserInfo#LEGACY_CONTROLLER LEGACY_CONTROLLER} if the package name * cannot be obtained. */ @NonNull public String getPackageName() { return mRemoteUserInfo.getPackageName(); } /** * Gets the UID of the controller. Can be a negative value for interoperability. *

* Interoperability: If {@code 21} ≤ {@code SDK_VERSION} < {@code 28}, then UID would * be a negative value because it cannot be obtained. * * @return uid of the controller. Can be a negative value if the uid cannot be obtained. */ public int getUid() { return mRemoteUserInfo.getUid(); } /** * Gets the connection hints sent from controller, or {@link Bundle#EMPTY} if none. */ @NonNull public Bundle getConnectionHints() { return mConnectionHints == null ? Bundle.EMPTY : new Bundle(mConnectionHints); } /** * Returns if the controller has been granted * {@code android.permission.MEDIA_CONTENT_CONTROL} or has a enabled notification listener * so can be trusted to accept connection and incoming command request. * * @return {@code true} if the controller is trusted. * @hide */ @RestrictTo(LIBRARY) public boolean isTrusted() { return mIsTrusted; } @Override public int hashCode() { return ObjectsCompat.hash(mControllerCb, mRemoteUserInfo); } @Override public boolean equals(Object obj) { if (!(obj instanceof ControllerInfo)) { return false; } if (this == obj) { return true; } ControllerInfo other = (ControllerInfo) obj; if (mControllerCb != null || other.mControllerCb != null) { return ObjectsCompat.equals(mControllerCb, other.mControllerCb); } return mRemoteUserInfo.equals(other.mRemoteUserInfo); } @Override public String toString() { return "ControllerInfo {pkg=" + mRemoteUserInfo.getPackageName() + ", uid=" + mRemoteUserInfo.getUid() + "})"; } @Nullable ControllerCb getControllerCb() { return mControllerCb; } @NonNull static ControllerInfo createLegacyControllerInfo() { RemoteUserInfo legacyRemoteUserInfo = new RemoteUserInfo( RemoteUserInfo.LEGACY_CONTROLLER, /* pid= */ RemoteUserInfo.UNKNOWN_PID, /* uid= */ RemoteUserInfo.UNKNOWN_UID); return new ControllerInfo( legacyRemoteUserInfo, MediaUtils.VERSION_UNKNOWN, /* trusted= */ false, /* cb= */ null, /* connectionHints= */ null); } } /** * Button for a {@link SessionCommand} that will be shown by the controller. *

* It's up to the controller's decision to respect or ignore this customization request. */ @VersionedParcelize public static final class CommandButton implements VersionedParcelable { @ParcelField(1) SessionCommand mCommand; @ParcelField(2) int mIconResId; @ParcelField(3) CharSequence mDisplayName; @ParcelField(4) Bundle mExtras; @ParcelField(5) boolean mEnabled; // WARNING: Adding a new ParcelField may break old library users (b/152830728) /** * Used for VersionedParcelable */ CommandButton() { } CommandButton(@Nullable SessionCommand command, int iconResId, @Nullable CharSequence displayName, Bundle extras, boolean enabled) { mCommand = command; mIconResId = iconResId; mDisplayName = displayName; mExtras = extras; mEnabled = enabled; } /** * Gets the command associated with this button. Can be {@code null} if the button isn't * enabled and only providing placeholder. * * @return command or {@code null} */ @Nullable public SessionCommand getCommand() { return mCommand; } /** * Gets the resource id of the button in this package. Can be {@code 0} if the command is * predefined and custom icon isn't needed. * * @return resource id of the icon. Can be {@code 0}. */ public int getIconResId() { return mIconResId; } /** * Gets the display name of the button. Can be {@code null} or empty if the command is * predefined and custom name isn't needed. * * @return custom display name. Can be {@code null} or empty. */ @Nullable public CharSequence getDisplayName() { return mDisplayName; } /** * Gets extra information of the button. It's private information between session and * controller. * * @return */ @Nullable public Bundle getExtras() { return mExtras; } /** * Returns whether it's enabled. * * @return {@code true} if enabled. {@code false} otherwise. */ public boolean isEnabled() { return mEnabled; } /** * Builder for {@link CommandButton}. */ public static final class Builder { private SessionCommand mCommand; private int mIconResId; private CharSequence mDisplayName; private Bundle mExtras; private boolean mEnabled; /** * Sets the {@link SessionCommand} that would be sent to the session when the button * is clicked. * * @param command session command */ @NonNull public Builder setCommand(@Nullable SessionCommand command) { mCommand = command; return this; } /** * Sets the bitmap-type (e.g. PNG) icon resource id of the button. *

* None bitmap type (e.g. VectorDrawabale) may cause unexpected behavior when it's sent * to {@link MediaController} app, so please avoid using it especially for the older * platform (API < 21). * * @param resId resource id of the button */ @NonNull public Builder setIconResId(int resId) { mIconResId = resId; return this; } /** * Sets the display name of the button. * * @param displayName display name of the button */ @NonNull public Builder setDisplayName(@Nullable CharSequence displayName) { mDisplayName = displayName; return this; } /** * Sets whether the button is enabled. Can be {@code false} to indicate that the button * should be shown but isn't clickable. * * @param enabled {@code true} if the button is enabled and ready. * {@code false} otherwise. */ @NonNull public Builder setEnabled(boolean enabled) { mEnabled = enabled; return this; } /** * Sets the extras of the button. * * @param extras extras information of the button */ @NonNull public Builder setExtras(@Nullable Bundle extras) { mExtras = extras; return this; } /** * Builds the {@link CommandButton}. * * @return a new {@link CommandButton} */ @NonNull public CommandButton build() { return new CommandButton(mCommand, mIconResId, mDisplayName, mExtras, mEnabled); } } } // TODO: Drop 'Cb' from the name. abstract static class ControllerCb { abstract void onPlayerResult(int seq, PlayerResult result) throws RemoteException; abstract void onSessionResult(int seq, SessionResult result) throws RemoteException; abstract void onLibraryResult(int seq, LibraryResult result) throws RemoteException; // Mostly matched with the methods in MediaController.ControllerCallback abstract void setCustomLayout(int seq, @NonNull List layout) throws RemoteException; abstract void sendCustomCommand(int seq, @NonNull SessionCommand command, @Nullable Bundle args) throws RemoteException; abstract void onPlaybackInfoChanged(int seq, @NonNull PlaybackInfo info) throws RemoteException; abstract void onAllowedCommandsChanged(int seq, @NonNull SessionCommandGroup commands) throws RemoteException; abstract void onPlayerStateChanged(int seq, long eventTimeMs, long positionMs, int playerState) throws RemoteException; abstract void onPlaybackSpeedChanged(int seq, long eventTimeMs, long positionMs, float speed) throws RemoteException; abstract void onBufferingStateChanged(int seq, @NonNull MediaItem item, @BuffState int bufferingState, long bufferedPositionMs, long eventTimeMs, long positionMs) throws RemoteException; abstract void onSeekCompleted(int seq, long eventTimeMs, long positionMs, long position) throws RemoteException; abstract void onCurrentMediaItemChanged(int seq, @Nullable MediaItem item, int currentIdx, int previousIdx, int nextIdx) throws RemoteException; abstract void onPlaylistChanged(int seq, @NonNull List playlist, @Nullable MediaMetadata metadata, int currentIdx, int previousIdx, int nextIdx) throws RemoteException; abstract void onPlaylistMetadataChanged(int seq, @Nullable MediaMetadata metadata) throws RemoteException; abstract void onShuffleModeChanged(int seq, @SessionPlayer.ShuffleMode int shuffleMode, int currentIdx, int previousIdx, int nextIdx) throws RemoteException; abstract void onRepeatModeChanged(int seq, @SessionPlayer.RepeatMode int repeatMode, int currentIdx, int previousIdx, int nextIdx) throws RemoteException; abstract void onPlaybackCompleted(int seq) throws RemoteException; abstract void onDisconnected(int seq) throws RemoteException; abstract void onVideoSizeChanged(int seq, @NonNull VideoSize videoSize) throws RemoteException; abstract void onTracksChanged(int seq, List tracks, TrackInfo selectedVideoTrack, TrackInfo selectedAudioTrack, TrackInfo selectedSubtitleTrack, TrackInfo selectedMetadataTrack) throws RemoteException; abstract void onTrackSelected(int seq, TrackInfo trackInfo) throws RemoteException; abstract void onTrackDeselected(int seq, TrackInfo trackInfo) throws RemoteException; abstract void onSubtitleData(int seq, @NonNull MediaItem item, @NonNull TrackInfo track, @NonNull SubtitleData data) throws RemoteException; // Mostly matched with the methods in MediaBrowser.BrowserCallback. abstract void onChildrenChanged(int seq, @NonNull String parentId, int itemCount, @Nullable LibraryParams params) throws RemoteException; abstract void onSearchResultChanged(int seq, @NonNull String query, int itemCount, @Nullable LibraryParams params) throws RemoteException; } interface MediaSessionImpl extends MediaInterface.SessionPlayer, Closeable { void updatePlayer(@NonNull SessionPlayer player, @Nullable SessionPlayer playlistAgent); void updatePlayer(@NonNull SessionPlayer player); @NonNull SessionPlayer getPlayer(); @NonNull String getId(); @NonNull Uri getUri(); @NonNull SessionToken getToken(); @NonNull List getConnectedControllers(); boolean isConnected(@NonNull ControllerInfo controller); ListenableFuture setCustomLayout(@NonNull ControllerInfo controller, @NonNull List layout); void setAllowedCommands(@NonNull ControllerInfo controller, @NonNull SessionCommandGroup commands); void broadcastCustomCommand(@NonNull SessionCommand command, @Nullable Bundle args); ListenableFuture sendCustomCommand(@NonNull ControllerInfo controller, @NonNull SessionCommand command, @Nullable Bundle args); // Internally used methods MediaSession getInstance(); MediaSessionCompat getSessionCompat(); void setLegacyControllerConnectionTimeoutMs(long timeoutMs); Context getContext(); Executor getCallbackExecutor(); SessionCallback getCallback(); boolean isClosed(); PlaybackStateCompat createPlaybackStateCompat(); PlaybackInfo getPlaybackInfo(); PendingIntent getSessionActivity(); IBinder getLegacyBrowserServiceBinder(); void connectFromService(IMediaController caller, int controllerVersion, String packageName, int pid, int uid, @Nullable Bundle connectionHints); } /** * Base builder class for MediaSession and its subclass. Any change in this class should be * also applied to the subclasses {@link MediaSession.Builder} and * {@link MediaLibraryService.MediaLibrarySession.Builder}. *

* APIs here should be package private, but should have documentations for developers. * Otherwise, javadoc will generate documentation with the generic types such as follows. *

U extends BuilderBase setSessionCallback(Executor executor, C callback)
*

* This class is hidden to prevent from generating test stub, which fails with * 'unexpected bound' because it tries to auto generate stub class as follows. *

abstract static class BuilderBase<
     *      T extends MediaSession,
     *      U extends MediaSession.BuilderBase<
     *              T, U, C extends MediaSession.SessionCallback>, C>
* @hide */ @RestrictTo(LIBRARY) abstract static class BuilderBase , C extends SessionCallback> { final Context mContext; SessionPlayer mPlayer; String mId; Executor mCallbackExecutor; C mCallback; PendingIntent mSessionActivity; Bundle mExtras; BuilderBase(@NonNull Context context, @NonNull SessionPlayer player) { if (context == null) { throw new NullPointerException("context shouldn't be null"); } if (player == null) { throw new NullPointerException("player shouldn't be null"); } mContext = context; mPlayer = player; // Ensure non-null id. mId = ""; } /** * Sets an intent for launching UI for this Session. This can be used as a * quick link to an ongoing media screen. The intent should be for an * activity that may be started using {@link Context#startActivity(Intent)}. * * @param pi The intent to launch to show UI for this session. */ @SuppressWarnings("unchecked") @NonNull U setSessionActivity(@Nullable PendingIntent pi) { mSessionActivity = pi; return (U) this; } /** * Sets the ID of the session. If it's not set, an empty string will be used to create a * session. *

* 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 controls of them. * * @param id id of the session. Must be unique per package. * @return */ // Note: This ID is not visible to the controllers. ID is introduced in order to prevent // apps from creating multiple sessions without any clear reasons. If they create two // sessions with the same ID in a process, then an IllegalStateException will be thrown. @SuppressWarnings("unchecked") @NonNull U setId(@NonNull String id) { if (id == null) { throw new NullPointerException("id shouldn't be null"); } mId = id; return (U) this; } /** * Sets callback for the session. * * @param executor callback executor * @param callback session callback * @return */ @SuppressWarnings("unchecked") @NonNull U setSessionCallback(@NonNull Executor executor, @NonNull C callback) { if (executor == null) { throw new NullPointerException("executor shouldn't be null"); } if (callback == null) { throw new NullPointerException("callback shouldn't be null"); } mCallbackExecutor = executor; mCallback = callback; return (U) this; } /** * Sets extras for the session token. If not set, {@link SessionToken#getExtras()} * will return an empty {@link Bundle}. * * @return the Builder to allow chaining * @throws IllegalArgumentException if the bundle contains any non-framework Parcelable * objects. * @see SessionToken#getExtras() */ @NonNull @SuppressWarnings("unchecked") U setExtras(@NonNull Bundle extras) { if (extras == null) { throw new NullPointerException("extras shouldn't be null"); } if (MediaUtils.doesBundleHaveCustomParcelable(extras)) { throw new IllegalArgumentException( "extras shouldn't contain any custom parcelables"); } mExtras = new Bundle(extras); return (U) this; } /** * Builds a {@link MediaSession}. * * @return a new session * @throws IllegalStateException if the session with the same id already exists for the * package. */ @NonNull abstract T build(); } }