1 /* 2 * Copyright 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.bluetooth.avrcp; 18 19 import android.annotation.Nullable; 20 import android.content.ComponentName; 21 import android.content.Context; 22 import android.media.browse.MediaBrowser.MediaItem; 23 import android.media.session.PlaybackState; 24 import android.os.Handler; 25 import android.os.Looper; 26 import android.os.Message; 27 import android.util.Log; 28 29 import java.util.ArrayList; 30 import java.util.LinkedHashMap; 31 import java.util.List; 32 import java.util.Map; 33 34 /* 35 * Helper class to create an abstraction layer for the MediaBrowser service that AVRCP can use. 36 * 37 * TODO (apanicke): Add timeouts in case a browser takes forever to connect or gets stuck. 38 * Right now this is ok because the BrowsablePlayerConnector will handle timeouts. 39 */ 40 class BrowsedPlayerWrapper { 41 private static final String TAG = "AvrcpBrowsedPlayerWrapper"; 42 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 43 44 enum ConnectionState { 45 DISCONNECTED, 46 CONNECTING, 47 CONNECTED, 48 } 49 50 interface ConnectionCallback { run(int status, BrowsedPlayerWrapper wrapper)51 void run(int status, BrowsedPlayerWrapper wrapper); 52 } 53 54 interface PlaybackCallback { run(int status)55 void run(int status); 56 } 57 58 interface BrowseCallback { run(int status, String mediaId, List<ListItem> results)59 void run(int status, String mediaId, List<ListItem> results); 60 } 61 62 public static final int STATUS_SUCCESS = 0; 63 public static final int STATUS_CONN_ERROR = 1; 64 public static final int STATUS_LOOKUP_ERROR = 2; 65 public static final int STATUS_PLAYBACK_TIMEOUT_ERROR = 3; 66 67 private MediaBrowser mWrappedBrowser; 68 69 // TODO (apanicke): Store the context in the factories so that we don't need to save this. 70 // As long as the service is alive those factories will have a valid context. 71 private final Context mContext; 72 private final Looper mLooper; 73 private final String mPackageName; 74 private final Object mCallbackLock = new Object(); 75 private ConnectionCallback mCallback; 76 77 // TODO(apanicke): We cache this because normally you can only grab the root 78 // while connected. We shouldn't cache this since theres nothing in the framework documentation 79 // that says this can't change between connections. Instead always treat empty string as root. 80 private String mRoot = ""; 81 82 // A linked hash map that keeps the contents of the last X browsed folders. 83 // 84 // NOTE: This is needed since some carkits will repeatedly request each item in a folder 85 // individually, incrementing the index of the requested item by one at a time. Going through 86 // the subscription process for each individual item is incredibly slow so we cache the items 87 // in the folder in order to speed up the process. We still run the risk of one device pushing 88 // out a cached folder that another device was using, but this is highly unlikely since for 89 // this to happen you would need to be connected to two carkits at the same time. 90 // 91 // TODO (apanicke): Dynamically set the number of cached folders equal to the max number 92 // of connected devices because that is the maximum number of folders that can be browsed at 93 // a single time. 94 static final int NUM_CACHED_FOLDERS = 5; 95 LinkedHashMap<String, List<ListItem>> mCachedFolders = 96 new LinkedHashMap<String, List<ListItem>>(NUM_CACHED_FOLDERS) { 97 @Override 98 protected boolean removeEldestEntry(Map.Entry<String, List<ListItem>> eldest) { 99 return size() > NUM_CACHED_FOLDERS; 100 } 101 }; 102 103 // TODO (apanicke): Investigate if there is a way to create this just by passing in the 104 // MediaBrowser. Right now there is no obvious way to create the browser then update the 105 // connection callback without being forced to re-create the object every time. BrowsedPlayerWrapper(Context context, Looper looper, String packageName, String className)106 private BrowsedPlayerWrapper(Context context, Looper looper, String packageName, 107 String className) { 108 mContext = context; 109 mPackageName = packageName; 110 mLooper = looper; 111 mWrappedBrowser = MediaBrowserFactory.make( 112 context, 113 new ComponentName(packageName, className), 114 new MediaConnectionCallback(), 115 null); 116 } 117 wrap(Context context, Looper looper, String packageName, String className)118 static BrowsedPlayerWrapper wrap(Context context, Looper looper, String packageName, 119 String className) { 120 Log.i(TAG, "Wrapping Media Browser " + packageName); 121 BrowsedPlayerWrapper wrapper = 122 new BrowsedPlayerWrapper(context, looper, packageName, className); 123 return wrapper; 124 } 125 126 /** 127 * Connect to the media application's MediaBrowserService 128 * 129 * Connections are asynchronous in nature. The given callback will be invoked once the 130 * connection is established. The connection will be torn down once your callback is executed 131 * when using this function. If you wish to control the lifecycle of the connection on your own 132 * then use {@link #setCallbackAndConnect(ConnectionCallback)} instead. 133 * 134 * @param cb A callback to execute once the connection is established 135 * @return True if we successfully make a connection attempt, False otherwise 136 */ connect(ConnectionCallback cb)137 boolean connect(ConnectionCallback cb) { 138 if (cb == null) { 139 Log.wtf(TAG, "connect: Trying to connect to " + mPackageName 140 + "with null callback"); 141 } 142 return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> { 143 cb.run(status, wrapper); 144 disconnect(); 145 }); 146 } 147 148 /** 149 * Disconnect from the media application's MediaBrowserService 150 * 151 * This clears any pending requests. This function is safe to call even if a connection isn't 152 * currently open. 153 */ disconnect()154 void disconnect() { 155 if (DEBUG) Log.d(TAG, "disconnect: Disconnecting from " + mPackageName); 156 mWrappedBrowser.disconnect(); 157 clearCallback(); 158 } 159 setCallbackAndConnect(ConnectionCallback callback)160 boolean setCallbackAndConnect(ConnectionCallback callback) { 161 synchronized (mCallbackLock) { 162 if (mCallback != null) { 163 Log.w(TAG, "setCallbackAndConnect: Already trying to connect to "); 164 return false; 165 } 166 mCallback = callback; 167 } 168 if (DEBUG) Log.d(TAG, "Set mCallback, connecting to " + mPackageName); 169 mWrappedBrowser.connect(); 170 return true; 171 } 172 executeCallback(int status, BrowsedPlayerWrapper player)173 void executeCallback(int status, BrowsedPlayerWrapper player) { 174 final ConnectionCallback callback; 175 synchronized (mCallbackLock) { 176 if (mCallback == null) { 177 Log.w(TAG, "Callback is NULL. Cannot execute"); 178 return; 179 } 180 callback = mCallback; 181 } 182 if (DEBUG) Log.d(TAG, "Executing callback"); 183 callback.run(status, player); 184 } 185 clearCallback()186 void clearCallback() { 187 synchronized (mCallbackLock) { 188 mCallback = null; 189 } 190 if (DEBUG) Log.d(TAG, "mCallback = null"); 191 } 192 getPackageName()193 public String getPackageName() { 194 return mPackageName; 195 } 196 getRootId()197 public String getRootId() { 198 return mRoot; 199 } 200 201 /** 202 * Requests to play a media item with a given media ID 203 * 204 * @param mediaId A string indicating the piece of media you would like to play 205 * @return False if any other requests are being serviced, True otherwise 206 */ playItem(String mediaId)207 public boolean playItem(String mediaId) { 208 if (DEBUG) Log.d(TAG, "playItem: Play item from media ID: " + mediaId); 209 return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> { 210 if (DEBUG) Log.d(TAG, "playItem: Connected to browsable player " + mPackageName); 211 MediaController controller = MediaControllerFactory.make(mContext, 212 wrapper.mWrappedBrowser.getSessionToken()); 213 MediaController.TransportControls ctrl = controller.getTransportControls(); 214 Log.i(TAG, "playItem: Playing " + mediaId); 215 ctrl.playFromMediaId(mediaId, null); 216 217 MediaPlaybackListener mpl = new MediaPlaybackListener(mLooper, controller); 218 mpl.waitForPlayback((int playbackStatus) -> { 219 Log.i(TAG, "playItem: Media item playback returned, status: " + playbackStatus); 220 disconnect(); 221 }); 222 }); 223 } 224 225 /** 226 * Request the contents of a folder item identified by the given media ID 227 * 228 * Contents must be loaded from a service and are returned asynchronously. 229 * 230 * @param mediaId A string indicating the piece of media you would like to play 231 * @param cb A Callback that returns the loaded contents of the requested media ID 232 * @return False if any other requests are being serviced, True otherwise 233 */ 234 // TODO (apanicke): Determine what happens when we subscribe to the same item while a 235 // callback is in flight. 236 // 237 // TODO (apanicke): Currently we do a full folder lookup even if the remote device requests 238 // info for only one item. Add a lookup function that can handle getting info for a single 239 // item. 240 public boolean getFolderItems(String mediaId, BrowseCallback cb) { 241 if (mCachedFolders.containsKey(mediaId)) { 242 Log.i(TAG, "getFolderItems: Grabbing cached data for mediaId: " + mediaId); 243 cb.run(STATUS_SUCCESS, mediaId, Util.cloneList(mCachedFolders.get(mediaId))); 244 return true; 245 } 246 247 if (cb == null) { 248 Log.wtf(TAG, "getFolderItems: Trying to connect to " + mPackageName 249 + "with null browse callback"); 250 } 251 252 if (DEBUG) Log.d(TAG, "getFolderItems: Connecting to browsable player: " + mPackageName); 253 return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> { 254 Log.i(TAG, "getFolderItems: Connected to browsable player: " + mPackageName); 255 if (status != STATUS_SUCCESS) { 256 cb.run(status, "", new ArrayList<ListItem>()); 257 } 258 getFolderItemsInternal(mediaId, cb); 259 }); 260 } 261 262 // Internal function to call once the Browser is connected 263 private boolean getFolderItemsInternal(String mediaId, BrowseCallback cb) { 264 mWrappedBrowser.subscribe(mediaId, new BrowserSubscriptionCallback(cb)); 265 return true; 266 } 267 268 class MediaConnectionCallback extends MediaBrowser.ConnectionCallback { 269 @Override 270 public void onConnected() { 271 Log.i(TAG, "onConnected: " + mPackageName + " is connected"); 272 // Get the root while connected because we may need to use it when disconnected. 273 mRoot = mWrappedBrowser.getRoot(); 274 275 if (mRoot == null || mRoot.isEmpty()) { 276 executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this); 277 return; 278 } 279 280 executeCallback(STATUS_SUCCESS, BrowsedPlayerWrapper.this); 281 } 282 283 284 @Override 285 public void onConnectionFailed() { 286 Log.w(TAG, "onConnectionFailed: Connection Failed with " + mPackageName); 287 executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this); 288 // No need to call disconnect as we never connected. Just need to remove our callback. 289 clearCallback(); 290 } 291 292 // TODO (apanicke): Add a check to list a player as unbrowsable if it suspends immediately 293 // after connection. 294 @Override 295 public void onConnectionSuspended() { 296 executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this); 297 disconnect(); 298 Log.i(TAG, "onConnectionSuspended: Connection Suspended with " + mPackageName); 299 } 300 } 301 302 class TimeoutHandler extends Handler { 303 static final int MSG_TIMEOUT = 0; 304 static final long CALLBACK_TIMEOUT_MS = 5000; 305 306 private PlaybackCallback mPlaybackCallback = null; 307 308 TimeoutHandler(Looper looper, PlaybackCallback cb) { 309 super(looper); 310 mPlaybackCallback = cb; 311 } 312 313 @Override 314 public void handleMessage(Message msg) { 315 if (msg.what != MSG_TIMEOUT) { 316 Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what); 317 return; 318 } 319 320 Log.e(TAG, "Timeout while waiting for playback to begin on " + mPackageName); 321 mPlaybackCallback.run(STATUS_PLAYBACK_TIMEOUT_ERROR); 322 } 323 } 324 325 class MediaPlaybackListener extends MediaController.Callback { 326 private final Object mTimeoutHandlerLock = new Object(); 327 private Handler mTimeoutHandler = null; 328 private Looper mLooper = null; 329 private MediaController mController = null; 330 private PlaybackCallback mPlaybackCallback = null; 331 332 MediaPlaybackListener(Looper looper, MediaController controller) { 333 synchronized (mTimeoutHandlerLock) { 334 mController = controller; 335 mLooper = looper; 336 } 337 } 338 339 void waitForPlayback(PlaybackCallback cb) { 340 synchronized (mTimeoutHandlerLock) { 341 mPlaybackCallback = cb; 342 343 // If we don't already have the proper state then register the callbacks to execute 344 // on the same thread as the timeout thread. This prevents a race condition where a 345 // timeout happens at the same time as an update. Then set the timeout 346 PlaybackState state = mController.getPlaybackState(); 347 if (state == null || state.getState() != PlaybackState.STATE_PLAYING) { 348 Log.d(TAG, "MediaPlayback: Waiting for media to play for " + mPackageName); 349 mTimeoutHandler = new TimeoutHandler(mLooper, mPlaybackCallback); 350 mController.registerCallback(this, mTimeoutHandler); 351 mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT, 352 TimeoutHandler.CALLBACK_TIMEOUT_MS); 353 } else { 354 Log.d(TAG, "MediaPlayback: Media is already playing for " + mPackageName); 355 mPlaybackCallback.run(STATUS_SUCCESS); 356 cleanup(); 357 } 358 } 359 } 360 361 void cleanup() { 362 synchronized (mTimeoutHandlerLock) { 363 if (mController != null) { 364 mController.unregisterCallback(this); 365 } 366 mController = null; 367 368 if (mTimeoutHandler != null) { 369 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 370 } 371 mTimeoutHandler = null; 372 mPlaybackCallback = null; 373 } 374 } 375 376 @Override 377 public void onPlaybackStateChanged(@Nullable PlaybackState state) { 378 if (DEBUG) Log.d(TAG, "MediaPlayback: " + mPackageName + " -> " + state.toString()); 379 if (state.getState() == PlaybackState.STATE_PLAYING) { 380 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 381 mPlaybackCallback.run(STATUS_SUCCESS); 382 cleanup(); 383 } 384 } 385 } 386 387 /** 388 * Subscription callback handler. Subscribe to a folder to get its contents. We generate a new 389 * instance for this class for each subscribe call to make it easier to differentiate between 390 * the callers. 391 */ 392 private class BrowserSubscriptionCallback extends MediaBrowser.SubscriptionCallback { 393 BrowseCallback mBrowseCallback = null; 394 395 BrowserSubscriptionCallback(BrowseCallback cb) { 396 mBrowseCallback = cb; 397 } 398 399 @Override 400 public void onChildrenLoaded(String parentId, List<MediaItem> children) { 401 if (DEBUG) { 402 Log.d(TAG, "onChildrenLoaded: mediaId=" + parentId + " size= " + children.size()); 403 } 404 405 if (mBrowseCallback == null) { 406 Log.w(TAG, "onChildrenLoaded: " + mPackageName 407 + " children loaded while callback is null"); 408 } 409 410 // TODO (apanicke): Instead of always unsubscribing, only unsubscribe from folders 411 // that aren't cached. This will let us update what is cached on the fly and prevent 412 // us from serving stale data. 413 mWrappedBrowser.unsubscribe(parentId); 414 415 ArrayList<ListItem> return_list = new ArrayList<ListItem>(); 416 417 for (MediaItem item : children) { 418 if (DEBUG) { 419 Log.d(TAG, "onChildrenLoaded: Child=\"" + item.toString() 420 + "\", ID=\"" + item.getMediaId() + "\""); 421 } 422 423 if (item.isBrowsable()) { 424 CharSequence titleCharSequence = item.getDescription().getTitle(); 425 String title = "Not Provided"; 426 if (titleCharSequence != null) { 427 title = titleCharSequence.toString(); 428 } 429 Folder f = new Folder(item.getMediaId(), false, title); 430 return_list.add(new ListItem(f)); 431 } else { 432 return_list.add(new ListItem(Util.toMetadata(item))); 433 } 434 } 435 436 mCachedFolders.put(parentId, return_list); 437 438 // Clone the list so that the callee can mutate it without affecting the cached data 439 mBrowseCallback.run(STATUS_SUCCESS, parentId, Util.cloneList(return_list)); 440 mBrowseCallback = null; 441 disconnect(); 442 } 443 444 /* mediaId is invalid */ 445 @Override 446 public void onError(String id) { 447 Log.e(TAG, "BrowserSubscriptionCallback: Could not get folder items"); 448 mBrowseCallback.run(STATUS_LOOKUP_ERROR, id, new ArrayList<ListItem>()); 449 disconnect(); 450 } 451 } 452 453 @Override 454 public String toString() { 455 StringBuilder sb = new StringBuilder(); 456 sb.append("Browsable Package Name: " + mPackageName + "\n"); 457 sb.append(" Cached Media ID's: "); 458 for (String id : mCachedFolders.keySet()) { 459 sb.append("\"" + id + "\", "); 460 } 461 sb.append("\n"); 462 return sb.toString(); 463 } 464 } 465