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