/* * 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.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.Process; import android.os.RemoteException; import android.os.ResultReceiver; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; 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 an app to interact with an active {@link MediaSession2} or a * {@link MediaSession2Service} which would provide {@link MediaSession2}. Media buttons and other * commands can be sent to the session. */ public class MediaController2 implements AutoCloseable { static final String TAG = "MediaController2"; static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); @SuppressWarnings("WeakerAccess") /* synthetic access */ final ControllerCallback mCallback; private final IBinder.DeathRecipient mDeathRecipient = () -> close(); private final Context mContext; private final Session2Token mSessionToken; private final Executor mCallbackExecutor; private final Controller2Link mControllerStub; private final Handler mResultHandler; private final SessionServiceConnection mServiceConnection; private final Object mLock = new Object(); //@GuardedBy("mLock") private boolean mClosed; //@GuardedBy("mLock") private int mNextSeqNumber; //@GuardedBy("mLock") private Session2Link mSessionBinder; //@GuardedBy("mLock") private Session2CommandGroup mAllowedCommands; //@GuardedBy("mLock") private Session2Token mConnectedToken; //@GuardedBy("mLock") private ArrayMap mPendingCommands; //@GuardedBy("mLock") private ArraySet mRequestedCommandSeqNumbers; //@GuardedBy("mLock") private boolean mPlaybackActive; /** * Create a {@link MediaController2} from the {@link Session2Token}. * This connects to the session and may wake up the service if it's not available. * * @param context context * @param token token to connect to * @param connectionHints a session-specific argument to send to the session when connecting. * The contents of this bundle may affect the connection result. * @param executor executor to run callbacks on. * @param callback controller callback to receive changes in. */ MediaController2(@NonNull Context context, @NonNull Session2Token token, @NonNull Bundle connectionHints, @NonNull Executor executor, @NonNull ControllerCallback callback) { if (context == null) { throw new IllegalArgumentException("context shouldn't be null"); } if (token == null) { throw new IllegalArgumentException("token shouldn't be null"); } mContext = context; mSessionToken = token; mCallbackExecutor = (executor == null) ? context.getMainExecutor() : executor; mCallback = (callback == null) ? new ControllerCallback() {} : callback; mControllerStub = new Controller2Link(this); // NOTE: mResultHandler uses main looper, so this MUST NOT be blocked. mResultHandler = new Handler(context.getMainLooper()); mNextSeqNumber = 0; mPendingCommands = new ArrayMap<>(); mRequestedCommandSeqNumbers = new ArraySet<>(); boolean connectRequested; if (token.getType() == TYPE_SESSION) { mServiceConnection = null; connectRequested = requestConnectToSession(connectionHints); } else { mServiceConnection = new SessionServiceConnection(connectionHints); connectRequested = requestConnectToService(); } if (!connectRequested) { close(); } } @Override public void close() { synchronized (mLock) { if (mClosed) { // Already closed. Ignore rest of clean up code. // Note: unbindService() throws IllegalArgumentException when it's called twice. return; } if (DEBUG) { Log.d(TAG, "closing " + this); } mClosed = true; if (mServiceConnection != null) { // Note: This should be called even when the bindService() has returned false. mContext.unbindService(mServiceConnection); } if (mSessionBinder != null) { try { mSessionBinder.disconnect(mControllerStub, getNextSeqNumber()); mSessionBinder.unlinkToDeath(mDeathRecipient, 0); } catch (RuntimeException e) { // No-op } } mConnectedToken = null; mPendingCommands.clear(); mRequestedCommandSeqNumbers.clear(); mCallbackExecutor.execute(() -> { mCallback.onDisconnected(MediaController2.this); }); mSessionBinder = null; } } /** * Returns {@link Session2Token} of the connected session. * If it is not connected yet, it returns {@code null}. *

