blob: 807535bed967aa6a8c4a8b6c46dd20cb94da49d3 [file] [log] [blame]
/*
* 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 android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.media.session.MediaSession;
import android.media.session.MediaSession.Callback;
import android.media.session.PlaybackState;
import android.media.update.ApiLoader;
import android.media.update.MediaSession2Provider;
import android.media.update.MediaSession2Provider.ControllerInfoProvider;
import android.os.Bundle;
import android.os.Handler;
import android.os.Parcelable;
import android.os.Process;
import android.text.TextUtils;
import android.util.ArraySet;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
/**
* Allows a media app to expose its transport controls and playback information in a process to
* other processes including the Android framework and other apps. Common use cases are as follows.
* <ul>
* <li>Bluetooth/wired headset key events support</li>
* <li>Android Auto/Wearable support</li>
* <li>Separating UI process and playback process</li>
* </ul>
* <p>
* A MediaSession2 should be created when an app wants to publish media playback information or
* handle media keys. In general an app only needs one session for all playback, though multiple
* sessions can be created to provide finer grain controls of media.
* <p>
* If you want to support background playback, {@link MediaSessionService2} is preferred
* instead. With it, your playback can be revived even after you've finished playback. See
* {@link MediaSessionService2} for details.
* <p>
* A session can be obtained by {@link #getInstance(Context, Handler)}. The owner of the session may
* pass its session token to other processes to allow them to create a {@link MediaController2}
* to interact with the session.
* <p>
* To receive transport control commands, an underlying media player must be set with
* {@link #setPlayer(MediaPlayerBase)}. Commands will be sent to the underlying player directly
* on the thread that had been specified by {@link #getInstance(Context, Handler)}.
* <p>
* When an app is finished performing playback it must call
* {@link #setPlayer(MediaPlayerBase)} with {@code null} to clean up the session and notify any
* controllers. It's developers responsibility of cleaning the session and releasing resources.
* <p>
* MediaSession2 objects should be used on the handler's thread that is initially given by
* {@link #getInstance(Context, Handler)}.
*
* @see MediaSessionService2
* @hide
*/
// TODO(jaewan): Unhide
// TODO(jaewan): Revisit comments. Currently it's borrowed from the MediaSession.
// TODO(jaewan): Add explicit release(), and make token @NonNull. Session will be active while the
// session exists, and controllers will be invalidated when session becomes inactive.
// TODO(jaewan): Should we support thread safe? It may cause tricky issue such as b/63797089
// TODO(jaewan): Should we make APIs for MediaSessionService2 public? It's helpful for
// developers that doesn't want to override from Browser, but user may not use this
// correctly.
public final class MediaSession2 extends MediaPlayerBase {
private final MediaSession2Provider mProvider;
// Note: Do not define IntDef because subclass can add more command code on top of these.
public static final int COMMAND_CODE_CUSTOM = 0;
public static final int COMMAND_CODE_PLAYBACK_START = 1;
public static final int COMMAND_CODE_PLAYBACK_PAUSE = 2;
public static final int COMMAND_CODE_PLAYBACK_STOP = 3;
public static final int COMMAND_CODE_PLAYBACK_SKIP_NEXT_ITEM = 4;
public static final int COMMAND_CODE_PLAYBACK_SKIP_PREV_ITEM = 5;
/**
* Define a command that a {@link MediaController2} can send to a {@link MediaSession2}.
* <p>
* If {@link #getCommandCode()} isn't {@link #COMMAND_CODE_CUSTOM}), it's predefined command.
* If {@link #getCommandCode()} is {@link #COMMAND_CODE_CUSTOM}), it's custom command and
* {@link #getCustomCommand()} shouldn't be {@code null}.
*/
// TODO(jaewan): Move this into the updatable.
public static final class Command {
private static final String KEY_COMMAND_CODE
= "android.media.mediasession2.command.command_command";
private static final String KEY_COMMAND_CUSTOM_COMMAND
= "android.media.mediasession2.command.custom_command";
private static final String KEY_COMMAND_EXTRA
= "android.media.mediasession2.command.extra";
private final int mCommandCode;
// Nonnull if it's custom command
private final String mCustomCommand;
private final Bundle mExtra;
public Command(int commandCode) {
mCommandCode = commandCode;
mCustomCommand = null;
mExtra = null;
}
public Command(@NonNull String action, @Nullable Bundle extra) {
if (action == null) {
throw new IllegalArgumentException("action shouldn't be null");
}
mCommandCode = COMMAND_CODE_CUSTOM;
mCustomCommand = action;
mExtra = extra;
}
public int getCommandCode() {
return mCommandCode;
}
public @Nullable String getCustomCommand() {
return mCustomCommand;
}
public @Nullable Bundle getExtra() {
return mExtra;
}
/**
* @return a new Bundle instance from the Command
* @hide
*/
public Bundle toBundle() {
Bundle bundle = new Bundle();
bundle.putInt(KEY_COMMAND_CODE, mCommandCode);
bundle.putString(KEY_COMMAND_CUSTOM_COMMAND, mCustomCommand);
bundle.putBundle(KEY_COMMAND_EXTRA, mExtra);
return bundle;
}
/**
* @return a new Command instance from the Bundle
* @hide
*/
public static Command fromBundle(Bundle command) {
int code = command.getInt(KEY_COMMAND_CODE);
if (code != COMMAND_CODE_CUSTOM) {
return new Command(code);
} else {
String customCommand = command.getString(KEY_COMMAND_CUSTOM_COMMAND);
if (customCommand == null) {
return null;
}
return new Command(customCommand, command.getBundle(KEY_COMMAND_EXTRA));
}
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Command)) {
return false;
}
Command other = (Command) obj;
// TODO(jaewan): Should we also compare contents in bundle?
// It may not be possible if the bundle contains private class.
return mCommandCode == other.mCommandCode
&& TextUtils.equals(mCustomCommand, other.mCustomCommand);
}
@Override
public int hashCode() {
final int prime = 31;
return ((mCustomCommand != null) ? mCustomCommand.hashCode() : 0) * prime + mCommandCode;
}
}
/**
* Represent set of {@link Command}.
*/
// TODO(jaewan): Move this to updatable
public static class CommandGroup {
private static final String KEY_COMMANDS =
"android.media.mediasession2.commandgroup.commands";
private ArraySet<Command> mCommands = new ArraySet<>();
public CommandGroup() {
}
public CommandGroup(CommandGroup others) {
mCommands.addAll(others.mCommands);
}
public void addCommand(Command command) {
mCommands.add(command);
}
public void addAllPredefinedCommands() {
// TODO(jaewan): Is there any better way than this?
mCommands.add(new Command(COMMAND_CODE_PLAYBACK_START));
mCommands.add(new Command(COMMAND_CODE_PLAYBACK_PAUSE));
mCommands.add(new Command(COMMAND_CODE_PLAYBACK_STOP));
mCommands.add(new Command(COMMAND_CODE_PLAYBACK_SKIP_NEXT_ITEM));
mCommands.add(new Command(COMMAND_CODE_PLAYBACK_SKIP_PREV_ITEM));
}
public void removeCommand(Command command) {
mCommands.remove(command);
}
public boolean hasCommand(Command command) {
return mCommands.contains(command);
}
public boolean hasCommand(int code) {
if (code == COMMAND_CODE_CUSTOM) {
throw new IllegalArgumentException("Use hasCommand(Command) for custom command");
}
for (int i = 0; i < mCommands.size(); i++) {
if (mCommands.valueAt(i).getCommandCode() == code) {
return true;
}
}
return false;
}
/**
* @return new bundle from the CommandGroup
* @hide
*/
public Bundle toBundle() {
ArrayList<Bundle> list = new ArrayList<>();
for (int i = 0; i < mCommands.size(); i++) {
list.add(mCommands.valueAt(i).toBundle());
}
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(KEY_COMMANDS, list);
return bundle;
}
/**
* @return new instance of CommandGroup from the bundle
* @hide
*/
public static @Nullable CommandGroup fromBundle(Bundle commands) {
if (commands == null) {
return null;
}
List<Parcelable> list = commands.getParcelableArrayList(KEY_COMMANDS);
if (list == null) {
return null;
}
CommandGroup commandGroup = new CommandGroup();
for (int i = 0; i < list.size(); i++) {
Parcelable parcelable = list.get(i);
if (!(parcelable instanceof Bundle)) {
continue;
}
Bundle commandBundle = (Bundle) parcelable;
Command command = Command.fromBundle(commandBundle);
if (command != null) {
commandGroup.addCommand(command);
}
}
return commandGroup;
}
}
/**
* Callback to be called for all incoming commands from {@link MediaController2}s.
* <p>
* If it's not set, the session will accept all controllers and all incoming commands by
* default.
*/
// TODO(jaewan): Can we move this inside of the updatable for default implementation.
public static class SessionCallback {
/**
* Called when a controller is created for this session. Return allowed commands for
* controller. By default it allows all connection requests and commands.
* <p>
* You can reject the connection by return {@code null}. In that case, controller receives
* {@link MediaController2.ControllerCallback#onDisconnected()} and cannot be usable.
*
* @param controller controller information.
* @return allowed commands. Can be {@code null} to reject coonnection.
*/
// TODO(jaewan): Change return type. Once we do, null is for reject.
public @Nullable CommandGroup onConnect(@NonNull ControllerInfo controller) {
CommandGroup commands = new CommandGroup();
commands.addAllPredefinedCommands();
return commands;
}
/**
* Called when a controller sent a command to the session. You can also reject the request
* by return {@code false}.
* <p>
* This method will be called on the session handler.
*
* @param controller controller information.
* @param command a command. This method will be called for every single command.
* @return {@code true} if you want to accept incoming command. {@code false} otherwise.
*/
public boolean onCommandRequest(@NonNull ControllerInfo controller,
@NonNull Command command) {
return true;
}
};
/**
* Builder for {@link MediaSession2}.
* <p>
* Any incoming event from the {@link MediaController2} will be handled on the thread
* that created session with the {@link Builder#build()}.
*/
// TODO(jaewan): Move this to updatable
// TODO(jaewan): Add setRatingType()
// TODO(jaewan): Add setSessionActivity()
public final static class Builder {
private final Context mContext;
private final MediaPlayerBase mPlayer;
private String mId;
private SessionCallback mCallback;
/**
* Constructor.
*
* @param context a context
* @param player a player to handle incoming command from any controller.
* @throws IllegalArgumentException if any parameter is null, or the player is a
* {@link MediaSession2} or {@link MediaController2}.
*/
public Builder(@NonNull Context context, @NonNull MediaPlayerBase player) {
if (context == null) {
throw new IllegalArgumentException("context shouldn't be null");
}
if (player == null) {
throw new IllegalArgumentException("player shouldn't be null");
}
if (player instanceof MediaSession2 || player instanceof MediaController2) {
throw new IllegalArgumentException("player doesn't accept MediaSession2 nor"
+ " MediaController2");
}
mContext = context;
mPlayer = player;
// Ensure non-null
mId = "";
}
/**
* Set ID of the session. If it's not set, an empty string with used to create a session.
* <p>
* 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
*/
public Builder setId(@NonNull String id) {
if (id == null) {
throw new IllegalArgumentException("id shouldn't be null");
}
mId = id;
return this;
}
/**
* Set {@link SessionCallback}.
*
* @param callback session callback.
* @return
*/
public Builder setSessionCallback(@Nullable SessionCallback callback) {
mCallback = callback;
return this;
}
/**
* Build {@link MediaSession2}.
*
* @return a new session
* @throws IllegalStateException if the session with the same id is already exists for the
* package.
*/
public MediaSession2 build() throws IllegalStateException {
if (mCallback == null) {
mCallback = new SessionCallback();
}
return new MediaSession2(mContext, mPlayer, mId, mCallback);
}
}
/**
* Information of a controller.
*/
// TODO(jaewan): Move implementation to the updatable.
public static final class ControllerInfo {
private final ControllerInfoProvider mProvider;
/**
* @hide
*/
// TODO(jaewan): SystemApi
// TODO(jaewan): Also accept componentName to check notificaiton listener.
public ControllerInfo(Context context, int uid, int pid, String packageName,
IMediaSession2Callback callback) {
mProvider = ApiLoader.getProvider(context)
.createMediaSession2ControllerInfoProvider(
this, context, uid, pid, packageName, callback);
}
/**
* @return package name of the controller
*/
public String getPackageName() {
return mProvider.getPackageName_impl();
}
/**
* @return uid of the controller
*/
public int getUid() {
return mProvider.getUid_impl();
}
/**
* 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.
*/
public boolean isTrusted() {
return mProvider.isTrusted_impl();
}
/**
* @hide
* @return
*/
// TODO(jaewan): SystemApi
public ControllerInfoProvider getProvider() {
return mProvider;
}
@Override
public int hashCode() {
return mProvider.hashCode_impl();
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof ControllerInfo)) {
return false;
}
ControllerInfo other = (ControllerInfo) obj;
return mProvider.equals_impl(other.mProvider);
}
@Override
public String toString() {
return "ControllerInfo {pkg=" + getPackageName() + ", uid=" + getUid() + ", trusted="
+ isTrusted() + "}";
}
}
/**
* Constructor is hidden and apps can only instantiate indirectly through {@link Builder}.
* <p>
* This intended behavior and here's the reasons.
* 1. Prevent multiple sessions with the same tag in a media app.
* Whenever it happens only one session was properly setup and others were all dummies.
* Android framework couldn't find the right session to dispatch media key event.
* 2. Simplify session's lifecycle.
* {@link MediaSession} can be available after all of {@link MediaSession#setFlags(int)},
* {@link MediaSession#setCallback(Callback)}, and
* {@link MediaSession#setActive(boolean)}. It was common for an app to omit one, so
* framework had to add heuristics to figure out if an app is
* @hide
*/
private MediaSession2(Context context, MediaPlayerBase player, String id,
SessionCallback callback) {
super();
mProvider = ApiLoader.getProvider(context)
.createMediaSession2(this, context, player, id, callback);
}
/**
* @hide
*/
// TODO(jaewan): SystemApi
public MediaSession2Provider getProvider() {
return mProvider;
}
/**
* Set the underlying {@link MediaPlayerBase} for this session to dispatch incoming event to.
* Events from the {@link MediaController2} will be sent directly to the underlying
* player on the {@link Handler} where the session is created on.
* <p>
* If the new player is successfully set, {@link PlaybackListener}
* will be called to tell the current playback state of the new player.
* <p>
* Calling this method with {@code null} will disconnect binding connection between the
* controllers and also release this object.
*
* @param player a {@link MediaPlayerBase} that handles actual media playback in your app.
* It shouldn't be {@link MediaSession2} nor {@link MediaController2}.
* @throws IllegalArgumentException if the player is either {@link MediaSession2}
* or {@link MediaController2}.
*/
// TODO(jaewan): Add release instead of setPlayer(null).
public void setPlayer(MediaPlayerBase player) throws IllegalArgumentException {
mProvider.setPlayer_impl(player);
}
/**
* @return player
*/
public @Nullable MediaPlayerBase getPlayer() {
return mProvider.getPlayer_impl();
}
/**
* Returns the {@link SessionToken} for creating {@link MediaController2}.
*/
public @NonNull
SessionToken getToken() {
return mProvider.getToken_impl();
}
public @NonNull List<ControllerInfo> getConnectedControllers() {
return mProvider.getConnectedControllers_impl();
}
@Override
public void play() {
mProvider.play_impl();
}
@Override
public void pause() {
mProvider.pause_impl();
}
@Override
public void stop() {
mProvider.stop_impl();
}
@Override
public void skipToPrevious() {
mProvider.skipToPrevious_impl();
}
@Override
public void skipToNext() {
mProvider.skipToNext_impl();
}
@Override
public @NonNull PlaybackState getPlaybackState() {
return mProvider.getPlaybackState_impl();
}
/**
* Add a {@link PlaybackListener} to listen changes in the
* underlying {@link MediaPlayerBase} which is previously set by
* {@link #setPlayer(MediaPlayerBase)}.
* <p>
* Added listeners will be also called when the underlying player is changed.
*
* @param listener the listener that will be run
* @param handler the Handler that will receive the listener
* @throws IllegalArgumentException when either the listener or handler is {@code null}.
*/
// TODO(jaewan): Can handler be null? Follow API guideline after it's finalized.
@Override
public void addPlaybackListener(@NonNull PlaybackListener listener, @NonNull Handler handler) {
mProvider.addPlaybackListener_impl(listener, handler);
}
/**
* Remove previously added {@link PlaybackListener}.
*
* @param listener the listener to be removed
* @throws IllegalArgumentException if the listener is {@code null}.
*/
@Override
public void removePlaybackListener(PlaybackListener listener) {
mProvider.removePlaybackListener_impl(listener);
}
}