content: /*
   * 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 <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
   * <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session
   * Library</a> 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<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,
              @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;
              }
              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 (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 <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
       * <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session
       * Library</a> for consistent behavior across all devices.
       * <p>
       * 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.
           * <p>
           * 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 <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
       * <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session
       * Library</a> for consistent behavior across all devices.
       * <p>
       * 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.
           * <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();
          }
      }
  }
  