blob: 31113daf6506553335ea97e50a1868f288131956 [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 com.android.bluetooth.avrcp;
import android.annotation.NonNull;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.media.session.MediaSession;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.KeyEvent;
import com.android.bluetooth.Utils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This class is directly responsible of maintaining the list of Browsable Players as well as
* the list of Addressable Players. This variation of the list doesn't actually list all the
* available players for a getAvailableMediaPlayers request. Instead it only reports one media
* player with ID=0 and all the other browsable players are folders in the root of that player.
*
* Changing the directory to a browsable player will allow you to traverse that player as normal.
* By only having one root player, we never have to send Addressed Player Changed notifications,
* UIDs Changed notifications, or Available Players Changed notifications.
*
* TODO (apanicke): Add non-browsable players as song items to the root folder. Selecting that
* player would effectively cause player switch by sending a play command to that player.
*/
public class MediaPlayerList {
private static final String TAG = "AvrcpMediaPlayerList";
private static final boolean DEBUG = true;
static boolean sTesting = false;
private static final String PACKAGE_SCHEME = "package";
private static final int NO_ACTIVE_PLAYER = 0;
private static final int BLUETOOTH_PLAYER_ID = 0;
private static final String BLUETOOTH_PLAYER_NAME = "Bluetooth Player";
// mediaId's for the now playing list will be in the form of "NowPlayingId[XX]" where [XX]
// is the Queue ID for the requested item.
private static final String NOW_PLAYING_ID_PATTERN = Util.NOW_PLAYING_PREFIX + "([0-9]*)";
// mediaId's for folder browsing will be in the form of [XX][mediaid], where [XX] is a
// two digit representation of the player id and [mediaid] is the original media id as a
// string.
private static final String BROWSE_ID_PATTERN = "\\d\\d.*";
private Context mContext;
private Looper mLooper; // Thread all media player callbacks and timeouts happen on
private PackageManager mPackageManager;
private MediaSessionManager mMediaSessionManager;
private Map<Integer, MediaPlayerWrapper> mMediaPlayers =
Collections.synchronizedMap(new HashMap<Integer, MediaPlayerWrapper>());
private Map<String, Integer> mMediaPlayerIds =
Collections.synchronizedMap(new HashMap<String, Integer>());
private Map<Integer, BrowsedPlayerWrapper> mBrowsablePlayers =
Collections.synchronizedMap(new HashMap<Integer, BrowsedPlayerWrapper>());
private int mActivePlayerId = NO_ACTIVE_PLAYER;
private AvrcpTargetService.ListCallback mCallback;
private BrowsablePlayerConnector mBrowsablePlayerConnector;
interface MediaUpdateCallback {
void run(MediaData data);
}
interface GetPlayerRootCallback {
void run(int playerId, boolean success, String rootId, int numItems);
}
interface GetFolderItemsCallback {
void run(String parentId, List<ListItem> items);
}
interface FolderUpdateCallback {
void run(boolean availablePlayers, boolean addressedPlayers, boolean uids);
}
MediaPlayerList(Looper looper, Context context) {
Log.v(TAG, "Creating MediaPlayerList");
mLooper = looper;
mContext = context;
// Register for intents where available players might have changed
IntentFilter pkgFilter = new IntentFilter();
pkgFilter.addAction(Intent.ACTION_PACKAGE_REMOVED);
pkgFilter.addAction(Intent.ACTION_PACKAGE_DATA_CLEARED);
pkgFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
pkgFilter.addAction(Intent.ACTION_PACKAGE_CHANGED);
pkgFilter.addDataScheme(PACKAGE_SCHEME);
context.registerReceiver(mPackageChangedBroadcastReceiver, pkgFilter);
mMediaSessionManager =
(MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE);
mMediaSessionManager.addOnActiveSessionsChangedListener(
mActiveSessionsChangedListener, null, new Handler(looper));
mMediaSessionManager.setCallback(mButtonDispatchCallback, null);
}
void init(AvrcpTargetService.ListCallback callback) {
Log.v(TAG, "Initializing MediaPlayerList");
mCallback = callback;
// Build the list of browsable players and afterwards, build the list of media players
Intent intent = new Intent(android.service.media.MediaBrowserService.SERVICE_INTERFACE);
List<ResolveInfo> playerList =
mContext
.getApplicationContext()
.getPackageManager()
.queryIntentServices(intent, PackageManager.MATCH_ALL);
mBrowsablePlayerConnector = BrowsablePlayerConnector.connectToPlayers(mContext, mLooper,
playerList, (List<BrowsedPlayerWrapper> players) -> {
Log.i(TAG, "init: Browsable Player list size is " + players.size());
// Check to see if the list has been cleaned up before this completed
if (mMediaSessionManager == null) {
return;
}
for (BrowsedPlayerWrapper wrapper : players) {
// Generate new id and add the browsable player
if (!mMediaPlayerIds.containsKey(wrapper.getPackageName())) {
mMediaPlayerIds.put(wrapper.getPackageName(), getFreeMediaPlayerId());
}
d("Adding Browser Wrapper for " + wrapper.getPackageName() + " with id "
+ mMediaPlayerIds.get(wrapper.getPackageName()));
mBrowsablePlayers.put(mMediaPlayerIds.get(wrapper.getPackageName()), wrapper);
wrapper.getFolderItems(wrapper.getRootId(),
(int status, String mediaId, List<ListItem> results) -> {
d("Got the contents for: " + mediaId + " : num results="
+ results.size());
});
}
// Construct the list of current players
d("Initializing list of current media players");
List<android.media.session.MediaController> controllers =
mMediaSessionManager.getActiveSessions(null);
for (android.media.session.MediaController controller : controllers) {
addMediaPlayer(controller);
}
// If there were any active players and we don't already have one due to the Media
// Framework Callbacks then set the highest priority one to active
if (mActivePlayerId == 0 && mMediaPlayers.size() > 0) setActivePlayer(1);
});
}
void cleanup() {
mContext.unregisterReceiver(mPackageChangedBroadcastReceiver);
mMediaSessionManager.removeOnActiveSessionsChangedListener(mActiveSessionsChangedListener);
mMediaSessionManager.setCallback(null, null);
mMediaSessionManager = null;
mMediaPlayerIds.clear();
for (MediaPlayerWrapper player : mMediaPlayers.values()) {
player.cleanup();
}
mMediaPlayers.clear();
if (mBrowsablePlayerConnector != null) {
mBrowsablePlayerConnector.cleanup();
}
for (BrowsedPlayerWrapper player : mBrowsablePlayers.values()) {
player.disconnect();
}
mBrowsablePlayers.clear();
}
int getCurrentPlayerId() {
return BLUETOOTH_PLAYER_ID;
}
int getFreeMediaPlayerId() {
int id = 1;
while (mMediaPlayerIds.containsValue(id)) {
id++;
}
return id;
}
MediaPlayerWrapper getActivePlayer() {
return mMediaPlayers.get(mActivePlayerId);
}
// In this case the displayed player is the Bluetooth Player, the number of items is equal
// to the number of players. The root ID will always be empty string in this case as well.
void getPlayerRoot(int playerId, GetPlayerRootCallback cb) {
cb.run(playerId, playerId == BLUETOOTH_PLAYER_ID, "", mBrowsablePlayers.size());
}
// Return the "Bluetooth Player" as the only player always
List<PlayerInfo> getMediaPlayerList() {
PlayerInfo info = new PlayerInfo();
info.id = BLUETOOTH_PLAYER_ID;
info.name = BLUETOOTH_PLAYER_NAME;
info.browsable = true;
List<PlayerInfo> ret = new ArrayList<PlayerInfo>();
ret.add(info);
return ret;
}
@NonNull
String getCurrentMediaId() {
final MediaPlayerWrapper player = getActivePlayer();
if (player == null) return "";
final PlaybackState state = player.getPlaybackState();
final List<Metadata> queue = player.getCurrentQueue();
// Disable the now playing list if the player doesn't have a queue or provide an active
// queue ID that can be used to determine the active song in the queue.
if (state == null
|| state.getActiveQueueItemId() == MediaSession.QueueItem.UNKNOWN_ID
|| queue.size() == 0) {
d("getCurrentMediaId: No active queue item Id sending empty mediaId: PlaybackState="
+ state);
return "";
}
return Util.NOW_PLAYING_PREFIX + state.getActiveQueueItemId();
}
@NonNull
Metadata getCurrentSongInfo() {
final MediaPlayerWrapper player = getActivePlayer();
if (player == null) return Util.empty_data();
return player.getCurrentMetadata();
}
PlaybackState getCurrentPlayStatus() {
final MediaPlayerWrapper player = getActivePlayer();
if (player == null) return null;
return player.getPlaybackState();
}
@NonNull
List<Metadata> getNowPlayingList() {
// Only send the current song for the now playing if there is no active song. See
// |getCurrentMediaId()| for reasons why there might be no active song.
if (getCurrentMediaId().equals("")) {
List<Metadata> ret = new ArrayList<Metadata>();
Metadata data = getCurrentSongInfo();
data.mediaId = "";
ret.add(data);
return ret;
}
return getActivePlayer().getCurrentQueue();
}
void playItem(int playerId, boolean nowPlaying, String mediaId) {
if (nowPlaying) {
playNowPlayingItem(mediaId);
} else {
playFolderItem(mediaId);
}
}
private void playNowPlayingItem(String mediaId) {
d("playNowPlayingItem: mediaId=" + mediaId);
Pattern regex = Pattern.compile(NOW_PLAYING_ID_PATTERN);
Matcher m = regex.matcher(mediaId);
if (!m.find()) {
// This should never happen since we control the media ID's reported
Log.wtf(TAG, "playNowPlayingItem: Couldn't match mediaId to pattern: mediaId="
+ mediaId);
}
long queueItemId = Long.parseLong(m.group(1));
if (getActivePlayer() != null) {
getActivePlayer().playItemFromQueue(queueItemId);
}
}
private void playFolderItem(String mediaId) {
d("playFolderItem: mediaId=" + mediaId);
if (!mediaId.matches(BROWSE_ID_PATTERN)) {
// This should never happen since we control the media ID's reported
Log.wtf(TAG, "playFolderItem: mediaId didn't match pattern: mediaId=" + mediaId);
}
int playerIndex = Integer.parseInt(mediaId.substring(0, 2));
String itemId = mediaId.substring(2);
if (!mBrowsablePlayers.containsKey(playerIndex)) {
e("playFolderItem: Do not have the a browsable player with ID " + playerIndex);
return;
}
mBrowsablePlayers.get(playerIndex).playItem(itemId);
}
void getFolderItemsMediaPlayerList(GetFolderItemsCallback cb) {
d("getFolderItemsMediaPlayerList: Sending Media Player list for root directory");
ArrayList<ListItem> playerList = new ArrayList<ListItem>();
for (BrowsedPlayerWrapper player : mBrowsablePlayers.values()) {
String displayName = Util.getDisplayName(mContext, player.getPackageName());
int id = mMediaPlayerIds.get(player.getPackageName());
d("getFolderItemsMediaPlayerList: Adding player " + displayName);
Folder playerFolder = new Folder(String.format("%02d", id), false, displayName);
playerList.add(new ListItem(playerFolder));
}
cb.run("", playerList);
return;
}
void getFolderItems(int playerId, String mediaId, GetFolderItemsCallback cb) {
// The playerId is unused since we always assume the remote device is using the
// Bluetooth Player.
d("getFolderItems(): playerId=" + playerId + ", mediaId=" + mediaId);
// The device is requesting the content of the root folder. This folder contains a list of
// Browsable Media Players displayed as folders with their contents contained within.
if (mediaId.equals("")) {
getFolderItemsMediaPlayerList(cb);
return;
}
if (!mediaId.matches(BROWSE_ID_PATTERN)) {
// This should never happen since we control the media ID's reported
Log.wtf(TAG, "getFolderItems: mediaId didn't match pattern: mediaId=" + mediaId);
}
int playerIndex = Integer.parseInt(mediaId.substring(0, 2));
String itemId = mediaId.substring(2);
// TODO (apanicke): Add timeouts for looking up folder items since media browsers don't
// have to respond.
if (mBrowsablePlayers.containsKey(playerIndex)) {
BrowsedPlayerWrapper wrapper = mBrowsablePlayers.get(playerIndex);
if (itemId.equals("")) {
Log.i(TAG, "Empty media id, getting the root for "
+ wrapper.getPackageName());
itemId = wrapper.getRootId();
}
wrapper.getFolderItems(itemId, (status, id, results) -> {
if (status != BrowsedPlayerWrapper.STATUS_SUCCESS) {
cb.run(mediaId, new ArrayList<ListItem>());
return;
}
String playerPrefix = String.format("%02d", playerIndex);
for (ListItem item : results) {
if (item.isFolder) {
item.folder.mediaId = playerPrefix.concat(item.folder.mediaId);
} else {
item.song.mediaId = playerPrefix.concat(item.song.mediaId);
}
}
cb.run(mediaId, results);
});
return;
} else {
cb.run(mediaId, new ArrayList<ListItem>());
}
}
// Adds the controller to the MediaPlayerList or updates the controller if we already had
// a controller for a package. Returns the new ID of the controller where its added or its
// previous value if it already existed. Returns -1 if the controller passed in is invalid
int addMediaPlayer(android.media.session.MediaController controller) {
if (controller == null) return -1;
// Each new player has an ID of 1 plus the highest ID. The ID 0 is reserved to signify that
// there is no active player. If we already have a browsable player for the package, reuse
// that key.
String packageName = controller.getPackageName();
if (!mMediaPlayerIds.containsKey(packageName)) {
mMediaPlayerIds.put(packageName, getFreeMediaPlayerId());
}
int playerId = mMediaPlayerIds.get(packageName);
// If we already have a controller for the package, then update it with this new controller
// as the old controller has probably gone stale.
if (mMediaPlayers.containsKey(playerId)) {
d("Already have a controller for the player: " + packageName + ", updating instead");
MediaPlayerWrapper player = mMediaPlayers.get(playerId);
player.updateMediaController(MediaControllerFactory.wrap(controller));
// If the media controller we updated was the active player check if the media updated
if (playerId == mActivePlayerId) {
sendMediaUpdate(getActivePlayer().getCurrentMediaData());
}
return playerId;
}
MediaPlayerWrapper newPlayer = MediaPlayerWrapper.wrap(
MediaControllerFactory.wrap(controller),
mLooper);
Log.i(TAG, "Adding wrapped media player: " + packageName + " at key: "
+ mMediaPlayerIds.get(controller.getPackageName()));
mMediaPlayers.put(playerId, newPlayer);
return playerId;
}
void removeMediaPlayer(int playerId) {
if (!mMediaPlayers.containsKey(playerId)) {
e("Trying to remove nonexistent media player: " + playerId);
return;
}
// If we removed the active player, set no player as active until the Media Framework
// tells us otherwise
if (playerId == mActivePlayerId && playerId != NO_ACTIVE_PLAYER) {
getActivePlayer().unregisterCallback();
mActivePlayerId = NO_ACTIVE_PLAYER;
sendMediaUpdate(new MediaData(Util.empty_data(), null, null));
}
final MediaPlayerWrapper wrapper = mMediaPlayers.get(playerId);
d("Removing media player " + wrapper.getPackageName());
mMediaPlayerIds.remove(wrapper.getPackageName());
mMediaPlayers.remove(playerId);
wrapper.cleanup();
}
void setActivePlayer(int playerId) {
if (!mMediaPlayers.containsKey(playerId)) {
e("Player doesn't exist in list(): " + playerId);
return;
}
if (playerId == mActivePlayerId) {
Log.w(TAG, getActivePlayer().getPackageName() + " is already the active player");
return;
}
if (mActivePlayerId != NO_ACTIVE_PLAYER) getActivePlayer().unregisterCallback();
mActivePlayerId = playerId;
getActivePlayer().registerCallback(mMediaPlayerCallback);
Log.i(TAG, "setActivePlayer(): setting player to " + getActivePlayer().getPackageName());
// Ensure that metadata is synced on the new player
if (!getActivePlayer().isMetadataSynced()) {
Log.w(TAG, "setActivePlayer(): Metadata not synced on new player");
return;
}
if (Utils.isPtsTestMode()) {
sendFolderUpdate(true, true, false);
}
sendMediaUpdate(getActivePlayer().getCurrentMediaData());
}
// TODO (apanicke): Add logging for media key events in dumpsys
void sendMediaKeyEvent(int key, boolean pushed) {
d("sendMediaKeyEvent: key=" + key + " pushed=" + pushed);
int action = pushed ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP;
KeyEvent event = new KeyEvent(action, AvrcpPassthrough.toKeyCode(key));
mMediaSessionManager.dispatchMediaKeyEvent(event);
}
private void sendFolderUpdate(boolean availablePlayers, boolean addressedPlayers,
boolean uids) {
d("sendFolderUpdate");
if (mCallback == null) {
return;
}
mCallback.run(availablePlayers, addressedPlayers, uids);
}
private void sendMediaUpdate(MediaData data) {
d("sendMediaUpdate");
if (mCallback == null) {
return;
}
// Always have items in the queue
if (data.queue.size() == 0) {
Log.i(TAG, "sendMediaUpdate: Creating a one item queue for a player with no queue");
data.queue.add(data.metadata);
}
mCallback.run(data);
}
private final MediaSessionManager.OnActiveSessionsChangedListener
mActiveSessionsChangedListener =
new MediaSessionManager.OnActiveSessionsChangedListener() {
@Override
public void onActiveSessionsChanged(
List<android.media.session.MediaController> controllers) {
synchronized (MediaPlayerList.this) {
Log.v(TAG, "onActiveSessionsChanged: number of controllers: "
+ controllers.size());
if (controllers.size() == 0) return;
// Apps are allowed to have multiple MediaControllers. If an app does have
// multiple controllers then controllers contains them in highest
// priority order. Since we only want to keep the highest priority one,
// we keep track of which controllers we updated and skip over ones
// we've already looked at.
HashSet<String> addedPackages = new HashSet<String>();
for (int i = 0; i < controllers.size(); i++) {
Log.d(TAG, "onActiveSessionsChanged: controller: "
+ controllers.get(i).getPackageName());
if (addedPackages.contains(controllers.get(i).getPackageName())) {
continue;
}
addedPackages.add(controllers.get(i).getPackageName());
addMediaPlayer(controllers.get(i));
}
// Remove all players that weren't added.
for (String packageName : mMediaPlayerIds.keySet()) {
if (!addedPackages.contains(packageName)) {
removeMediaPlayer(mMediaPlayerIds.get(packageName));
}
}
}
}
};
// TODO (apanicke): Write a test that tests uninstalling the active session
private final BroadcastReceiver mPackageChangedBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
Log.v(TAG, "mPackageChangedBroadcastReceiver: action: " + action);
if (action.equals(Intent.ACTION_PACKAGE_REMOVED)
|| action.equals(Intent.ACTION_PACKAGE_DATA_CLEARED)) {
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return;
String packageName = intent.getData().getSchemeSpecificPart();
if (packageName != null && mMediaPlayerIds.containsKey(packageName)) {
removeMediaPlayer(mMediaPlayerIds.get(packageName));
}
} else if (action.equals(Intent.ACTION_PACKAGE_ADDED)
|| action.equals(Intent.ACTION_PACKAGE_CHANGED)) {
String packageName = intent.getData().getSchemeSpecificPart();
if (packageName != null) {
if (DEBUG) Log.d(TAG, "Name of package changed: " + packageName);
// TODO (apanicke): Handle either updating or adding the new package.
// Check if its browsable and send the UIDS changed to update the
// root folder
}
}
}
};
private final MediaPlayerWrapper.Callback mMediaPlayerCallback =
new MediaPlayerWrapper.Callback() {
@Override
public void mediaUpdatedCallback(MediaData data) {
if (data.metadata == null) {
Log.d(TAG, "mediaUpdatedCallback(): metadata is null");
return;
}
if (data.state == null) {
Log.w(TAG, "mediaUpdatedCallback(): Tried to update with null state");
return;
}
sendMediaUpdate(data);
}
};
private final MediaSessionManager.Callback mButtonDispatchCallback =
new MediaSessionManager.Callback() {
@Override
public void onMediaKeyEventDispatched(KeyEvent event, MediaSession.Token token) {
// TODO (apanicke): Add logging for these
}
@Override
public void onMediaKeyEventDispatched(KeyEvent event, ComponentName receiver) {
// TODO (apanicke): Add logging for these
}
@Override
public void onAddressedPlayerChanged(MediaSession.Token token) {
android.media.session.MediaController controller =
new android.media.session.MediaController(mContext, token);
if (!mMediaPlayerIds.containsKey(controller.getPackageName())) {
// Since we have a controller, we can try to to recover by adding the
// player and then setting it as active.
Log.w(TAG, "onAddressedPlayerChanged(Token): Addressed Player "
+ "changed to a player we didn't have a session for");
addMediaPlayer(controller);
}
Log.i(TAG, "onAddressedPlayerChanged: token=" + controller.getPackageName());
setActivePlayer(mMediaPlayerIds.get(controller.getPackageName()));
}
@Override
public void onAddressedPlayerChanged(ComponentName receiver) {
if (receiver == null) {
return;
}
if (!mMediaPlayerIds.containsKey(receiver.getPackageName())) {
e("onAddressedPlayerChanged(Component): Addressed Player "
+ "changed to a player we don't have a session for");
return;
}
Log.i(TAG, "onAddressedPlayerChanged: component=" + receiver.getPackageName());
setActivePlayer(mMediaPlayerIds.get(receiver.getPackageName()));
}
};
void dump(StringBuilder sb) {
sb.append("List of MediaControllers: size=" + mMediaPlayers.size() + "\n");
for (int id : mMediaPlayers.keySet()) {
if (id == mActivePlayerId) {
sb.append("<Active> ");
}
MediaPlayerWrapper player = mMediaPlayers.get(id);
sb.append(" Media Player " + id + ": " + player.getPackageName() + "\n");
sb.append(player.toString().replaceAll("(?m)^", " "));
sb.append("\n");
}
sb.append("List of Browsers: size=" + mBrowsablePlayers.size() + "\n");
for (BrowsedPlayerWrapper player : mBrowsablePlayers.values()) {
sb.append(player.toString().replaceAll("(?m)^", " "));
sb.append("\n");
}
// TODO (apanicke): Add media key events
// TODO (apanicke): Add last sent data
// TODO (apanicke): Add addressed player history
}
private static void e(String message) {
if (sTesting) {
Log.wtfStack(TAG, message);
} else {
Log.e(TAG, message);
}
}
private static void d(String message) {
if (DEBUG) {
Log.d(TAG, message);
}
}
}