* This may differ with the {@link Session2Token} from the constructor. For example, if the * controller is created with the token for {@link MediaSession2Service}, this would return * token for the {@link MediaSession2} in the service. * * @return Session2Token of the connected session, or {@code null} if not connected */ @Nullable public Session2Token getConnectedToken() { synchronized (mLock) { return mConnectedToken; } } /** * Returns whether the session's playback is active. * * @return {@code true} if playback active. {@code false} otherwise. * @see ControllerCallback#onPlaybackActiveChanged(MediaController2, boolean) */ public boolean isPlaybackActive() { synchronized (mLock) { return mPlaybackActive; } } /** * Sends a session command to the session *

* @param command the session command * @param args optional arguments * @return a token which will be sent together in {@link ControllerCallback#onCommandResult} * when its result is received. */ @NonNull public Object sendSessionCommand(@NonNull Session2Command command, @Nullable Bundle args) { if (command == null) { throw new IllegalArgumentException("command shouldn't be null"); } ResultReceiver resultReceiver = new ResultReceiver(mResultHandler) { protected void onReceiveResult(int resultCode, Bundle resultData) { synchronized (mLock) { mPendingCommands.remove(this); } mCallbackExecutor.execute(() -> { mCallback.onCommandResult(MediaController2.this, this, command, new Session2Command.Result(resultCode, resultData)); }); } }; synchronized (mLock) { if (mSessionBinder != null) { int seq = getNextSeqNumber(); mPendingCommands.put(resultReceiver, seq); try { mSessionBinder.sendSessionCommand(mControllerStub, seq, command, args, resultReceiver); } catch (RuntimeException e) { mPendingCommands.remove(resultReceiver); resultReceiver.send(RESULT_ERROR_UNKNOWN_ERROR, null); } } } return resultReceiver; } /** * Cancels the session command previously sent. * * @param token the token which is returned from {@link #sendSessionCommand}. */ public void cancelSessionCommand(@NonNull Object token) { if (token == null) { throw new IllegalArgumentException("token shouldn't be null"); } synchronized (mLock) { if (mSessionBinder == null) return; Integer seq = mPendingCommands.remove(token); if (seq != null) { mSessionBinder.cancelSessionCommand(mControllerStub, seq); } } } // Called by Controller2Link.onConnected void onConnected(int seq, Bundle connectionResult) { Session2Link sessionBinder = connectionResult.getParcelable(KEY_SESSION2LINK); Session2CommandGroup allowedCommands = connectionResult.getParcelable(KEY_ALLOWED_COMMANDS); boolean playbackActive = connectionResult.getBoolean(KEY_PLAYBACK_ACTIVE); Bundle tokenExtras = connectionResult.getBundle(KEY_TOKEN_EXTRAS); if (tokenExtras == null) { Log.w(TAG, "extras shouldn't be null."); tokenExtras = Bundle.EMPTY; } else if (MediaSession2.hasCustomParcelable(tokenExtras)) { Log.w(TAG, "extras contain custom parcelable. Ignoring."); tokenExtras = Bundle.EMPTY; } if (DEBUG) { Log.d(TAG, "notifyConnected sessionBinder=" + sessionBinder + ", allowedCommands=" + allowedCommands); } if (sessionBinder == null || allowedCommands == null) { // Connection rejected. close(); return; } synchronized (mLock) { mSessionBinder = sessionBinder; mAllowedCommands = allowedCommands; mPlaybackActive = playbackActive; // Implementation for the local binder is no-op, // so can be used without worrying about deadlock. sessionBinder.linkToDeath(mDeathRecipient, 0); mConnectedToken = new Session2Token(mSessionToken.getUid(), TYPE_SESSION, mSessionToken.getPackageName(), sessionBinder, tokenExtras); } mCallbackExecutor.execute(() -> { mCallback.onConnected(MediaController2.this, allowedCommands); }); } // Called by Controller2Link.onDisconnected void onDisconnected(int seq) { // close() will call mCallback.onDisconnected close(); } // Called by Controller2Link.onPlaybackActiveChanged void onPlaybackActiveChanged(int seq, boolean playbackActive) { synchronized (mLock) { mPlaybackActive = playbackActive; } mCallbackExecutor.execute(() -> { mCallback.onPlaybackActiveChanged(MediaController2.this, playbackActive); }); } // Called by Controller2Link.onSessionCommand void onSessionCommand(int seq, Session2Command command, Bundle args, @Nullable ResultReceiver resultReceiver) { synchronized (mLock) { mRequestedCommandSeqNumbers.add(seq); } mCallbackExecutor.execute(() -> { boolean isCanceled; synchronized (mLock) { isCanceled = !mRequestedCommandSeqNumbers.remove(seq); } if (isCanceled) { if (resultReceiver != null) { resultReceiver.send(RESULT_INFO_SKIPPED, null); } return; } Session2Command.Result result = mCallback.onSessionCommand( MediaController2.this, command, args); if (resultReceiver != null) { if (result == null) { resultReceiver.send(RESULT_INFO_SKIPPED, null); } else { resultReceiver.send(result.getResultCode(), result.getResultData()); } } }); } // Called by Controller2Link.onSessionCommand void onCancelCommand(int seq) { synchronized (mLock) { mRequestedCommandSeqNumbers.remove(seq); } } private int getNextSeqNumber() { synchronized (mLock) { return mNextSeqNumber++; } } private Bundle createConnectionRequest(@NonNull Bundle connectionHints) { Bundle connectionRequest = new Bundle(); connectionRequest.putString(KEY_PACKAGE_NAME, mContext.getPackageName()); connectionRequest.putInt(KEY_PID, Process.myPid()); connectionRequest.putBundle(KEY_CONNECTION_HINTS, connectionHints); return connectionRequest; } private boolean requestConnectToSession(@NonNull Bundle connectionHints) { Session2Link sessionBinder = mSessionToken.getSessionLink(); Bundle connectionRequest = createConnectionRequest(connectionHints); try { sessionBinder.connect(mControllerStub, getNextSeqNumber(), connectionRequest); } catch (RuntimeException e) { Log.w(TAG, "Failed to call connection request", e); return false; } return true; } private boolean requestConnectToService() { // Service. Needs to get fresh binder whenever connection is needed. final Intent intent = new Intent(MediaSession2Service.SERVICE_INTERFACE); intent.setClassName(mSessionToken.getPackageName(), mSessionToken.getServiceName()); // Use bindService() instead of startForegroundService() to start session service for three // reasons. // 1. Prevent session service owner's stopSelf() from destroying service. // With the startForegroundService(), service's call of stopSelf() will trigger immediate // onDestroy() calls on the main thread even when onConnect() is running in another // thread. // 2. Minimize APIs for developers to take care about. // With bindService(), developers only need to take care about Service.onBind() // but Service.onStartCommand() should be also taken care about with the // startForegroundService(). // 3. Future support for UI-less playback // If a service wants to keep running, it should be either foreground service or // bound service. But there had been request for the feature for system apps // and using bindService() will be better fit with it. synchronized (mLock) { boolean result = mContext.bindService( intent, mServiceConnection, Context.BIND_AUTO_CREATE); if (!result) { Log.w(TAG, "bind to " + mSessionToken + " failed"); return false; } else if (DEBUG) { Log.d(TAG, "bind to " + mSessionToken + " succeeded"); } } return true; } /** * 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 MediaController2}. *

