| /* |
| * 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; |
| |
| /** |
| * 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. |
| * <p> |
| * This API is not generally intended for third party application developers. |
| * Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a> |
| * <a href="{@docRoot}reference/androidx/media2/package-summary.html">Media2 Library</a> |
| * for consistent behavior across all devices. |
| */ |
| 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<ResultReceiver, Integer> mPendingCommands; |
| //@GuardedBy("mLock") |
| private ArraySet<Integer> 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, |
| @Nullable 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; |
| } |
| 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}. |
| * <p> |
| * 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 |
| * <p> |
| * @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 (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(@Nullable 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(@Nullable 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; |
| } |
| |
| /** |
| * Builder for {@link MediaController2}. |
| * <p> |
| * 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. |
| * <p> |
| * {@code connectionHints} is a session-specific argument to send to the session when |
| * connecting. The contents of this bundle may affect the connection result. |
| * |
| * @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"); |
| } |
| 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() {}; |
| } |
| return new MediaController2( |
| mContext, mToken, mConnectionHints, mCallbackExecutor, mCallback); |
| } |
| } |
| |
| /** |
| * Interface for listening to change in activeness of the {@link MediaSession2}. |
| * <p> |
| * This API is not generally intended for third party application developers. |
| */ |
| 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. |
| * <p> |
| * 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); |
| } |
| // Sanity check |
| 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(); |
| } |
| } |
| } |