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