| /* |
| * 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.Nullable; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.media.browse.MediaBrowser.MediaItem; |
| import android.media.session.PlaybackState; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.os.Message; |
| import android.util.Log; |
| |
| import java.util.ArrayList; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| |
| /* |
| * Helper class to create an abstraction layer for the MediaBrowser service that AVRCP can use. |
| * |
| * TODO (apanicke): Add timeouts in case a browser takes forever to connect or gets stuck. |
| * Right now this is ok because the BrowsablePlayerConnector will handle timeouts. |
| */ |
| class BrowsedPlayerWrapper { |
| private static final String TAG = "AvrcpBrowsedPlayerWrapper"; |
| private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); |
| |
| enum ConnectionState { |
| DISCONNECTED, |
| CONNECTING, |
| CONNECTED, |
| } |
| |
| interface ConnectionCallback { |
| void run(int status, BrowsedPlayerWrapper wrapper); |
| } |
| |
| interface PlaybackCallback { |
| void run(int status); |
| } |
| |
| interface BrowseCallback { |
| void run(int status, String mediaId, List<ListItem> results); |
| } |
| |
| public static final int STATUS_SUCCESS = 0; |
| public static final int STATUS_CONN_ERROR = 1; |
| public static final int STATUS_LOOKUP_ERROR = 2; |
| public static final int STATUS_PLAYBACK_TIMEOUT_ERROR = 3; |
| |
| private MediaBrowser mWrappedBrowser; |
| |
| // TODO (apanicke): Store the context in the factories so that we don't need to save this. |
| // As long as the service is alive those factories will have a valid context. |
| private final Context mContext; |
| private final Looper mLooper; |
| private final String mPackageName; |
| private final Object mCallbackLock = new Object(); |
| private ConnectionCallback mCallback; |
| |
| // TODO(apanicke): We cache this because normally you can only grab the root |
| // while connected. We shouldn't cache this since theres nothing in the framework documentation |
| // that says this can't change between connections. Instead always treat empty string as root. |
| private String mRoot = ""; |
| |
| // A linked hash map that keeps the contents of the last X browsed folders. |
| // |
| // NOTE: This is needed since some carkits will repeatedly request each item in a folder |
| // individually, incrementing the index of the requested item by one at a time. Going through |
| // the subscription process for each individual item is incredibly slow so we cache the items |
| // in the folder in order to speed up the process. We still run the risk of one device pushing |
| // out a cached folder that another device was using, but this is highly unlikely since for |
| // this to happen you would need to be connected to two carkits at the same time. |
| // |
| // TODO (apanicke): Dynamically set the number of cached folders equal to the max number |
| // of connected devices because that is the maximum number of folders that can be browsed at |
| // a single time. |
| static final int NUM_CACHED_FOLDERS = 5; |
| LinkedHashMap<String, List<ListItem>> mCachedFolders = |
| new LinkedHashMap<String, List<ListItem>>(NUM_CACHED_FOLDERS) { |
| @Override |
| protected boolean removeEldestEntry(Map.Entry<String, List<ListItem>> eldest) { |
| return size() > NUM_CACHED_FOLDERS; |
| } |
| }; |
| |
| // TODO (apanicke): Investigate if there is a way to create this just by passing in the |
| // MediaBrowser. Right now there is no obvious way to create the browser then update the |
| // connection callback without being forced to re-create the object every time. |
| private BrowsedPlayerWrapper(Context context, Looper looper, String packageName, |
| String className) { |
| mContext = context; |
| mPackageName = packageName; |
| mLooper = looper; |
| mWrappedBrowser = MediaBrowserFactory.make( |
| context, |
| new ComponentName(packageName, className), |
| new MediaConnectionCallback(), |
| null); |
| } |
| |
| static BrowsedPlayerWrapper wrap(Context context, Looper looper, String packageName, |
| String className) { |
| Log.i(TAG, "Wrapping Media Browser " + packageName); |
| BrowsedPlayerWrapper wrapper = |
| new BrowsedPlayerWrapper(context, looper, packageName, className); |
| return wrapper; |
| } |
| |
| /** |
| * Connect to the media application's MediaBrowserService |
| * |
| * Connections are asynchronous in nature. The given callback will be invoked once the |
| * connection is established. The connection will be torn down once your callback is executed |
| * when using this function. If you wish to control the lifecycle of the connection on your own |
| * then use {@link #setCallbackAndConnect(ConnectionCallback)} instead. |
| * |
| * @param cb A callback to execute once the connection is established |
| * @return True if we successfully make a connection attempt, False otherwise |
| */ |
| boolean connect(ConnectionCallback cb) { |
| if (cb == null) { |
| Log.wtfStack(TAG, "connect: Trying to connect to " + mPackageName |
| + "with null callback"); |
| } |
| return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> { |
| cb.run(status, wrapper); |
| disconnect(); |
| }); |
| } |
| |
| /** |
| * Disconnect from the media application's MediaBrowserService |
| * |
| * This clears any pending requests. This function is safe to call even if a connection isn't |
| * currently open. |
| */ |
| void disconnect() { |
| if (DEBUG) Log.d(TAG, "disconnect: Disconnecting from " + mPackageName); |
| mWrappedBrowser.disconnect(); |
| clearCallback(); |
| } |
| |
| boolean setCallbackAndConnect(ConnectionCallback callback) { |
| synchronized (mCallbackLock) { |
| if (mCallback != null) { |
| Log.w(TAG, "setCallbackAndConnect: Already trying to connect to "); |
| return false; |
| } |
| mCallback = callback; |
| } |
| if (DEBUG) Log.d(TAG, "Set mCallback, connecting to " + mPackageName); |
| mWrappedBrowser.connect(); |
| return true; |
| } |
| |
| void executeCallback(int status, BrowsedPlayerWrapper player) { |
| final ConnectionCallback callback; |
| synchronized (mCallbackLock) { |
| if (mCallback == null) { |
| Log.w(TAG, "Callback is NULL. Cannot execute"); |
| return; |
| } |
| callback = mCallback; |
| } |
| if (DEBUG) Log.d(TAG, "Executing callback"); |
| callback.run(status, player); |
| } |
| |
| void clearCallback() { |
| synchronized (mCallbackLock) { |
| mCallback = null; |
| } |
| if (DEBUG) Log.d(TAG, "mCallback = null"); |
| } |
| |
| public String getPackageName() { |
| return mPackageName; |
| } |
| |
| public String getRootId() { |
| return mRoot; |
| } |
| |
| /** |
| * Requests to play a media item with a given media ID |
| * |
| * @param mediaId A string indicating the piece of media you would like to play |
| * @return False if any other requests are being serviced, True otherwise |
| */ |
| public boolean playItem(String mediaId) { |
| if (DEBUG) Log.d(TAG, "playItem: Play item from media ID: " + mediaId); |
| return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> { |
| if (DEBUG) Log.d(TAG, "playItem: Connected to browsable player " + mPackageName); |
| MediaController controller = MediaControllerFactory.make(mContext, |
| wrapper.mWrappedBrowser.getSessionToken()); |
| MediaController.TransportControls ctrl = controller.getTransportControls(); |
| Log.i(TAG, "playItem: Playing " + mediaId); |
| ctrl.playFromMediaId(mediaId, null); |
| |
| MediaPlaybackListener mpl = new MediaPlaybackListener(mLooper, controller); |
| mpl.waitForPlayback((int playbackStatus) -> { |
| Log.i(TAG, "playItem: Media item playback returned, status: " + playbackStatus); |
| disconnect(); |
| }); |
| }); |
| } |
| |
| /** |
| * Request the contents of a folder item identified by the given media ID |
| * |
| * Contents must be loaded from a service and are returned asynchronously. |
| * |
| * @param mediaId A string indicating the piece of media you would like to play |
| * @param cb A Callback that returns the loaded contents of the requested media ID |
| * @return False if any other requests are being serviced, True otherwise |
| */ |
| // TODO (apanicke): Determine what happens when we subscribe to the same item while a |
| // callback is in flight. |
| // |
| // TODO (apanicke): Currently we do a full folder lookup even if the remote device requests |
| // info for only one item. Add a lookup function that can handle getting info for a single |
| // item. |
| public boolean getFolderItems(String mediaId, BrowseCallback cb) { |
| if (mCachedFolders.containsKey(mediaId)) { |
| Log.i(TAG, "getFolderItems: Grabbing cached data for mediaId: " + mediaId); |
| cb.run(STATUS_SUCCESS, mediaId, Util.cloneList(mCachedFolders.get(mediaId))); |
| return true; |
| } |
| |
| if (cb == null) { |
| Log.wtfStack(TAG, "getFolderItems: Trying to connect to " + mPackageName |
| + "with null browse callback"); |
| } |
| |
| if (DEBUG) Log.d(TAG, "getFolderItems: Connecting to browsable player: " + mPackageName); |
| return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> { |
| Log.i(TAG, "getFolderItems: Connected to browsable player: " + mPackageName); |
| if (status != STATUS_SUCCESS) { |
| cb.run(status, "", new ArrayList<ListItem>()); |
| } |
| getFolderItemsInternal(mediaId, cb); |
| }); |
| } |
| |
| // Internal function to call once the Browser is connected |
| private boolean getFolderItemsInternal(String mediaId, BrowseCallback cb) { |
| mWrappedBrowser.subscribe(mediaId, new BrowserSubscriptionCallback(cb)); |
| return true; |
| } |
| |
| class MediaConnectionCallback extends MediaBrowser.ConnectionCallback { |
| @Override |
| public void onConnected() { |
| Log.i(TAG, "onConnected: " + mPackageName + " is connected"); |
| // Get the root while connected because we may need to use it when disconnected. |
| mRoot = mWrappedBrowser.getRoot(); |
| |
| if (mRoot == null || mRoot.isEmpty()) { |
| executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this); |
| return; |
| } |
| |
| executeCallback(STATUS_SUCCESS, BrowsedPlayerWrapper.this); |
| } |
| |
| |
| @Override |
| public void onConnectionFailed() { |
| Log.w(TAG, "onConnectionFailed: Connection Failed with " + mPackageName); |
| executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this); |
| // No need to call disconnect as we never connected. Just need to remove our callback. |
| clearCallback(); |
| } |
| |
| // TODO (apanicke): Add a check to list a player as unbrowsable if it suspends immediately |
| // after connection. |
| @Override |
| public void onConnectionSuspended() { |
| executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this); |
| disconnect(); |
| Log.i(TAG, "onConnectionSuspended: Connection Suspended with " + mPackageName); |
| } |
| } |
| |
| class TimeoutHandler extends Handler { |
| static final int MSG_TIMEOUT = 0; |
| static final long CALLBACK_TIMEOUT_MS = 5000; |
| |
| private PlaybackCallback mPlaybackCallback = null; |
| |
| TimeoutHandler(Looper looper, PlaybackCallback cb) { |
| super(looper); |
| mPlaybackCallback = cb; |
| } |
| |
| @Override |
| public void handleMessage(Message msg) { |
| if (msg.what != MSG_TIMEOUT) { |
| Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what); |
| return; |
| } |
| |
| Log.e(TAG, "Timeout while waiting for playback to begin on " + mPackageName); |
| mPlaybackCallback.run(STATUS_PLAYBACK_TIMEOUT_ERROR); |
| } |
| } |
| |
| class MediaPlaybackListener extends MediaController.Callback { |
| private final Object mTimeoutHandlerLock = new Object(); |
| private Handler mTimeoutHandler = null; |
| private Looper mLooper = null; |
| private MediaController mController = null; |
| private PlaybackCallback mPlaybackCallback = null; |
| |
| MediaPlaybackListener(Looper looper, MediaController controller) { |
| synchronized (mTimeoutHandlerLock) { |
| mController = controller; |
| mLooper = looper; |
| } |
| } |
| |
| void waitForPlayback(PlaybackCallback cb) { |
| synchronized (mTimeoutHandlerLock) { |
| mPlaybackCallback = cb; |
| |
| // If we don't already have the proper state then register the callbacks to execute |
| // on the same thread as the timeout thread. This prevents a race condition where a |
| // timeout happens at the same time as an update. Then set the timeout |
| PlaybackState state = mController.getPlaybackState(); |
| if (state == null || state.getState() != PlaybackState.STATE_PLAYING) { |
| Log.d(TAG, "MediaPlayback: Waiting for media to play for " + mPackageName); |
| mTimeoutHandler = new TimeoutHandler(mLooper, mPlaybackCallback); |
| mController.registerCallback(this, mTimeoutHandler); |
| mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT, |
| TimeoutHandler.CALLBACK_TIMEOUT_MS); |
| } else { |
| Log.d(TAG, "MediaPlayback: Media is already playing for " + mPackageName); |
| mPlaybackCallback.run(STATUS_SUCCESS); |
| cleanup(); |
| } |
| } |
| } |
| |
| void cleanup() { |
| synchronized (mTimeoutHandlerLock) { |
| if (mController != null) { |
| mController.unregisterCallback(this); |
| } |
| mController = null; |
| |
| if (mTimeoutHandler != null) { |
| mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); |
| } |
| mTimeoutHandler = null; |
| mPlaybackCallback = null; |
| } |
| } |
| |
| @Override |
| public void onPlaybackStateChanged(@Nullable PlaybackState state) { |
| if (DEBUG) Log.d(TAG, "MediaPlayback: " + mPackageName + " -> " + state.toString()); |
| if (state.getState() == PlaybackState.STATE_PLAYING) { |
| mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); |
| mPlaybackCallback.run(STATUS_SUCCESS); |
| cleanup(); |
| } |
| } |
| } |
| |
| /** |
| * Subscription callback handler. Subscribe to a folder to get its contents. We generate a new |
| * instance for this class for each subscribe call to make it easier to differentiate between |
| * the callers. |
| */ |
| private class BrowserSubscriptionCallback extends MediaBrowser.SubscriptionCallback { |
| BrowseCallback mBrowseCallback = null; |
| |
| BrowserSubscriptionCallback(BrowseCallback cb) { |
| mBrowseCallback = cb; |
| } |
| |
| @Override |
| public void onChildrenLoaded(String parentId, List<MediaItem> children) { |
| if (DEBUG) { |
| Log.d(TAG, "onChildrenLoaded: mediaId=" + parentId + " size= " + children.size()); |
| } |
| |
| if (mBrowseCallback == null) { |
| Log.w(TAG, "onChildrenLoaded: " + mPackageName |
| + " children loaded while callback is null"); |
| } |
| |
| // TODO (apanicke): Instead of always unsubscribing, only unsubscribe from folders |
| // that aren't cached. This will let us update what is cached on the fly and prevent |
| // us from serving stale data. |
| mWrappedBrowser.unsubscribe(parentId); |
| |
| ArrayList<ListItem> return_list = new ArrayList<ListItem>(); |
| |
| for (MediaItem item : children) { |
| if (DEBUG) { |
| Log.d(TAG, "onChildrenLoaded: Child=\"" + item.toString() |
| + "\", ID=\"" + item.getMediaId() + "\""); |
| } |
| |
| if (item.isBrowsable()) { |
| CharSequence titleCharSequence = item.getDescription().getTitle(); |
| String title = "Not Provided"; |
| if (titleCharSequence != null) { |
| title = titleCharSequence.toString(); |
| } |
| Folder f = new Folder(item.getMediaId(), false, title); |
| return_list.add(new ListItem(f)); |
| } else { |
| return_list.add(new ListItem(Util.toMetadata(item))); |
| } |
| } |
| |
| mCachedFolders.put(parentId, return_list); |
| |
| // Clone the list so that the callee can mutate it without affecting the cached data |
| mBrowseCallback.run(STATUS_SUCCESS, parentId, Util.cloneList(return_list)); |
| mBrowseCallback = null; |
| disconnect(); |
| } |
| |
| /* mediaId is invalid */ |
| @Override |
| public void onError(String id) { |
| Log.e(TAG, "BrowserSubscriptionCallback: Could not get folder items"); |
| mBrowseCallback.run(STATUS_LOOKUP_ERROR, id, new ArrayList<ListItem>()); |
| disconnect(); |
| } |
| } |
| |
| @Override |
| public String toString() { |
| StringBuilder sb = new StringBuilder(); |
| sb.append("Browsable Package Name: " + mPackageName + "\n"); |
| sb.append(" Cached Media ID's: "); |
| for (String id : mCachedFolders.keySet()) { |
| sb.append("\"" + id + "\", "); |
| } |
| sb.append("\n"); |
| return sb.toString(); |
| } |
| } |