/* * Copyright 2018 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 android.media; import static android.media.MediaConstants.KEY_ALLOWED_COMMANDS; import static android.media.MediaConstants.KEY_CONNECTION_HINTS; import static android.media.MediaConstants.KEY_PACKAGE_NAME; import static android.media.MediaConstants.KEY_PID; import static android.media.MediaConstants.KEY_PLAYBACK_ACTIVE; import static android.media.MediaConstants.KEY_SESSION2LINK; import static android.media.MediaConstants.KEY_TOKEN_EXTRAS; import static android.media.Session2Command.Result.RESULT_ERROR_UNKNOWN_ERROR; import static android.media.Session2Command.Result.RESULT_INFO_SKIPPED; import static android.media.Session2Token.TYPE_SESSION; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.media.session.MediaSessionManager; import android.media.session.MediaSessionManager.RemoteUserInfo; import android.os.BadParcelableException; import android.os.Bundle; import android.os.Handler; import android.os.Parcel; import android.os.Process; import android.os.ResultReceiver; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import com.android.modules.utils.build.SdkLevel; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Executor; /** * This API is not generally intended for third party application developers. * Use the AndroidX * Media2 session * Library for consistent behavior across all devices. *

* 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. */ public class MediaSession2 implements AutoCloseable { static final String TAG = "MediaSession2"; static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); // Note: This checks the uniqueness of a session ID only in a single process. // When the framework becomes able to check the uniqueness, this logic should be removed. //@GuardedBy("MediaSession.class") private static final List SESSION_ID_LIST = new ArrayList<>(); @SuppressWarnings("WeakerAccess") /* synthetic access */ final Object mLock = new Object(); //@GuardedBy("mLock") @SuppressWarnings("WeakerAccess") /* synthetic access */ final Map mConnectedControllers = new HashMap<>(); @SuppressWarnings("WeakerAccess") /* synthetic access */ final Context mContext; @SuppressWarnings("WeakerAccess") /* synthetic access */ final Executor mCallbackExecutor; @SuppressWarnings("WeakerAccess") /* synthetic access */ final SessionCallback mCallback; @SuppressWarnings("WeakerAccess") /* synthetic access */ final Session2Link mSessionStub; private final String mSessionId; private final PendingIntent mSessionActivity; private final Session2Token mSessionToken; private final MediaSessionManager mMediaSessionManager; private final MediaCommunicationManager mCommunicationManager; private final Handler mResultHandler; //@GuardedBy("mLock") private boolean mClosed; //@GuardedBy("mLock") private boolean mPlaybackActive; //@GuardedBy("mLock") private ForegroundServiceEventCallback mForegroundServiceEventCallback; MediaSession2(@NonNull Context context, @NonNull String id, PendingIntent sessionActivity, @NonNull Executor callbackExecutor, @NonNull SessionCallback callback, @NonNull Bundle tokenExtras) { synchronized (MediaSession2.class) { if (SESSION_ID_LIST.contains(id)) { throw new IllegalStateException("Session ID must be unique. ID=" + id); } SESSION_ID_LIST.add(id); } mContext = context; mSessionId = id; mSessionActivity = sessionActivity; mCallbackExecutor = callbackExecutor; mCallback = callback; mSessionStub = new Session2Link(this); mSessionToken = new Session2Token(Process.myUid(), TYPE_SESSION, context.getPackageName(), mSessionStub, tokenExtras); if (SdkLevel.isAtLeastS()) { mCommunicationManager = mContext.getSystemService(MediaCommunicationManager.class); mMediaSessionManager = null; } else { mMediaSessionManager = mContext.getSystemService(MediaSessionManager.class); mCommunicationManager = null; } // NOTE: mResultHandler uses main looper, so this MUST NOT be blocked. mResultHandler = new Handler(context.getMainLooper()); mClosed = false; } @Override public void close() { try { List controllerInfos; ForegroundServiceEventCallback callback; synchronized (mLock) { if (mClosed) { return; } mClosed = true; controllerInfos = getConnectedControllers(); mConnectedControllers.clear(); callback = mForegroundServiceEventCallback; mForegroundServiceEventCallback = null; } synchronized (MediaSession2.class) { SESSION_ID_LIST.remove(mSessionId); } if (callback != null) { callback.onSessionClosed(this); } for (ControllerInfo info : controllerInfos) { info.notifyDisconnected(); } } catch (Exception e) { // Should not be here. } } /** * Returns the session ID */ @NonNull public String getId() { return mSessionId; } /** * Returns the {@link Session2Token} for creating {@link MediaController2}. */ @NonNull public Session2Token getToken() { return mSessionToken; } /** * Broadcasts a session command to all the connected controllers *

