blob: 5e5eaefc22cd0ae762d895a83383899915e4b0e1 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.bluetooth.avrcpcontroller;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.SystemProperties;
import android.util.Log;
import com.android.obex.ResponseCodes;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Manager of all AVRCP Controller connections to remote devices' BIP servers for retrieving cover
* art.
*
* When given an image handle and device, this manager will negotiate the downloaded image
* properties, download the image, and place it into a Content Provider for others to retrieve from
*/
public class AvrcpCoverArtManager {
private static final String TAG = "AvrcpCoverArtManager";
private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
// Image Download Schemes for cover art
public static final String AVRCP_CONTROLLER_COVER_ART_SCHEME =
"persist.bluetooth.avrcpcontroller.BIP_DOWNLOAD_SCHEME";
public static final String SCHEME_NATIVE = "native";
public static final String SCHEME_THUMBNAIL = "thumbnail";
private final AvrcpControllerService mService;
protected final Map<BluetoothDevice, AvrcpBipClient> mClients = new ConcurrentHashMap<>(1);
private Map<BluetoothDevice, AvrcpBipSession> mBipSessions = new ConcurrentHashMap<>(1);
private final AvrcpCoverArtStorage mCoverArtStorage;
private final Callback mCallback;
private final String mDownloadScheme;
/**
* An object representing an image download event. Contains the information necessary to
* retrieve the image from storage.
*/
public class DownloadEvent {
final String mImageUuid;
final Uri mUri;
public DownloadEvent(String uuid, Uri uri) {
mImageUuid = uuid;
mUri = uri;
}
public String getUuid() {
return mImageUuid;
}
public Uri getUri() {
return mUri;
}
}
interface Callback {
/**
* Notify of a get image download completing
*
* @param device The device the image handle belongs to
* @param event The download event, containing the downloaded image's information
*/
void onImageDownloadComplete(BluetoothDevice device, DownloadEvent event);
}
/**
* A thread-safe collection of BIP connection specific imformation meant to be cleared each
* time a client disconnects from the Target's BIP OBEX server.
*
* Currently contains the mapping of image handles seen to assigned UUIDs.
*/
private class AvrcpBipSession {
private final BluetoothDevice mDevice;
private Map<String, String> mUuids = new ConcurrentHashMap<>(1); /* handle -> UUID */
private Map<String, String> mHandles = new ConcurrentHashMap<>(1); /* UUID -> handle */
AvrcpBipSession(BluetoothDevice device) {
mDevice = device;
}
public String getHandleUuid(String handle) {
if (!isValidImageHandle(handle)) return null;
String newUuid = UUID.randomUUID().toString();
String existingUuid = mUuids.putIfAbsent(handle, newUuid);
if (existingUuid != null) return existingUuid;
mHandles.put(newUuid, handle);
return newUuid;
}
public String getUuidHandle(String uuid) {
return mHandles.get(uuid);
}
public void clearHandleUuids() {
mUuids.clear();
mHandles.clear();
}
public Set<String> getSessionHandles() {
return mUuids.keySet();
}
}
/**
* Validate an image handle meets the AVRCP and BIP specifications
*
* By the BIP specification that AVRCP uses, "Image handles are 7 character long strings
* containing only the digits 0 to 9."
*
* @return True if the input string is a valid image handle
*/
public static boolean isValidImageHandle(String handle) {
if (handle == null || handle.length() != 7) return false;
for (char c : handle.toCharArray()) {
if (!Character.isDigit(c)) {
return false;
}
}
return true;
}
public AvrcpCoverArtManager(AvrcpControllerService service, Callback callback) {
mService = service;
mCoverArtStorage = new AvrcpCoverArtStorage(mService);
mCallback = callback;
mDownloadScheme =
SystemProperties.get(AVRCP_CONTROLLER_COVER_ART_SCHEME, SCHEME_THUMBNAIL);
mCoverArtStorage.clear();
}
/**
* Create a client and connect to a remote device's BIP Image Pull Server
*
* @param device The remote Bluetooth device you wish to connect to
* @param psm The Protocol Service Multiplexer that the remote device is hosting the server on
* @return True if the connection is successfully queued, False otherwise.
*/
public synchronized boolean connect(BluetoothDevice device, int psm) {
debug("Connect " + device + ", psm: " + psm);
if (mClients.containsKey(device)) return false;
AvrcpBipClient client = new AvrcpBipClient(device, psm, new BipClientCallback(device));
client.connectAsync();
mClients.put(device, client);
mBipSessions.put(device, new AvrcpBipSession(device));
return true;
}
/**
* Refresh the OBEX session of a connected client
*
* @param device The remote Bluetooth device you wish to refresh
* @return True if the refresh is successfully queued, False otherwise.
*/
public synchronized boolean refreshSession(BluetoothDevice device) {
debug("Refresh OBEX session for " + device);
AvrcpBipClient client = getClient(device);
if (client == null) {
warn("No client for " + device);
return false;
}
client.refreshSession();
return true;
}
/**
* Disconnect from a remote device's BIP Image Pull Server
*
* @param device The remote Bluetooth device you wish to disconnect from
* @return True if the disconnection is successfully queued, False otherwise.
*/
public synchronized boolean disconnect(BluetoothDevice device) {
debug("Disconnect " + device);
AvrcpBipClient client = getClient(device);
if (client == null) {
warn("No client for " + device);
return false;
}
client.shutdown();
mClients.remove(device);
mBipSessions.remove(device);
mCoverArtStorage.removeImagesForDevice(device);
return true;
}
/**
* Cleanup all cover art related resources
*
* Please call when you've committed to shutting down the service.
*/
public synchronized void cleanup() {
debug("Clean up and shutdown");
for (BluetoothDevice device : mClients.keySet()) {
disconnect(device);
}
}
/**
* Get the client connection state for a particular device's BIP Client
*
* @param device The Bluetooth device you want connection status for
* @return Connection status, based on BluetoothProfile.STATE_* constants
*/
public int getState(BluetoothDevice device) {
AvrcpBipClient client = getClient(device);
if (client == null) return BluetoothProfile.STATE_DISCONNECTED;
return client.getState();
}
/**
* Get the UUID for an image handle coming from a particular device.
*
* This UUID is used to request and track downloads.
*
* Image handles are only good for the life of the BIP client. Since this connection is torn
* down frequently by specification, we have a layer of indirection to the images in the form
* of an UUID. This UUID will allow images to be identified outside the connection lifecycle.
* It also allows handles to be reused by the target in ways that won't impact image consumer's
* cache schemes.
*
* @param device The Bluetooth device you want a handle from
* @param handle The image handle you want a UUID for
* @return A string UUID by which the handle can be identified during the life of the BIP
* connection.
*/
public String getUuidForHandle(BluetoothDevice device, String handle) {
AvrcpBipSession session = getSession(device);
if (session == null || !isValidImageHandle(handle)) return null;
return session.getHandleUuid(handle);
}
/**
* Get the handle thats associated with a particular UUID.
*
* The handle must have been seen during this connection.
*
* @param device The Bluetooth device you want a handle from
* @param uuid The UUID you want the associated handle for
* @return The image handle associated with this UUID if it exists, null otherwise.
*/
public String getHandleForUuid(BluetoothDevice device, String uuid) {
AvrcpBipSession session = getSession(device);
if (session == null || uuid == null) return null;
return session.getUuidHandle(uuid);
}
private void clearHandleUuids(BluetoothDevice device) {
AvrcpBipSession session = getSession(device);
if (session == null) return;
session.clearHandleUuids();
}
/**
* Get the Uri of an image if it has already been downloaded.
*
* @param device The remote Bluetooth device you wish to get an image for
* @param imageUuid The UUID associated with the image you want
* @return A Uri the image can be found at, null if it does not exist
*/
public Uri getImageUri(BluetoothDevice device, String imageUuid) {
if (mCoverArtStorage.doesImageExist(device, imageUuid)) {
return AvrcpCoverArtProvider.getImageUri(device, imageUuid);
}
return null;
}
/**
* Download an image from a remote device and make it findable via the given uri
*
* Downloading happens in three steps:
* 1) Get the available image formats by requesting the Image Properties
* 2) Determine the specific format we want the image in and turn it into an image descriptor
* 3) Get the image using the chosen descriptor
*
* Getting image properties and the image are both asynchronous in nature.
*
* @param device The remote Bluetooth device you wish to download from
* @param imageUuid The UUID associated with the image you wish to download. This will be
* translated into an image handle.
* @return A Uri that will be assign to the image once the download is complete
*/
public Uri downloadImage(BluetoothDevice device, String imageUuid) {
debug("Download Image - device: " + device + ", Handle: " + imageUuid);
AvrcpBipClient client = getClient(device);
if (client == null) {
error("Cannot download an image. No client is available.");
return null;
}
// Check to see if we have the image already. No need to download it if we do have it.
if (mCoverArtStorage.doesImageExist(device, imageUuid)) {
debug("Image is already downloaded");
return AvrcpCoverArtProvider.getImageUri(device, imageUuid);
}
// Getting image properties will return via the callback created when connecting, which
// invokes the download image function after we're returned the properties. If we already
// have the image, GetImageProperties returns true but does not start a download.
String imageHandle = getHandleForUuid(device, imageUuid);
if (imageHandle == null) {
warn("No handle for UUID");
return null;
}
boolean status = client.getImageProperties(imageHandle);
if (!status) return null;
// Return the Uri that the caller should use to retrieve the image
return AvrcpCoverArtProvider.getImageUri(device, imageUuid);
}
/**
* Get a specific downloaded image if it exists
*
* @param device The remote Bluetooth device associated with the image
* @param imageUuid The UUID associated with the image you wish to retrieve
*/
public Bitmap getImage(BluetoothDevice device, String imageUuid) {
return mCoverArtStorage.getImage(device, imageUuid);
}
/**
* Remove a specific downloaded image if it exists
*
* @param device The remote Bluetooth device associated with the image
* @param imageUuid The UUID associated with the image you wish to remove
*/
public void removeImage(BluetoothDevice device, String imageUuid) {
mCoverArtStorage.removeImage(device, imageUuid);
}
/**
* Get a device's BIP client if it exists
*
* @param device The device you want the client for
* @return The AvrcpBipClient object associated with the device, or null if it doesn't exist
*/
private AvrcpBipClient getClient(BluetoothDevice device) {
return mClients.get(device);
}
/**
* Get a device's BIP session information, if it exists
*
* @param device The device you want the client for
* @return The AvrcpBipSession object associated with the device, or null if it doesn't exist
*/
private AvrcpBipSession getSession(BluetoothDevice device) {
return mBipSessions.get(device);
}
/**
* Determines our preferred download descriptor from the list of available image download
* formats presented in the image properties object.
*
* Our goal is ensure the image arrives in a format Android can consume and to minimize transfer
* size if possible.
*
* @param properties The set of available formats and image is downloadable in
* @return A descriptor containing the desirable download format
*/
private BipImageDescriptor determineImageDescriptor(BipImageProperties properties) {
if (properties == null || !properties.isValid()) {
warn("Provided properties don't meet the spec. Requesting thumbnail format anyway.");
}
BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
switch (mDownloadScheme) {
// BIP Specification says a blank/null descriptor signals to pull the native format
case SCHEME_NATIVE:
return null;
// AVRCP 1.6.2 defined "thumbnail" size is guaranteed so we'll do that for now
case SCHEME_THUMBNAIL:
default:
builder.setEncoding(BipEncoding.JPEG);
builder.setFixedDimensions(200, 200);
break;
}
return builder.build();
}
/**
* Callback for facilitating image download
*/
class BipClientCallback implements AvrcpBipClient.Callback {
final BluetoothDevice mDevice;
BipClientCallback(BluetoothDevice device) {
mDevice = device;
}
@Override
public void onConnectionStateChanged(int oldState, int newState) {
debug(mDevice + ": " + oldState + " -> " + newState);
if (newState == BluetoothProfile.STATE_CONNECTED) {
// Ensure the handle map is cleared since old ones are invalid on a new connection
clearHandleUuids(mDevice);
// Once we're connected fetch the current metadata again in case the target has an
// image handle they can now give us. Only do this if we don't already have one.
mService.getCurrentMetadataIfNoCoverArt(mDevice);
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
AvrcpBipClient client = getClient(mDevice);
boolean shouldReconnect = (client != null);
disconnect(mDevice);
if (shouldReconnect) {
debug("Disconnect was not expected by us. Attempt to reconnect.");
connect(mDevice, client.getL2capPsm());
}
}
}
@Override
public void onGetImagePropertiesComplete(int status, String imageHandle,
BipImageProperties properties) {
if (status != ResponseCodes.OBEX_HTTP_OK || properties == null) {
warn(mDevice + ": GetImageProperties() failed - Handle: " + imageHandle
+ ", Code: " + status);
return;
}
BipImageDescriptor descriptor = determineImageDescriptor(properties);
debug(mDevice + ": Download image - handle='" + imageHandle + "'");
AvrcpBipClient client = getClient(mDevice);
if (client == null) {
warn(mDevice + ": Could not getImage() for " + imageHandle
+ " because client has disconnected.");
return;
}
client.getImage(imageHandle, descriptor);
}
@Override
public void onGetImageComplete(int status, String imageHandle, BipImage image) {
if (status != ResponseCodes.OBEX_HTTP_OK) {
warn(mDevice + ": GetImage() failed - Handle: " + imageHandle
+ ", Code: " + status);
return;
}
String imageUuid = getUuidForHandle(mDevice, imageHandle);
debug(mDevice + ": Received image data for handle: " + imageHandle
+ ", uuid: " + imageUuid + ", image: " + image);
Uri uri = mCoverArtStorage.addImage(mDevice, imageUuid, image.getImage());
if (uri == null) {
error("Could not store downloaded image");
return;
}
DownloadEvent event = new DownloadEvent(imageUuid, uri);
if (mCallback != null) mCallback.onImageDownloadComplete(mDevice, event);
}
}
@Override
public String toString() {
String s = "CoverArtManager:\n";
s += " Download Scheme: " + mDownloadScheme + "\n";
for (BluetoothDevice device : mClients.keySet()) {
AvrcpBipClient client = getClient(device);
AvrcpBipSession session = getSession(device);
s += " " + device + ":" + "\n";
s += " Client: " + client.toString() + "\n";
s += " Handles: " + "\n";
for (String handle : session.getSessionHandles()) {
s += " " + handle + " -> " + session.getHandleUuid(handle) + "\n";
}
}
s += " " + mCoverArtStorage.toString();
return s;
}
/**
* Print to debug if debug is enabled for this class
*/
private void debug(String msg) {
if (DBG) {
Log.d(TAG, msg);
}
}
/**
* Print to warn
*/
private void warn(String msg) {
Log.w(TAG, msg);
}
/**
* Print to error
*/
private void error(String msg) {
Log.e(TAG, msg);
}
}