• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.content.ComponentName;
20 import android.content.Context;
21 import android.media.browse.MediaBrowser.MediaItem;
22 import android.util.Log;
23 
24 import java.util.ArrayList;
25 import java.util.LinkedHashMap;
26 import java.util.List;
27 import java.util.Map;
28 
29 /*
30  * Helper class to create an abstraction layer for the MediaBrowser service that AVRCP can use.
31  *
32  * TODO (apanicke): Add timeouts in case a browser takes forever to connect or gets stuck.
33  * Right now this is ok because the BrowsablePlayerConnector will handle timeouts.
34  */
35 class BrowsedPlayerWrapper {
36     private static final String TAG = "NewAvrcpBrowsedPlayerWrapper";
37     private static final boolean DEBUG = true;
38 
39     enum ConnectionState {
40         DISCONNECTED,
41         CONNECTING,
42         CONNECTED,
43     }
44 
45     interface ConnectionCallback {
run(int status, BrowsedPlayerWrapper wrapper)46         void run(int status, BrowsedPlayerWrapper wrapper);
47     }
48 
49     interface BrowseCallback {
run(int status, String mediaId, List<ListItem> results)50         void run(int status, String mediaId, List<ListItem> results);
51     }
52 
53     public static final int STATUS_SUCCESS = 0;
54     public static final int STATUS_CONN_ERROR = 1;
55     public static final int STATUS_LOOKUP_ERROR = 2;
56 
57     private MediaBrowser mWrappedBrowser;
58 
59     // TODO (apanicke): Store the context in the factories so that we don't need to save this.
60     // As long as the service is alive those factories will have a valid context.
61     private Context mContext;
62     private String mPackageName;
63     private ConnectionCallback mCallback;
64 
65     // TODO(apanicke): We cache this because normally you can only grab the root
66     // while connected. We shouldn't cache this since theres nothing in the framework documentation
67     // that says this can't change between connections. Instead always treat empty string as root.
68     private String mRoot = "";
69 
70     // A linked hash map that keeps the contents of the last X browsed folders.
71     //
72     // NOTE: This is needed since some carkits will repeatedly request each item in a folder
73     // individually, incrementing the index of the requested item by one at a time. Going through
74     // the subscription process for each individual item is incredibly slow so we cache the items
75     // in the folder in order to speed up the process. We still run the risk of one device pushing
76     // out a cached folder that another device was using, but this is highly unlikely since for
77     // this to happen you would need to be connected to two carkits at the same time.
78     //
79     // TODO (apanicke): Dynamically set the number of cached folders equal to the max number
80     // of connected devices because that is the maximum number of folders that can be browsed at
81     // a single time.
82     static final int NUM_CACHED_FOLDERS = 5;
83     LinkedHashMap<String, List<ListItem>> mCachedFolders =
84             new LinkedHashMap<String, List<ListItem>>(NUM_CACHED_FOLDERS) {
85                 @Override
86                 protected boolean removeEldestEntry(Map.Entry<String, List<ListItem>> eldest) {
87                     return size() > NUM_CACHED_FOLDERS;
88                 }
89             };
90 
91     // TODO (apanicke): Investigate if there is a way to create this just by passing in the
92     // MediaBrowser. Right now there is no obvious way to create the browser then update the
93     // connection callback without being forced to re-create the object every time.
BrowsedPlayerWrapper(Context context, String packageName, String className)94     private BrowsedPlayerWrapper(Context context, String packageName, String className) {
95         mContext = context;
96         mPackageName = packageName;
97 
98         mWrappedBrowser = MediaBrowserFactory.make(
99                 context,
100                 new ComponentName(packageName, className),
101                 new MediaConnectionCallback(),
102                 null);
103     }
104 
wrap(Context context, String packageName, String className)105     static BrowsedPlayerWrapper wrap(Context context, String packageName, String className) {
106         Log.i(TAG, "Wrapping Media Browser " + packageName);
107         BrowsedPlayerWrapper wrapper =
108                 new BrowsedPlayerWrapper(context, packageName, className);
109         return wrapper;
110     }
111 
connect(ConnectionCallback cb)112     void connect(ConnectionCallback cb) {
113         if (cb == null) {
114             Log.wtfStack(TAG, "connect: Trying to connect to " + mPackageName
115                     + "with null callback");
116         }
117         if (mCallback != null) {
118             Log.w(TAG, "connect: Already trying to connect to " + mPackageName);
119             return;
120         }
121 
122         if (DEBUG) Log.d(TAG, "connect: Connecting to browsable player: " + mPackageName);
123         mCallback = (int status, BrowsedPlayerWrapper wrapper) -> {
124             cb.run(status, wrapper);
125             wrapper.disconnect();
126         };
127         mWrappedBrowser.connect();
128     }
129 
disconnect()130     void disconnect() {
131         if (DEBUG) Log.d(TAG, "disconnect: Disconnecting from " + mPackageName);
132         mWrappedBrowser.disconnect();
133         mCallback = null;
134     }
135 
getPackageName()136     public String getPackageName() {
137         return mPackageName;
138     }
139 
getRootId()140     public String getRootId() {
141         return mRoot;
142     }
143 
playItem(String mediaId)144     public void playItem(String mediaId) {
145         if (DEBUG) Log.d(TAG, "playItem: Play Item from media ID: " + mediaId);
146         connect((int status, BrowsedPlayerWrapper wrapper) -> {
147             if (DEBUG) Log.d(TAG, "playItem: Connected to browsable player " + mPackageName);
148 
149             MediaController controller = MediaControllerFactory.make(mContext,
150                     wrapper.mWrappedBrowser.getSessionToken());
151             MediaController.TransportControls ctrl = controller.getTransportControls();
152             Log.i(TAG, "playItem: Playing " + mediaId);
153             ctrl.playFromMediaId(mediaId, null);
154         });
155         return;
156     }
157 
158     // Returns false if the player is in the connecting state. Wait for it to either be
159     // connected or disconnected.
160     //
161     // TODO (apanicke): Determine what happens when we subscribe to the same item while a
162     // callback is in flight.
163     //
164     // TODO (apanicke): Currently we do a full folder lookup even if the remote device requests
165     // info for only one item. Add a lookup function that can handle getting info for a single
166     // item.
getFolderItems(String mediaId, BrowseCallback cb)167     public boolean getFolderItems(String mediaId, BrowseCallback cb) {
168         if (mCachedFolders.containsKey(mediaId)) {
169             Log.i(TAG, "getFolderItems: Grabbing cached data for mediaId: " + mediaId);
170             cb.run(STATUS_SUCCESS, mediaId, Util.cloneList(mCachedFolders.get(mediaId)));
171             return true;
172         }
173 
174         if (cb == null) {
175             Log.wtfStack(TAG, "connect: Trying to connect to " + mPackageName
176                     + "with null callback");
177         }
178         if (mCallback != null) {
179             Log.w(TAG, "connect: Already trying to connect to " + mPackageName);
180             return false;
181         }
182 
183         if (DEBUG) Log.d(TAG, "connect: Connecting to browsable player: " + mPackageName);
184         mCallback = (int status, BrowsedPlayerWrapper wrapper) -> {
185             Log.i(TAG, "getFolderItems: Connected to browsable player: " + mPackageName);
186             if (status != STATUS_SUCCESS) {
187                 cb.run(status, "", new ArrayList<ListItem>());
188             }
189 
190             // This will disconnect when the callback is called
191             getFolderItemsInternal(mediaId, cb);
192         };
193         mWrappedBrowser.connect();
194 
195         return true;
196     }
197 
198     // Internal function to call once the Browser is connected
getFolderItemsInternal(String mediaId, BrowseCallback cb)199     private boolean getFolderItemsInternal(String mediaId, BrowseCallback cb) {
200         mWrappedBrowser.subscribe(mediaId, new BrowserSubscriptionCallback(cb));
201         return true;
202     }
203 
204     class MediaConnectionCallback extends MediaBrowser.ConnectionCallback {
205         @Override
onConnected()206         public void onConnected() {
207             Log.i(TAG, "onConnected: " + mPackageName + " is connected");
208             // Get the root while connected because we may need to use it when disconnected.
209             mRoot = mWrappedBrowser.getRoot();
210 
211             if (mCallback == null) return;
212 
213             if (mRoot == null || mRoot.isEmpty()) {
214                 mCallback.run(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
215                 return;
216             }
217 
218             mCallback.run(STATUS_SUCCESS, BrowsedPlayerWrapper.this);
219             mCallback = null;
220         }
221 
222 
223         @Override
onConnectionFailed()224         public void onConnectionFailed() {
225             Log.w(TAG, "onConnectionFailed: Connection Failed with " + mPackageName);
226             if (mCallback != null) mCallback.run(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
227             mCallback = null;
228         }
229 
230         // TODO (apanicke): Add a check to list a player as unbrowsable if it suspends immediately
231         // after connection.
232         @Override
onConnectionSuspended()233         public void onConnectionSuspended() {
234             mWrappedBrowser.disconnect();
235             Log.i(TAG, "onConnectionSuspended: Connection Suspended with " + mPackageName);
236         }
237     }
238 
239     /**
240      * Subscription callback handler. Subscribe to a folder to get its contents. We generate a new
241      * instance for this class for each subscribe call to make it easier to differentiate between
242      * the callers.
243      */
244     private class BrowserSubscriptionCallback extends MediaBrowser.SubscriptionCallback {
245         BrowseCallback mCallback = null;
246 
BrowserSubscriptionCallback(BrowseCallback cb)247         BrowserSubscriptionCallback(BrowseCallback cb) {
248             mCallback = cb;
249         }
250 
251         @Override
onChildrenLoaded(String parentId, List<MediaItem> children)252         public void onChildrenLoaded(String parentId, List<MediaItem> children) {
253             if (DEBUG) {
254                 Log.d(TAG, "onChildrenLoaded: mediaId=" + parentId + " size= " + children.size());
255             }
256 
257             if (mCallback == null) {
258                 Log.w(TAG, "onChildrenLoaded: " + mPackageName
259                         + " children loaded while callback is null");
260             }
261 
262             // TODO (apanicke): Instead of always unsubscribing, only unsubscribe from folders
263             // that aren't cached. This will let us update what is cached on the fly and prevent
264             // us from serving stale data.
265             mWrappedBrowser.unsubscribe(parentId);
266 
267             ArrayList<ListItem> return_list = new ArrayList<ListItem>();
268 
269             for (MediaItem item : children) {
270                 if (DEBUG) {
271                     Log.d(TAG, "onChildrenLoaded: Child=\"" + item.toString()
272                             + "\",  ID=\"" + item.getMediaId() + "\"");
273                 }
274 
275                 if (item.isBrowsable()) {
276                     CharSequence titleCharSequence = item.getDescription().getTitle();
277                     String title = "Not Provided";
278                     if (titleCharSequence != null) {
279                         title = titleCharSequence.toString();
280                     }
281                     Folder f = new Folder(item.getMediaId(), false, title);
282                     return_list.add(new ListItem(f));
283                 } else {
284                     return_list.add(new ListItem(Util.toMetadata(item)));
285                 }
286             }
287 
288             mCachedFolders.put(parentId, return_list);
289 
290             // Clone the list so that the callee can mutate it without affecting the cached data
291             mCallback.run(STATUS_SUCCESS, parentId, Util.cloneList(return_list));
292             mCallback = null;
293             disconnect();
294         }
295 
296         /* mediaId is invalid */
297         @Override
onError(String id)298         public void onError(String id) {
299             Log.e(TAG, "BrowserSubscriptionCallback: Could not get folder items");
300             mCallback.run(STATUS_LOOKUP_ERROR, id, new ArrayList<ListItem>());
301             disconnect();
302         }
303     }
304 
305     @Override
toString()306     public String toString() {
307         StringBuilder sb = new StringBuilder();
308         sb.append("Browsable Package Name: " + mPackageName + "\n");
309         sb.append("   Cached Media ID's: ");
310         for (String id : mCachedFolders.keySet()) {
311             sb.append("\"" + id + "\", ");
312         }
313         sb.append("\n");
314         return sb.toString();
315     }
316 }
317