1 /* 2 * Copyright (C) 2024 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.content.ComponentName; 20 import android.content.Context; 21 import android.media.browse.MediaBrowser.MediaItem; 22 import android.os.Handler; 23 import android.os.Looper; 24 import android.util.Log; 25 26 import com.android.bluetooth.R; 27 28 import java.time.Duration; 29 import java.util.ArrayList; 30 import java.util.Arrays; 31 import java.util.Collections; 32 import java.util.HashMap; 33 import java.util.List; 34 import java.util.Map; 35 36 /** 37 * Handles API calls to a MediaBrowser. 38 * 39 * <p>{@link MediaBrowser} APIs work with callbacks only and need a connection beforehand. 40 * 41 * <p>This class handles the connection then will call the appropriate API and trigger the given 42 * callback when it gets the answer from MediaBrowser. 43 */ 44 class MediaBrowserWrapper { 45 private static final String TAG = MediaBrowserWrapper.class.getSimpleName(); 46 47 /** 48 * Some devices will continuously request each item in a folder one at a time. 49 * 50 * <p>This timeout is here to remove the connection between this class and the {@link 51 * MediaBrowser} after a certain time without requests from the remote device to browse the 52 * player. If the next request happens soon after, the bound will still exist. 53 * 54 * <p>Note: Previous implementation was keeping a local list of fetched items, this worked at 55 * the cost of not having the items actualized if fetching again the same folder. 56 */ 57 private static final Duration BROWSER_DISCONNECT_TIMEOUT = Duration.ofSeconds(5); 58 59 private enum ConnectionState { 60 DISCONNECTED, 61 CONNECTING, 62 CONNECTED, 63 } 64 65 public interface RequestCallback { run()66 void run(); 67 } 68 69 public interface GetPlayerRootCallback { run(String rootId)70 void run(String rootId); 71 } 72 73 public interface GetFolderItemsCallback { run(String parentId, List<ListItem> items)74 void run(String parentId, List<ListItem> items); 75 } 76 77 private final MediaBrowser mWrappedBrowser; 78 private final Context mContext; 79 private final Looper mLooper; 80 private final String mPackageName; 81 private final Handler mRunHandler; 82 private final String mClassName; 83 84 private ConnectionState mBrowserConnectionState = ConnectionState.DISCONNECTED; 85 86 private final ArrayList<RequestCallback> mRequestsList = new ArrayList<>(); 87 88 // GetFolderItems also works with a callback, so we need to store all requests made before we 89 // got the results and prevent new subscriptions. 90 private final Map<String, List<GetFolderItemsCallback>> mSubscribedIds = new HashMap<>(); 91 92 private final Runnable mDisconnectRunnable = () -> _disconnect(); 93 MediaBrowserWrapper( Context context, Looper looper, String packageName, String className)94 public MediaBrowserWrapper( 95 Context context, Looper looper, String packageName, String className) { 96 mContext = context; 97 mPackageName = packageName; 98 mClassName = className; 99 mLooper = looper; 100 mRunHandler = new Handler(mLooper); 101 mWrappedBrowser = 102 MediaBrowserFactory.make( 103 context, 104 new ComponentName(packageName, className), 105 new MediaConnectionCallback(), 106 null); 107 } 108 109 /** Returns the package name of the {@link MediaBrowser}. */ getPackageName()110 public final String getPackageName() { 111 Log.v(TAG, "getPackageName: " + mPackageName); 112 return mPackageName; 113 } 114 115 /** Retrieves the root path of the {@link MediaBrowser}. */ getRootId(GetPlayerRootCallback callback)116 public void getRootId(GetPlayerRootCallback callback) { 117 Log.v(TAG, "getRootId: " + mPackageName); 118 browseRequest( 119 () -> { 120 if (mBrowserConnectionState != ConnectionState.CONNECTED) { 121 Log.e(TAG, "getRootId: cb triggered but MediaBrowser is not connected."); 122 callback.run(""); 123 return; 124 } 125 String rootId = mWrappedBrowser.getRoot(); 126 Log.v(TAG, "getRootId for " + mPackageName + ": " + rootId); 127 callback.run(rootId); 128 setDisconnectDelay(); 129 }); 130 } 131 132 /** Plays the specified {@code mediaId}. */ playItem(String mediaId)133 public void playItem(String mediaId) { 134 Log.v(TAG, "playItem for " + mPackageName + ": " + mediaId); 135 browseRequest( 136 () -> { 137 if (mBrowserConnectionState != ConnectionState.CONNECTED) { 138 Log.e(TAG, "playItem: cb triggered but MediaBrowser is not connected."); 139 return; 140 } 141 setDisconnectDelay(); 142 // Retrieve the MediaController linked with this MediaBrowser. 143 // Note that the MediaBrowser should be connected for this. 144 MediaController controller = 145 MediaControllerFactory.make( 146 mContext, mWrappedBrowser.getSessionToken()); 147 // Retrieve TransportControls from this MediaController and play mediaId 148 MediaController.TransportControls ctrl = controller.getTransportControls(); 149 Log.v(TAG, "playItem for " + mPackageName + ": " + mediaId + " playing."); 150 ctrl.playFromMediaId(mediaId, null); 151 }); 152 } 153 154 /** 155 * Retrieves the content of a specific {@link MediaBrowser} path. 156 * 157 * @param mediaId the path to retrieve content of 158 * @param callback to be called when the content list is retrieved 159 */ getFolderItems(String mediaId, GetFolderItemsCallback callback)160 public void getFolderItems(String mediaId, GetFolderItemsCallback callback) { 161 Log.v(TAG, "getFolderItems for " + mPackageName + " and " + mediaId); 162 browseRequest( 163 () -> { 164 if (mBrowserConnectionState != ConnectionState.CONNECTED) { 165 Log.e( 166 TAG, 167 "getFolderItems: cb triggered but MediaBrowser is not connected."); 168 callback.run(mediaId, Collections.emptyList()); 169 return; 170 } 171 setDisconnectDelay(); 172 if (mSubscribedIds.containsKey(mediaId)) { 173 Log.v( 174 TAG, 175 "getFolderItems for " 176 + mPackageName 177 + " and " 178 + mediaId 179 + ": adding callback, already subscribed."); 180 ArrayList<GetFolderItemsCallback> newList = 181 (ArrayList) mSubscribedIds.get(mediaId); 182 newList.add(callback); 183 mSubscribedIds.put(mediaId, newList); 184 return; 185 } 186 Log.v( 187 TAG, 188 "getFolderItems for " 189 + mPackageName 190 + " and " 191 + mediaId 192 + ": adding callback and subscribing."); 193 // Empty mediaId can cause an exception, retrieve root instead. 194 if (mediaId.isEmpty()) { 195 getRootId( 196 (rootId) -> { 197 mSubscribedIds.put( 198 rootId, new ArrayList<>(Arrays.asList(callback))); 199 mWrappedBrowser.subscribe( 200 rootId, new BrowserSubscriptionCallback(rootId)); 201 }); 202 } else { 203 mSubscribedIds.put(mediaId, new ArrayList<>(Arrays.asList(callback))); 204 mWrappedBrowser.subscribe( 205 mediaId, new BrowserSubscriptionCallback(mediaId)); 206 } 207 }); 208 } 209 210 /** 211 * Requests information from {@link MediaBrowser}. 212 * 213 * <p>If the {@link MediaBrowser} this instance wraps around is already connected, calls the 214 * callback directly. 215 * 216 * <p>If it is connecting, adds the callback to the {@code mRequestsList}, to be called once the 217 * connection is done. 218 * 219 * <p>If the connection isn't started, starts it and adds the callback to the {@code 220 * mRequestsList} 221 */ browseRequest(RequestCallback callback)222 private void browseRequest(RequestCallback callback) { 223 mRunHandler.post( 224 () -> { 225 switch (mBrowserConnectionState) { 226 case CONNECTED: 227 callback.run(); 228 break; 229 case DISCONNECTED: 230 connect(); 231 mRequestsList.add(callback); 232 break; 233 case CONNECTING: 234 mRequestsList.add(callback); 235 break; 236 } 237 }); 238 } 239 240 /** Connects to the {@link MediaBrowser} this instance wraps around. */ connect()241 private void connect() { 242 if (mBrowserConnectionState != ConnectionState.DISCONNECTED) { 243 Log.e( 244 TAG, 245 "Trying to bind to a player that is not disconnected: " 246 + mBrowserConnectionState); 247 return; 248 } 249 mBrowserConnectionState = ConnectionState.CONNECTING; 250 Log.v(TAG, "connect: " + mPackageName + " connecting"); 251 mWrappedBrowser.connect(); 252 } 253 254 /** Disconnects from the {@link MediaBrowser} */ disconnect()255 public void disconnect() { 256 mRunHandler.post(() -> _disconnect()); 257 } 258 _disconnect()259 private void _disconnect() { 260 mRunHandler.removeCallbacks(mDisconnectRunnable); 261 if (mBrowserConnectionState == ConnectionState.DISCONNECTED) { 262 Log.e( 263 TAG, 264 "disconnect: Trying to disconnect a player that is not connected: " 265 + mBrowserConnectionState); 266 return; 267 } 268 mBrowserConnectionState = ConnectionState.DISCONNECTED; 269 Log.v(TAG, "disconnect: " + mPackageName + " disconnected"); 270 mWrappedBrowser.disconnect(); 271 } 272 273 /** Sets the delay before the disconnection from the {@link MediaBrowser} happens. */ setDisconnectDelay()274 private void setDisconnectDelay() { 275 mRunHandler.removeCallbacks(mDisconnectRunnable); 276 mRunHandler.postDelayed(mDisconnectRunnable, BROWSER_DISCONNECT_TIMEOUT.toMillis()); 277 } 278 279 /** Callback for {@link MediaBrowser} connection. */ 280 private class MediaConnectionCallback extends MediaBrowser.ConnectionCallback { 281 @Override onConnected()282 public void onConnected() { 283 mRunHandler.post( 284 () -> { 285 mBrowserConnectionState = ConnectionState.CONNECTED; 286 Log.v(TAG, "MediaConnectionCallback: " + mPackageName + " onConnected"); 287 runCallbacks(); 288 }); 289 } 290 291 @Override onConnectionFailed()292 public void onConnectionFailed() { 293 mRunHandler.post( 294 () -> { 295 Log.e( 296 TAG, 297 "MediaConnectionCallback: " + mPackageName + " onConnectionFailed"); 298 mBrowserConnectionState = ConnectionState.DISCONNECTED; 299 runCallbacks(); 300 }); 301 } 302 303 @Override onConnectionSuspended()304 public void onConnectionSuspended() { 305 mRunHandler.post( 306 () -> { 307 Log.e( 308 TAG, 309 "MediaConnectionCallback: " 310 + mPackageName 311 + " onConnectionSuspended"); 312 runCallbacks(); 313 mWrappedBrowser.disconnect(); 314 }); 315 } 316 317 /** 318 * Executes all the callbacks stored during the connection process 319 * 320 * <p>This has to run on constructor's Looper. 321 */ runCallbacks()322 private void runCallbacks() { 323 for (RequestCallback callback : mRequestsList) { 324 callback.run(); 325 } 326 mRequestsList.clear(); 327 } 328 } 329 330 private class BrowserSubscriptionCallback extends MediaBrowser.SubscriptionCallback { 331 332 private final Runnable mTimeoutRunnable; 333 private boolean mCallbacksExecuted = false; 334 BrowserSubscriptionCallback(String mediaId)335 BrowserSubscriptionCallback(String mediaId) { 336 mTimeoutRunnable = 337 () -> { 338 executeCallbacks(mediaId, new ArrayList<>()); 339 }; 340 mRunHandler.postDelayed(mTimeoutRunnable, BROWSER_DISCONNECT_TIMEOUT.toMillis()); 341 } 342 executeCallbacks(String parentId, List<ListItem> browsableContent)343 private void executeCallbacks(String parentId, List<ListItem> browsableContent) { 344 if (mCallbacksExecuted) { 345 return; 346 } 347 mCallbacksExecuted = true; 348 mRunHandler.removeCallbacks(mTimeoutRunnable); 349 for (GetFolderItemsCallback callback : mSubscribedIds.get(parentId)) { 350 Log.v( 351 TAG, 352 "getFolderItems for " 353 + mPackageName 354 + " and " 355 + parentId 356 + ": callback called with " 357 + browsableContent.size() 358 + " items."); 359 callback.run(parentId, browsableContent); 360 } 361 362 mSubscribedIds.remove(parentId); 363 mWrappedBrowser.unsubscribe(parentId); 364 } 365 366 @Override onChildrenLoaded(String parentId, List<MediaItem> children)367 public void onChildrenLoaded(String parentId, List<MediaItem> children) { 368 List<ListItem> browsableContent = new ArrayList<>(); 369 370 for (MediaItem item : children) { 371 if (item.isBrowsable()) { 372 String title = item.getDescription().getTitle().toString(); 373 if (title.isEmpty()) { 374 title = mContext.getString(R.string.not_provided); 375 } 376 Folder f = new Folder(item.getMediaId(), false, title); 377 browsableContent.add(new ListItem(f)); 378 } else { 379 Metadata data = Util.toMetadata(mContext, item); 380 if (Util.isEmptyData(data)) { 381 continue; 382 } 383 browsableContent.add(new ListItem(data)); 384 } 385 } 386 387 mRunHandler.post(() -> executeCallbacks(parentId, browsableContent)); 388 } 389 390 @Override onError(String parentId)391 public void onError(String parentId) { 392 mRunHandler.post(() -> executeCallbacks(parentId, new ArrayList<>())); 393 } 394 395 @Override getTimeoutHandler()396 public Handler getTimeoutHandler() { 397 return mRunHandler; 398 } 399 } 400 401 @Override toString()402 public String toString() { 403 return "Browsable Package & Class Name: " + mPackageName + " " + mClassName + "\n"; 404 } 405 } 406