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