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