1 /* 2 * Copyright (C) 2019 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.avrcpcontroller; 18 19 import static android.bluetooth.BluetoothProfile.STATE_CONNECTED; 20 import static android.bluetooth.BluetoothProfile.STATE_DISCONNECTED; 21 22 import android.bluetooth.BluetoothDevice; 23 import android.graphics.Bitmap; 24 import android.net.Uri; 25 import android.os.SystemProperties; 26 import android.util.Log; 27 28 import com.android.obex.ResponseCodes; 29 30 import java.util.Map; 31 import java.util.Set; 32 import java.util.UUID; 33 import java.util.concurrent.ConcurrentHashMap; 34 35 /** 36 * Manager of all AVRCP Controller connections to remote devices' BIP servers for retrieving cover 37 * art. 38 * 39 * <p>When given an image handle and device, this manager will negotiate the downloaded image 40 * properties, download the image, and place it into a Content Provider for others to retrieve from 41 */ 42 class AvrcpCoverArtManager { 43 private static final String TAG = AvrcpCoverArtManager.class.getSimpleName(); 44 45 // Image Download Schemes for cover art 46 private static final String AVRCP_CONTROLLER_COVER_ART_SCHEME = 47 "persist.bluetooth.avrcpcontroller.BIP_DOWNLOAD_SCHEME"; 48 private static final String SCHEME_NATIVE = "native"; 49 private static final String SCHEME_THUMBNAIL = "thumbnail"; 50 51 private final Map<BluetoothDevice, AvrcpBipClient> mClients = new ConcurrentHashMap<>(1); 52 private final Map<BluetoothDevice, AvrcpBipSession> mBipSessions = new ConcurrentHashMap<>(1); 53 private final AvrcpControllerService mService; 54 private final AvrcpCoverArtStorage mCoverArtStorage; 55 private final Callback mCallback; 56 private final String mDownloadScheme; 57 AvrcpCoverArtManager(AvrcpControllerService service, Callback callback)58 AvrcpCoverArtManager(AvrcpControllerService service, Callback callback) { 59 mService = service; 60 mCoverArtStorage = new AvrcpCoverArtStorage(mService); 61 mCallback = callback; 62 mDownloadScheme = SystemProperties.get(AVRCP_CONTROLLER_COVER_ART_SCHEME, SCHEME_THUMBNAIL); 63 mCoverArtStorage.clear(); 64 } 65 66 /** 67 * An object representing an image download event. Contains the information necessary to 68 * retrieve the image from storage. 69 */ DownloadEvent(String uuid, Uri uri)70 record DownloadEvent(String uuid, Uri uri) {} 71 72 interface Callback { 73 /** 74 * Notify of a get image download completing 75 * 76 * @param device The device the image handle belongs to 77 * @param event The download event, containing the downloaded image's information 78 */ onImageDownloadComplete(BluetoothDevice device, DownloadEvent event)79 void onImageDownloadComplete(BluetoothDevice device, DownloadEvent event); 80 } 81 82 /** 83 * A thread-safe collection of BIP connection specific information meant to be cleared each time 84 * a client disconnects from the Target's BIP OBEX server. 85 * 86 * <p>Currently contains the mapping of image handles seen to assigned UUIDs. 87 */ 88 private static class AvrcpBipSession { 89 private final Map<String, String> mUuids = new ConcurrentHashMap<>(1); // handle -> UUID 90 private final Map<String, String> mHandles = new ConcurrentHashMap<>(1); // UUID -> handle 91 getHandleUuid(String handle)92 private String getHandleUuid(String handle) { 93 if (!isValidImageHandle(handle)) return null; 94 String newUuid = UUID.randomUUID().toString(); 95 String existingUuid = mUuids.putIfAbsent(handle, newUuid); 96 if (existingUuid != null) return existingUuid; 97 mHandles.put(newUuid, handle); 98 return newUuid; 99 } 100 getUuidHandle(String uuid)101 private String getUuidHandle(String uuid) { 102 return mHandles.get(uuid); 103 } 104 clearHandleUuids()105 private void clearHandleUuids() { 106 mUuids.clear(); 107 mHandles.clear(); 108 } 109 getSessionHandles()110 private Set<String> getSessionHandles() { 111 return mUuids.keySet(); 112 } 113 } 114 115 /** 116 * Validate an image handle meets the AVRCP and BIP specifications 117 * 118 * <p>By the BIP specification that AVRCP uses, "Image handles are 7 character long strings 119 * containing only the digits 0 to 9." 120 * 121 * @return True if the input string is a valid image handle 122 */ isValidImageHandle(String handle)123 static boolean isValidImageHandle(String handle) { 124 if (handle == null || handle.length() != 7) return false; 125 for (int i = 0; i < handle.length(); i++) { 126 char c = handle.charAt(i); 127 if (!Character.isDigit(c)) { 128 return false; 129 } 130 } 131 return true; 132 } 133 134 /** 135 * Create a client and connect to a remote device's BIP Image Pull Server 136 * 137 * @param device The remote Bluetooth device you wish to connect to 138 * @param psm The Protocol Service Multiplexer that the remote device is hosting the server on 139 * @return True if the connection is successfully queued, False otherwise. 140 */ connect(BluetoothDevice device, int psm)141 synchronized boolean connect(BluetoothDevice device, int psm) { 142 debug("Connect " + device + ", psm: " + psm); 143 if (mClients.containsKey(device)) return false; 144 AvrcpBipClient client = new AvrcpBipClient(device, psm, new BipClientCallback(device)); 145 client.connectAsync(); 146 mClients.put(device, client); 147 mBipSessions.put(device, new AvrcpBipSession()); 148 return true; 149 } 150 151 /** 152 * Refresh the OBEX session of a connected client 153 * 154 * @param device The remote Bluetooth device you wish to refresh 155 * @return True if the refresh is successfully queued, False otherwise. 156 */ refreshSession(BluetoothDevice device)157 synchronized boolean refreshSession(BluetoothDevice device) { 158 debug("Refresh OBEX session for " + device); 159 AvrcpBipClient client = getClient(device); 160 if (client == null) { 161 warn("No client for " + device); 162 return false; 163 } 164 client.refreshSession(); 165 return true; 166 } 167 168 /** 169 * Disconnect from a remote device's BIP Image Pull Server 170 * 171 * @param device The remote Bluetooth device you wish to disconnect from 172 * @return True if the disconnection is successfully queued, False otherwise. 173 */ disconnect(BluetoothDevice device)174 synchronized boolean disconnect(BluetoothDevice device) { 175 debug("Disconnect " + device); 176 AvrcpBipClient client = getClient(device); 177 if (client == null) { 178 warn("No client for " + device); 179 return false; 180 } 181 client.shutdown(); 182 mClients.remove(device); 183 mBipSessions.remove(device); 184 mCoverArtStorage.removeImagesForDevice(device); 185 return true; 186 } 187 188 /** 189 * Cleanup all cover art related resources 190 * 191 * <p>Please call when you've committed to shutting down the service. 192 */ cleanup()193 synchronized void cleanup() { 194 debug("Clean up and shutdown"); 195 for (BluetoothDevice device : mClients.keySet()) { 196 disconnect(device); 197 } 198 } 199 200 /** 201 * Get the client connection state for a particular device's BIP Client 202 * 203 * @param device The Bluetooth device you want connection status for 204 * @return Connection status, based on STATE_* constants 205 */ getState(BluetoothDevice device)206 int getState(BluetoothDevice device) { 207 AvrcpBipClient client = getClient(device); 208 if (client == null) return STATE_DISCONNECTED; 209 return client.getState(); 210 } 211 212 /** 213 * Get the UUID for an image handle coming from a particular device. 214 * 215 * <p>This UUID is used to request and track downloads. 216 * 217 * <p>Image handles are only good for the life of the BIP client. Since this connection is torn 218 * down frequently by specification, we have a layer of indirection to the images in the form of 219 * an UUID. This UUID will allow images to be identified outside the connection lifecycle. It 220 * also allows handles to be reused by the target in ways that won't impact image consumer's 221 * cache schemes. 222 * 223 * @param device The Bluetooth device you want a handle from 224 * @param handle The image handle you want a UUID for 225 * @return A string UUID by which the handle can be identified during the life of the BIP 226 * connection. 227 */ getUuidForHandle(BluetoothDevice device, String handle)228 String getUuidForHandle(BluetoothDevice device, String handle) { 229 AvrcpBipSession session = getSession(device); 230 if (session == null || !isValidImageHandle(handle)) return null; 231 return session.getHandleUuid(handle); 232 } 233 234 /** 235 * Get the handle thats associated with a particular UUID. 236 * 237 * <p>The handle must have been seen during this connection. 238 * 239 * @param device The Bluetooth device you want a handle from 240 * @param uuid The UUID you want the associated handle for 241 * @return The image handle associated with this UUID if it exists, null otherwise. 242 */ getHandleForUuid(BluetoothDevice device, String uuid)243 String getHandleForUuid(BluetoothDevice device, String uuid) { 244 AvrcpBipSession session = getSession(device); 245 if (session == null || uuid == null) return null; 246 return session.getUuidHandle(uuid); 247 } 248 clearHandleUuids(BluetoothDevice device)249 void clearHandleUuids(BluetoothDevice device) { 250 AvrcpBipSession session = getSession(device); 251 if (session == null) return; 252 session.clearHandleUuids(); 253 } 254 255 /** 256 * Get the Uri of an image if it has already been downloaded. 257 * 258 * @param device The remote Bluetooth device you wish to get an image for 259 * @param imageUuid The UUID associated with the image you want 260 * @return A Uri the image can be found at, null if it does not exist 261 */ getImageUri(BluetoothDevice device, String imageUuid)262 Uri getImageUri(BluetoothDevice device, String imageUuid) { 263 if (mCoverArtStorage.doesImageExist(device, imageUuid)) { 264 return AvrcpCoverArtProvider.getImageUri(device, imageUuid); 265 } 266 return null; 267 } 268 269 /** 270 * Download an image from a remote device and make it findable via the given uri 271 * 272 * <p>Downloading happens in three steps: 1) Get the available image formats by requesting the 273 * Image Properties 2) Determine the specific format we want the image in and turn it into an 274 * image descriptor 3) Get the image using the chosen descriptor 275 * 276 * <p>Getting image properties and the image are both asynchronous in nature. 277 * 278 * @param device The remote Bluetooth device you wish to download from 279 * @param imageUuid The UUID associated with the image you wish to download. This will be 280 * translated into an image handle. 281 * @return A Uri that will be assign to the image once the download is complete 282 */ downloadImage(BluetoothDevice device, String imageUuid)283 Uri downloadImage(BluetoothDevice device, String imageUuid) { 284 debug("Download Image - device: " + device + ", Handle: " + imageUuid); 285 AvrcpBipClient client = getClient(device); 286 if (client == null) { 287 error("Cannot download an image. No client is available."); 288 return null; 289 } 290 291 // Check to see if we have the image already. No need to download it if we do have it. 292 if (mCoverArtStorage.doesImageExist(device, imageUuid)) { 293 debug("Image is already downloaded"); 294 return AvrcpCoverArtProvider.getImageUri(device, imageUuid); 295 } 296 297 // Getting image properties will return via the callback created when connecting, which 298 // invokes the download image function after we're returned the properties. If we already 299 // have the image, GetImageProperties returns true but does not start a download. 300 String imageHandle = getHandleForUuid(device, imageUuid); 301 if (imageHandle == null) { 302 warn("No handle for UUID"); 303 return null; 304 } 305 boolean status = client.getImageProperties(imageHandle); 306 if (!status) return null; 307 308 // Return the Uri that the caller should use to retrieve the image 309 return AvrcpCoverArtProvider.getImageUri(device, imageUuid); 310 } 311 312 /** 313 * Get a specific downloaded image if it exists 314 * 315 * @param device The remote Bluetooth device associated with the image 316 * @param imageUuid The UUID associated with the image you wish to retrieve 317 */ getImage(BluetoothDevice device, String imageUuid)318 Bitmap getImage(BluetoothDevice device, String imageUuid) { 319 return mCoverArtStorage.getImage(device, imageUuid); 320 } 321 322 /** 323 * Remove a specific downloaded image if it exists 324 * 325 * @param device The remote Bluetooth device associated with the image 326 * @param imageUuid The UUID associated with the image you wish to remove 327 */ removeImage(BluetoothDevice device, String imageUuid)328 void removeImage(BluetoothDevice device, String imageUuid) { 329 mCoverArtStorage.removeImage(device, imageUuid); 330 } 331 332 /** 333 * Get a device's BIP client if it exists 334 * 335 * @param device The device you want the client for 336 * @return The AvrcpBipClient object associated with the device, or null if it doesn't exist 337 */ getClient(BluetoothDevice device)338 private AvrcpBipClient getClient(BluetoothDevice device) { 339 return mClients.get(device); 340 } 341 342 /** 343 * Get a device's BIP session information, if it exists 344 * 345 * @param device The device you want the client for 346 * @return The AvrcpBipSession object associated with the device, or null if it doesn't exist 347 */ getSession(BluetoothDevice device)348 private AvrcpBipSession getSession(BluetoothDevice device) { 349 return mBipSessions.get(device); 350 } 351 352 /** 353 * Determines our preferred download descriptor from the list of available image download 354 * formats presented in the image properties object. 355 * 356 * <p>Our goal is ensure the image arrives in a format Android can consume and to minimize 357 * transfer size if possible. 358 * 359 * @param properties The set of available formats and image is downloadable in 360 * @return A descriptor containing the desirable download format 361 */ determineImageDescriptor(BipImageProperties properties)362 private BipImageDescriptor determineImageDescriptor(BipImageProperties properties) { 363 if (properties == null || !properties.isValid()) { 364 warn("Provided properties don't meet the spec. Requesting thumbnail format anyway."); 365 } 366 BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder(); 367 switch (mDownloadScheme) { 368 // BIP Specification says a blank/null descriptor signals to pull the native format 369 case SCHEME_NATIVE: 370 return null; 371 // AVRCP 1.6.2 defined "thumbnail" size is guaranteed so we'll do that for now 372 case SCHEME_THUMBNAIL: 373 default: 374 builder.setEncoding(BipEncoding.JPEG); 375 builder.setFixedDimensions(200, 200); 376 break; 377 } 378 return builder.build(); 379 } 380 381 /** Callback for facilitating image download */ 382 class BipClientCallback implements AvrcpBipClient.Callback { 383 private final BluetoothDevice mDevice; 384 BipClientCallback(BluetoothDevice device)385 BipClientCallback(BluetoothDevice device) { 386 mDevice = device; 387 } 388 389 @Override onConnectionStateChanged(int oldState, int newState)390 public void onConnectionStateChanged(int oldState, int newState) { 391 debug(mDevice + ": " + oldState + " -> " + newState); 392 if (newState == STATE_CONNECTED) { 393 // Ensure the handle map is cleared since old ones are invalid on a new connection 394 clearHandleUuids(mDevice); 395 396 // Once we're connected fetch the current metadata again in case the target has an 397 // image handle they can now give us. Only do this if we don't already have one. 398 mService.getCurrentMetadataIfNoCoverArt(mDevice); 399 } else if (newState == STATE_DISCONNECTED) { 400 AvrcpBipClient client = getClient(mDevice); 401 boolean shouldReconnect = (client != null); 402 disconnect(mDevice); 403 if (shouldReconnect) { 404 debug("Disconnect was not expected by us. Attempt to reconnect."); 405 connect(mDevice, client.getL2capPsm()); 406 } 407 } 408 } 409 410 @Override onGetImagePropertiesComplete( int status, String imageHandle, BipImageProperties properties)411 public void onGetImagePropertiesComplete( 412 int status, String imageHandle, BipImageProperties properties) { 413 if (status != ResponseCodes.OBEX_HTTP_OK || properties == null) { 414 warn( 415 mDevice 416 + ": GetImageProperties() failed - Handle: " 417 + imageHandle 418 + ", Code: " 419 + status); 420 return; 421 } 422 BipImageDescriptor descriptor = determineImageDescriptor(properties); 423 debug(mDevice + ": Download image - handle='" + imageHandle + "'"); 424 425 AvrcpBipClient client = getClient(mDevice); 426 if (client == null) { 427 warn( 428 mDevice 429 + ": Could not getImage() for " 430 + imageHandle 431 + " because client has disconnected."); 432 return; 433 } 434 client.getImage(imageHandle, descriptor); 435 } 436 437 @Override onGetImageComplete(int status, String imageHandle, BipImage image)438 public void onGetImageComplete(int status, String imageHandle, BipImage image) { 439 if (status != ResponseCodes.OBEX_HTTP_OK) { 440 warn( 441 mDevice 442 + ": GetImage() failed - Handle: " 443 + imageHandle 444 + ", Code: " 445 + status); 446 return; 447 } 448 String imageUuid = getUuidForHandle(mDevice, imageHandle); 449 debug( 450 mDevice 451 + ": Received image data for handle: " 452 + imageHandle 453 + ", uuid: " 454 + imageUuid 455 + ", image: " 456 + image); 457 Uri uri = mCoverArtStorage.addImage(mDevice, imageUuid, image.getImage()); 458 if (uri == null) { 459 error("Could not store downloaded image"); 460 return; 461 } 462 DownloadEvent event = new DownloadEvent(imageUuid, uri); 463 if (mCallback != null) mCallback.onImageDownloadComplete(mDevice, event); 464 } 465 } 466 467 @Override toString()468 public String toString() { 469 StringBuilder sb = new StringBuilder("CoverArtManager:\n"); 470 sb.append(" Download Scheme: ").append(mDownloadScheme).append("\n"); 471 for (BluetoothDevice device : mClients.keySet()) { 472 AvrcpBipClient client = getClient(device); 473 AvrcpBipSession session = getSession(device); 474 sb.append(" ").append(device).append(":").append("\n"); 475 sb.append(" Client: ").append(client.toString()).append("\n"); 476 sb.append(" Handles: ").append("\n"); 477 for (String handle : session.getSessionHandles()) { 478 sb.append(" ") 479 .append(handle) 480 .append(" -> ") 481 .append(session.getHandleUuid(handle)) 482 .append("\n"); 483 } 484 } 485 sb.append(" ").append(mCoverArtStorage.toString()); 486 return sb.toString(); 487 } 488 489 /** Print to debug if debug is enabled for this class */ debug(String msg)490 private static void debug(String msg) { 491 Log.d(TAG, msg); 492 } 493 494 /** Print to warn */ warn(String msg)495 private static void warn(String msg) { 496 Log.w(TAG, msg); 497 } 498 499 /** Print to error */ error(String msg)500 private static void error(String msg) { 501 Log.e(TAG, msg); 502 } 503 } 504