* @param command the session command * @param args optional arguments */ public void broadcastSessionCommand(@NonNull Session2Command command, @Nullable Bundle args) { if (command == null) { throw new IllegalArgumentException("command shouldn't be null"); } List controllerInfos = getConnectedControllers(); for (ControllerInfo controller : controllerInfos) { controller.sendSessionCommand(command, args, null); } } /** * Sends a session command to a specific controller *

* @param controller the controller to get the session command * @param command the session command * @param args optional arguments * @return a token which will be sent together in {@link SessionCallback#onCommandResult} * when its result is received. */ @NonNull public Object sendSessionCommand(@NonNull ControllerInfo controller, @NonNull Session2Command command, @Nullable Bundle args) { if (controller == null) { throw new IllegalArgumentException("controller shouldn't be null"); } if (command == null) { throw new IllegalArgumentException("command shouldn't be null"); } ResultReceiver resultReceiver = new ResultReceiver(mResultHandler) { protected void onReceiveResult(int resultCode, Bundle resultData) { controller.receiveCommandResult(this); mCallbackExecutor.execute(() -> { mCallback.onCommandResult(MediaSession2.this, controller, this, command, new Session2Command.Result(resultCode, resultData)); }); } }; controller.sendSessionCommand(command, args, resultReceiver); return resultReceiver; } /** * Cancels the session command previously sent. * * @param controller the controller to get the session command * @param token the token which is returned from {@link #sendSessionCommand}. */ public void cancelSessionCommand(@NonNull ControllerInfo controller, @NonNull Object token) { if (controller == null) { throw new IllegalArgumentException("controller shouldn't be null"); } if (token == null) { throw new IllegalArgumentException("token shouldn't be null"); } controller.cancelSessionCommand(token); } /** * Sets whether the playback is active (i.e. playing something) * * @param playbackActive {@code true} if the playback active, {@code false} otherwise. **/ public void setPlaybackActive(boolean playbackActive) { final ForegroundServiceEventCallback serviceCallback; synchronized (mLock) { if (mPlaybackActive == playbackActive) { return; } mPlaybackActive = playbackActive; serviceCallback = mForegroundServiceEventCallback; } if (serviceCallback != null) { serviceCallback.onPlaybackActiveChanged(this, playbackActive); } List controllerInfos = getConnectedControllers(); for (ControllerInfo controller : controllerInfos) { controller.notifyPlaybackActiveChanged(playbackActive); } } /** * Returns whether the playback is active (i.e. playing something) * * @return {@code true} if the playback active, {@code false} otherwise. */ public boolean isPlaybackActive() { synchronized (mLock) { return mPlaybackActive; } } /** * Gets the list of the connected controllers * * @return list of the connected controllers. */ @NonNull public List getConnectedControllers() { List controllers = new ArrayList<>(); synchronized (mLock) { controllers.addAll(mConnectedControllers.values()); } return controllers; } /** * Returns whether the given bundle includes non-framework Parcelables. */ static boolean hasCustomParcelable(@Nullable Bundle bundle) { if (bundle == null) { return false; } // Try writing the bundle to parcel, and read it with framework classloader. Parcel parcel = null; try { parcel = Parcel.obtain(); parcel.writeBundle(bundle); parcel.setDataPosition(0); Bundle out = parcel.readBundle(null); for (String key : out.keySet()) { out.get(key); } } catch (BadParcelableException e) { Log.d(TAG, "Custom parcelable in bundle.", e); return true; } finally { if (parcel != null) { parcel.recycle(); } } return false; } boolean isClosed() { synchronized (mLock) { return mClosed; } } SessionCallback getCallback() { return mCallback; } boolean isTrustedForMediaControl(RemoteUserInfo remoteUserInfo) { if (SdkLevel.isAtLeastS()) { return mCommunicationManager.isTrustedForMediaControl(remoteUserInfo); } else { return mMediaSessionManager.isTrustedForMediaControl(remoteUserInfo); } } void setForegroundServiceEventCallback(ForegroundServiceEventCallback callback) { synchronized (mLock) { if (mForegroundServiceEventCallback == callback) { return; } if (mForegroundServiceEventCallback != null && callback != null) { throw new IllegalStateException("A session cannot be added to multiple services"); } mForegroundServiceEventCallback = callback; } } // Called by Session2Link.onConnect and MediaSession2Service.MediaSession2ServiceStub.connect void onConnect(final Controller2Link controller, int callingPid, int callingUid, int seq, Bundle connectionRequest) { if (callingPid == 0) { // The pid here is from Binder.getCallingPid(), which can be 0 for an oneway call from // the remote process. If it's the case, use PID from the connectionRequest. callingPid = connectionRequest.getInt(KEY_PID); } String callingPkg = connectionRequest.getString(KEY_PACKAGE_NAME); RemoteUserInfo remoteUserInfo = new RemoteUserInfo(callingPkg, callingPid, callingUid); Bundle connectionHints = connectionRequest.getBundle(KEY_CONNECTION_HINTS); if (connectionHints == null) { Log.w(TAG, "connectionHints shouldn't be null."); connectionHints = Bundle.EMPTY; } else if (hasCustomParcelable(connectionHints)) { Log.w(TAG, "connectionHints contain custom parcelable. Ignoring."); connectionHints = Bundle.EMPTY; } final ControllerInfo controllerInfo = new ControllerInfo( remoteUserInfo, isTrustedForMediaControl(remoteUserInfo), controller, connectionHints); mCallbackExecutor.execute(() -> { boolean connected = false; try { if (isClosed()) { return; } controllerInfo.mAllowedCommands = mCallback.onConnect(MediaSession2.this, controllerInfo); // Don't reject connection for the request from trusted app. // Otherwise server will fail to retrieve session's information to dispatch // media keys to. if (controllerInfo.mAllowedCommands == null && !controllerInfo.isTrusted()) { return; } if (controllerInfo.mAllowedCommands == null) { // For trusted apps, send non-null allowed commands to keep // connection. controllerInfo.mAllowedCommands = new Session2CommandGroup.Builder().build(); } if (DEBUG) { Log.d(TAG, "Accepting connection: " + controllerInfo); } // If connection is accepted, notify the current state to the controller. // It's needed because we cannot call synchronous calls between // session/controller. Bundle connectionResult = new Bundle(); connectionResult.putParcelable(KEY_SESSION2LINK, mSessionStub); connectionResult.putParcelable(KEY_ALLOWED_COMMANDS, controllerInfo.mAllowedCommands); connectionResult.putBoolean(KEY_PLAYBACK_ACTIVE, isPlaybackActive()); connectionResult.putBundle(KEY_TOKEN_EXTRAS, mSessionToken.getExtras()); // Double check if session is still there, because close() can be called in // another thread. if (isClosed()) { return; } controllerInfo.notifyConnected(connectionResult); synchronized (mLock) { if (mConnectedControllers.containsKey(controller)) { Log.w(TAG, "Controller " + controllerInfo + " has sent connection" + " request multiple times"); } mConnectedControllers.put(controller, controllerInfo); } mCallback.onPostConnect(MediaSession2.this, controllerInfo); connected = true; } finally { if (!connected || isClosed()) { if (DEBUG) { Log.d(TAG, "Rejecting connection or notifying that session is closed" + ", controllerInfo=" + controllerInfo); } synchronized (mLock) { mConnectedControllers.remove(controller); } controllerInfo.notifyDisconnected(); } } }); } // Called by Session2Link.onDisconnect void onDisconnect(@NonNull final Controller2Link controller, int seq) { final ControllerInfo controllerInfo; synchronized (mLock) { controllerInfo = mConnectedControllers.remove(controller); } if (controllerInfo == null) { return; } mCallbackExecutor.execute(() -> { mCallback.onDisconnected(MediaSession2.this, controllerInfo); }); } // Called by Session2Link.onSessionCommand void onSessionCommand(@NonNull final Controller2Link controller, final int seq, final Session2Command command, final Bundle args, @Nullable ResultReceiver resultReceiver) { if (controller == null) { return; } final ControllerInfo controllerInfo; synchronized (mLock) { controllerInfo = mConnectedControllers.get(controller); } if (controllerInfo == null) { return; } // TODO: check allowed commands. synchronized (mLock) { controllerInfo.addRequestedCommandSeqNumber(seq); } mCallbackExecutor.execute(() -> { if (!controllerInfo.removeRequestedCommandSeqNumber(seq)) { if (resultReceiver != null) { resultReceiver.send(RESULT_INFO_SKIPPED, null); } return; } Session2Command.Result result = mCallback.onSessionCommand( MediaSession2.this, controllerInfo, command, args); if (resultReceiver != null) { if (result == null) { resultReceiver.send(RESULT_INFO_SKIPPED, null); } else { resultReceiver.send(result.getResultCode(), result.getResultData()); } } }); } // Called by Session2Link.onCancelCommand void onCancelCommand(@NonNull final Controller2Link controller, final int seq) { final ControllerInfo controllerInfo; synchronized (mLock) { controllerInfo = mConnectedControllers.get(controller); } if (controllerInfo == null) { return; } controllerInfo.removeRequestedCommandSeqNumber(seq); } /** * This API is not generally intended for third party application developers. * Use the AndroidX * Media2 session * Library for consistent behavior across all devices. *

* Builder for {@link MediaSession2}. *

* Any incoming event from the {@link MediaController2} will be handled on the callback * executor. If it's not set, {@link Context#getMainExecutor()} will be used by default. */ public static final class Builder { private Context mContext; private String mId; private PendingIntent mSessionActivity; private Executor mCallbackExecutor; private SessionCallback mCallback; private Bundle mExtras; /** * Creates a builder for {@link MediaSession2}. * * @param context Context * @throws IllegalArgumentException if context is {@code null}. */ public Builder(@NonNull Context context) { if (context == null) { throw new IllegalArgumentException("context shouldn't be null"); } mContext = context; } /** * Set 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. * @return The Builder to allow chaining */ @NonNull public Builder setSessionActivity(@Nullable PendingIntent pi) { mSessionActivity = pi; return this; } /** * Set 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. * @throws IllegalArgumentException if id is {@code null}. * @return The Builder to allow chaining */ @NonNull public Builder setId(@NonNull String id) { if (id == null) { throw new IllegalArgumentException("id shouldn't be null"); } mId = id; return this; } /** * Set callback for the session and its executor. * * @param executor callback executor * @param callback session callback. * @return The Builder to allow chaining */ @NonNull public Builder setSessionCallback(@NonNull Executor executor, @NonNull SessionCallback callback) { mCallbackExecutor = executor; mCallback = callback; return this; } /** * Set extras for the session token. If null or not set, {@link Session2Token#getExtras()} * will return an empty {@link Bundle}. An {@link IllegalArgumentException} will be thrown * if the bundle contains any non-framework Parcelable objects. * * @return The Builder to allow chaining * @see Session2Token#getExtras() */ @NonNull public Builder setExtras(@NonNull Bundle extras) { if (extras == null) { throw new NullPointerException("extras shouldn't be null"); } if (hasCustomParcelable(extras)) { throw new IllegalArgumentException( "extras shouldn't contain any custom parcelables"); } mExtras = new Bundle(extras); return this; } /** * Build {@link MediaSession2}. * * @return a new session * @throws IllegalStateException if the session with the same id is already exists for the * package. */ @NonNull public MediaSession2 build() { if (mCallbackExecutor == null) { mCallbackExecutor = mContext.getMainExecutor(); } if (mCallback == null) { mCallback = new SessionCallback() {}; } if (mId == null) { mId = ""; } if (mExtras == null) { mExtras = Bundle.EMPTY; } MediaSession2 session2 = new MediaSession2(mContext, mId, mSessionActivity, mCallbackExecutor, mCallback, mExtras); // Notify framework about the newly create session after the constructor is finished. // Otherwise, framework may access the session before the initialization is finished. try { if (SdkLevel.isAtLeastS()) { MediaCommunicationManager manager = mContext.getSystemService(MediaCommunicationManager.class); manager.notifySession2Created(session2.getToken()); } else { MediaSessionManager manager = mContext.getSystemService(MediaSessionManager.class); manager.notifySession2Created(session2.getToken()); } } catch (Exception e) { session2.close(); throw e; } return session2; } } /** * This API is not generally intended for third party application developers. * Use the AndroidX * Media2 session * Library for consistent behavior across all devices. *

* Information of a controller. */ public static final class ControllerInfo { private final RemoteUserInfo mRemoteUserInfo; private final boolean mIsTrusted; private final Controller2Link mControllerBinder; private final Bundle mConnectionHints; private final Object mLock = new Object(); //@GuardedBy("mLock") private int mNextSeqNumber; //@GuardedBy("mLock") private ArrayMap mPendingCommands; //@GuardedBy("mLock") private ArraySet mRequestedCommandSeqNumbers; @SuppressWarnings("WeakerAccess") /* synthetic access */ Session2CommandGroup mAllowedCommands; /** * @param remoteUserInfo remote user info * @param trusted {@code true} if trusted, {@code false} otherwise * @param controllerBinder Controller2Link for the connected controller. * @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, boolean trusted, @Nullable Controller2Link controllerBinder, @NonNull Bundle connectionHints) { mRemoteUserInfo = remoteUserInfo; mIsTrusted = trusted; mControllerBinder = controllerBinder; mConnectionHints = connectionHints; mPendingCommands = new ArrayMap<>(); mRequestedCommandSeqNumbers = new ArraySet<>(); } /** * @return remote user info of the controller. */ @NonNull public RemoteUserInfo getRemoteUserInfo() { return mRemoteUserInfo; } /** * @return package name of the controller. */ @NonNull public String getPackageName() { return mRemoteUserInfo.getPackageName(); } /** * @return uid of the controller. Can be a negative value if the uid cannot be obtained. */ public int getUid() { return mRemoteUserInfo.getUid(); } /** * @return connection hints sent from controller. */ @NonNull public Bundle getConnectionHints() { return new Bundle(mConnectionHints); } /** * Return if the controller has 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 */ public boolean isTrusted() { return mIsTrusted; } @Override public int hashCode() { return Objects.hash(mControllerBinder, mRemoteUserInfo); } @Override public boolean equals(@Nullable Object obj) { if (!(obj instanceof ControllerInfo)) return false; if (this == obj) return true; ControllerInfo other = (ControllerInfo) obj; if (mControllerBinder != null || other.mControllerBinder != null) { return Objects.equals(mControllerBinder, other.mControllerBinder); } return mRemoteUserInfo.equals(other.mRemoteUserInfo); } @Override @NonNull public String toString() { return "ControllerInfo {pkg=" + mRemoteUserInfo.getPackageName() + ", uid=" + mRemoteUserInfo.getUid() + ", allowedCommands=" + mAllowedCommands + "})"; } void notifyConnected(Bundle connectionResult) { if (mControllerBinder == null) return; try { mControllerBinder.notifyConnected(getNextSeqNumber(), connectionResult); } catch (RuntimeException e) { // Controller may be died prematurely. } } void notifyDisconnected() { if (mControllerBinder == null) return; try { mControllerBinder.notifyDisconnected(getNextSeqNumber()); } catch (RuntimeException e) { // Controller may be died prematurely. } } void notifyPlaybackActiveChanged(boolean playbackActive) { if (mControllerBinder == null) return; try { mControllerBinder.notifyPlaybackActiveChanged(getNextSeqNumber(), playbackActive); } catch (RuntimeException e) { // Controller may be died prematurely. } } void sendSessionCommand(Session2Command command, Bundle args, ResultReceiver resultReceiver) { if (mControllerBinder == null) return; try { int seq = getNextSeqNumber(); synchronized (mLock) { mPendingCommands.put(resultReceiver, seq); } mControllerBinder.sendSessionCommand(seq, command, args, resultReceiver); } catch (RuntimeException e) { // Controller may be died prematurely. synchronized (mLock) { mPendingCommands.remove(resultReceiver); } resultReceiver.send(RESULT_ERROR_UNKNOWN_ERROR, null); } } void cancelSessionCommand(@NonNull Object token) { if (mControllerBinder == null) return; Integer seq; synchronized (mLock) { seq = mPendingCommands.remove(token); } if (seq != null) { mControllerBinder.cancelSessionCommand(seq); } } void receiveCommandResult(ResultReceiver resultReceiver) { synchronized (mLock) { mPendingCommands.remove(resultReceiver); } } void addRequestedCommandSeqNumber(int seq) { synchronized (mLock) { mRequestedCommandSeqNumbers.add(seq); } } boolean removeRequestedCommandSeqNumber(int seq) { synchronized (mLock) { return mRequestedCommandSeqNumbers.remove(seq); } } private int getNextSeqNumber() { synchronized (mLock) { return mNextSeqNumber++; } } } /** * This API is not generally intended for third party application developers. * Use the AndroidX * Media2 session * Library for consistent behavior across all devices. *

* Callback to be called for all incoming commands from {@link MediaController2}s. */ public abstract static class SessionCallback { /** * Called when a controller is created for this session. Return allowed commands for * controller. By default it returns {@code null}. *

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

* The controller hasn't connected yet in this method, so calls to the controller * (e.g. {@link #sendSessionCommand}) 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. */ @Nullable public Session2CommandGroup onConnect(@NonNull MediaSession2 session, @NonNull ControllerInfo controller) { return null; } /** * 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 #sendSessionCommand}) 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 MediaSession2 session, @NonNull ControllerInfo controller) { } /** * Called when a controller is disconnected * * @param session the session for this event * @param controller controller information */ public void onDisconnected(@NonNull MediaSession2 session, @NonNull ControllerInfo controller) {} /** * Called when a controller sent a session command. * * @param session the session for this event * @param controller controller information * @param command the session command * @param args optional arguments * @return the result for the session command. If {@code null}, RESULT_INFO_SKIPPED * will be sent to the session. */ @Nullable public Session2Command.Result onSessionCommand(@NonNull MediaSession2 session, @NonNull ControllerInfo controller, @NonNull Session2Command command, @Nullable Bundle args) { return null; } /** * Called when the command sent to the controller is finished. * * @param session the session for this event * @param controller controller information * @param token the token got from {@link MediaSession2#sendSessionCommand} * @param command the session command * @param result the result of the session command */ public void onCommandResult(@NonNull MediaSession2 session, @NonNull ControllerInfo controller, @NonNull Object token, @NonNull Session2Command command, @NonNull Session2Command.Result result) {} } abstract static class ForegroundServiceEventCallback { public void onPlaybackActiveChanged(MediaSession2 session, boolean playbackActive) {} public void onSessionClosed(MediaSession2 session) {} } }