* Any incoming event from the {@link MediaSession2} 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 Session2Token mToken; private Bundle mConnectionHints; private Executor mCallbackExecutor; private ControllerCallback mCallback; /** * Creates a builder for {@link MediaController2}. * * @param context context * @param token token of the session to connect to */ public Builder(@NonNull Context context, @NonNull Session2Token token) { if (context == null) { throw new IllegalArgumentException("context shouldn't be null"); } if (token == null) { throw new IllegalArgumentException("token shouldn't be null"); } mContext = context; mToken = token; } /** * Set the connection hints for the controller. *

* {@code connectionHints} is a session-specific argument to send to the session when * connecting. The contents of this bundle may affect the connection result. *

* An {@link IllegalArgumentException} will be thrown if the bundle contains any * non-framework Parcelable objects. * * @param connectionHints a bundle which contains the connection hints * @return The Builder to allow chaining */ @NonNull public Builder setConnectionHints(@NonNull Bundle connectionHints) { if (connectionHints == null) { throw new IllegalArgumentException("connectionHints shouldn't be null"); } if (MediaSession2.hasCustomParcelable(connectionHints)) { throw new IllegalArgumentException("connectionHints shouldn't contain any custom " + "parcelables"); } mConnectionHints = new Bundle(connectionHints); return this; } /** * Set callback for the controller and its executor. * * @param executor callback executor * @param callback session callback. * @return The Builder to allow chaining */ @NonNull public Builder setControllerCallback(@NonNull Executor executor, @NonNull ControllerCallback callback) { if (executor == null) { throw new IllegalArgumentException("executor shouldn't be null"); } if (callback == null) { throw new IllegalArgumentException("callback shouldn't be null"); } mCallbackExecutor = executor; mCallback = callback; return this; } /** * Build {@link MediaController2}. * * @return a new controller */ @NonNull public MediaController2 build() { if (mCallbackExecutor == null) { mCallbackExecutor = mContext.getMainExecutor(); } if (mCallback == null) { mCallback = new ControllerCallback() {}; } if (mConnectionHints == null) { mConnectionHints = Bundle.EMPTY; } return new MediaController2( mContext, mToken, mConnectionHints, mCallbackExecutor, mCallback); } } /** * This API is not generally intended for third party application developers. * Use the AndroidX * Media2 session * Library for consistent behavior across all devices. *

* Interface for listening to change in activeness of the {@link MediaSession2}. */ public abstract static class ControllerCallback { /** * Called when the controller is successfully connected to the session. The controller * becomes available afterwards. * * @param controller the controller for this event * @param allowedCommands commands that's allowed by the session. */ public void onConnected(@NonNull MediaController2 controller, @NonNull Session2CommandGroup allowedCommands) {} /** * Called when the session refuses the controller or the controller is disconnected from * the session. The controller becomes unavailable afterwards and the callback wouldn't * be called. *

* It will be also called after the {@link #close()}, so you can put clean up code here. * You don't need to call {@link #close()} after this. * * @param controller the controller for this event */ public void onDisconnected(@NonNull MediaController2 controller) {} /** * Called when the session's playback activeness is changed. * * @param controller the controller for this event * @param playbackActive {@code true} if the session's playback is active. * {@code false} otherwise. * @see MediaController2#isPlaybackActive() */ public void onPlaybackActiveChanged(@NonNull MediaController2 controller, boolean playbackActive) {} /** * Called when the connected session sent a session command. * * @param controller the controller for this event * @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 MediaController2 controller, @NonNull Session2Command command, @Nullable Bundle args) { return null; } /** * Called when the command sent to the connected session is finished. * * @param controller the controller for this event * @param token the token got from {@link MediaController2#sendSessionCommand} * @param command the session command * @param result the result of the session command */ public void onCommandResult(@NonNull MediaController2 controller, @NonNull Object token, @NonNull Session2Command command, @NonNull Session2Command.Result result) {} } // This will be called on the main thread. private class SessionServiceConnection implements ServiceConnection { private final Bundle mConnectionHints; SessionServiceConnection(@Nullable Bundle connectionHints) { mConnectionHints = connectionHints; } @Override public void onServiceConnected(ComponentName name, IBinder service) { // Note that it's always main-thread. boolean connectRequested = false; try { if (DEBUG) { Log.d(TAG, "onServiceConnected " + name + " " + this); } if (!mSessionToken.getPackageName().equals(name.getPackageName())) { Log.wtf(TAG, "Expected connection to " + mSessionToken.getPackageName() + " but is connected to " + name); return; } IMediaSession2Service iService = IMediaSession2Service.Stub.asInterface(service); if (iService == null) { Log.wtf(TAG, "Service interface is missing."); return; } Bundle connectionRequest = createConnectionRequest(mConnectionHints); iService.connect(mControllerStub, getNextSeqNumber(), connectionRequest); connectRequested = true; } catch (RemoteException e) { Log.w(TAG, "Service " + name + " has died prematurely", e); } finally { if (!connectRequested) { close(); } } } @Override public void onServiceDisconnected(ComponentName name) { // Temporal lose of the binding because of the service crash. System will automatically // rebind, so just no-op. if (DEBUG) { Log.w(TAG, "Session service " + name + " is disconnected."); } close(); } @Override public void onBindingDied(ComponentName name) { // Permanent lose of the binding because of the service package update or removed. // This SessionServiceRecord will be removed accordingly, but forget session binder here // for sure. close(); } } }