• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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