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.audio_util; 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, mLooper, mediaId)); 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 static final long SUBSCRIPTION_TIMEOUT_MS = 3000; 306 307 private PlaybackCallback mPlaybackCallback = null; 308 private BrowseCallback mBrowseCallback = null; 309 private String mId = ""; 310 311 TimeoutHandler(Looper looper, PlaybackCallback cb) { 312 super(looper); 313 mPlaybackCallback = cb; 314 } 315 316 TimeoutHandler(Looper looper, BrowseCallback cb, String mediaId) { 317 super(looper); 318 mBrowseCallback = cb; 319 mId = mediaId; 320 } 321 322 @Override 323 public void handleMessage(Message msg) { 324 if (msg.what != MSG_TIMEOUT) { 325 Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what); 326 return; 327 } 328 329 if (mPlaybackCallback != null) { 330 Log.e(TAG, "Timeout while waiting for playback to begin on " + mPackageName); 331 mPlaybackCallback.run(STATUS_PLAYBACK_TIMEOUT_ERROR); 332 } else { 333 Log.e(TAG, "Timeout while waiting subscription result for " + mPackageName); 334 mBrowseCallback.run(STATUS_LOOKUP_ERROR, mId, new ArrayList<ListItem>()); 335 disconnect(); 336 } 337 } 338 } 339 340 class MediaPlaybackListener extends MediaController.Callback { 341 private final Object mTimeoutHandlerLock = new Object(); 342 private Handler mTimeoutHandler = null; 343 private Looper mLooper = null; 344 private MediaController mController = null; 345 private PlaybackCallback mPlaybackCallback = null; 346 347 MediaPlaybackListener(Looper looper, MediaController controller) { 348 synchronized (mTimeoutHandlerLock) { 349 mController = controller; 350 mLooper = looper; 351 } 352 } 353 354 void waitForPlayback(PlaybackCallback cb) { 355 synchronized (mTimeoutHandlerLock) { 356 mPlaybackCallback = cb; 357 358 // If we don't already have the proper state then register the callbacks to execute 359 // on the same thread as the timeout thread. This prevents a race condition where a 360 // timeout happens at the same time as an update. Then set the timeout 361 PlaybackState state = mController.getPlaybackState(); 362 if (state == null || state.getState() != PlaybackState.STATE_PLAYING) { 363 Log.d(TAG, "MediaPlayback: Waiting for media to play for " + mPackageName); 364 mTimeoutHandler = new TimeoutHandler(mLooper, mPlaybackCallback); 365 mController.registerCallback(this, mTimeoutHandler); 366 mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT, 367 TimeoutHandler.CALLBACK_TIMEOUT_MS); 368 } else { 369 Log.d(TAG, "MediaPlayback: Media is already playing for " + mPackageName); 370 mPlaybackCallback.run(STATUS_SUCCESS); 371 cleanup(); 372 } 373 } 374 } 375 376 void cleanup() { 377 synchronized (mTimeoutHandlerLock) { 378 if (mController != null) { 379 mController.unregisterCallback(this); 380 } 381 mController = null; 382 383 if (mTimeoutHandler != null) { 384 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 385 } 386 mTimeoutHandler = null; 387 mPlaybackCallback = null; 388 } 389 } 390 391 @Override 392 public void onPlaybackStateChanged(@Nullable PlaybackState state) { 393 if (DEBUG) Log.d(TAG, "MediaPlayback: " + mPackageName + " -> " + state.toString()); 394 if (state.getState() == PlaybackState.STATE_PLAYING) { 395 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 396 mPlaybackCallback.run(STATUS_SUCCESS); 397 cleanup(); 398 } 399 } 400 } 401 402 /** 403 * Subscription callback handler. Subscribe to a folder to get its contents. We generate a new 404 * instance for this class for each subscribe call to make it easier to differentiate between 405 * the callers. 406 */ 407 private class BrowserSubscriptionCallback extends MediaBrowser.SubscriptionCallback { 408 BrowseCallback mBrowseCallback = null; 409 private Looper mLooper = null; 410 private TimeoutHandler mTimeoutHandler = null; 411 412 BrowserSubscriptionCallback(BrowseCallback cb, Looper looper, String mediaId) { 413 mBrowseCallback = cb; 414 mLooper = looper; 415 mTimeoutHandler = new TimeoutHandler(mLooper, cb, mediaId); 416 mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT, 417 TimeoutHandler.SUBSCRIPTION_TIMEOUT_MS); 418 } 419 420 @Override 421 public Handler getTimeoutHandler() { 422 return mTimeoutHandler; 423 } 424 425 @Override 426 public void onChildrenLoaded(String parentId, List<MediaItem> children) { 427 if (DEBUG) { 428 Log.d(TAG, "onChildrenLoaded: mediaId=" + parentId + " size= " + children.size()); 429 } 430 431 if (mBrowseCallback == null) { 432 Log.w(TAG, "onChildrenLoaded: " + mPackageName 433 + " children loaded while callback is null"); 434 } 435 436 // TODO (apanicke): Instead of always unsubscribing, only unsubscribe from folders 437 // that aren't cached. This will let us update what is cached on the fly and prevent 438 // us from serving stale data. 439 mWrappedBrowser.unsubscribe(parentId); 440 441 ArrayList<ListItem> return_list = new ArrayList<ListItem>(); 442 443 for (MediaItem item : children) { 444 if (DEBUG) { 445 Log.d(TAG, "onChildrenLoaded: Child=\"" + item.toString() 446 + "\", ID=\"" + item.getMediaId() + "\""); 447 } 448 449 if (item.isBrowsable()) { 450 CharSequence titleCharSequence = item.getDescription().getTitle(); 451 String title = "Not Provided"; 452 if (titleCharSequence != null) { 453 title = titleCharSequence.toString(); 454 } 455 Folder f = new Folder(item.getMediaId(), false, title); 456 return_list.add(new ListItem(f)); 457 } else { 458 Metadata data = Util.toMetadata(mContext, item); 459 if (Util.isEmptyData(data)) { 460 Log.e(TAG, "Received empty Metadata, ignoring browsed item"); 461 continue; 462 } 463 return_list.add(new ListItem(data)); 464 } 465 } 466 467 mCachedFolders.put(parentId, return_list); 468 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 469 470 // Clone the list so that the callee can mutate it without affecting the cached data 471 mBrowseCallback.run(STATUS_SUCCESS, parentId, Util.cloneList(return_list)); 472 mBrowseCallback = null; 473 disconnect(); 474 } 475 476 /* mediaId is invalid */ 477 @Override 478 public void onError(String id) { 479 Log.e(TAG, "BrowserSubscriptionCallback: Could not get folder items"); 480 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT); 481 mBrowseCallback.run(STATUS_LOOKUP_ERROR, id, new ArrayList<ListItem>()); 482 disconnect(); 483 } 484 } 485 486 @Override 487 public String toString() { 488 StringBuilder sb = new StringBuilder(); 489 sb.append("Browsable Package Name: " + mPackageName + "\n"); 490 sb.append(" Cached Media ID's: "); 491 for (String id : mCachedFolders.keySet()) { 492 sb.append("\"" + id + "\", "); 493 } 494 sb.append("\n"); 495 return sb.toString(); 496 } 497 } 498