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