Add support for the AVRCP Controller Cover Art feature
Bug: b/132812696
Test: Build, flash, interop test with devices, atest. New unit tests to
come as the topic of a new patch.
Change-Id: I0895dbce1fa797f73929a9e26f46c1d48b3a367f
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 5faf095..bc42835 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -344,6 +344,13 @@
<action android:name="android.bluetooth.IBluetoothAvrcpController" />
</intent-filter>
</service>
+ <provider android:process="@string/process"
+ android:name=".avrcpcontroller.AvrcpCoverArtProvider"
+ android:authorities="com.android.bluetooth.avrcpcontroller.AvrcpCoverArtProvider"
+ android:enabled="@bool/avrcp_controller_enable_cover_art"
+ android:grantUriPermissions="true"
+ android:exported="true">
+ </provider>
<service
android:process="@string/process"
android:name = ".hid.HidHostService"
diff --git a/jni/com_android_bluetooth_avrcp_controller.cpp b/jni/com_android_bluetooth_avrcp_controller.cpp
index 818a38f..55a6af6 100755
--- a/jni/com_android_bluetooth_avrcp_controller.cpp
+++ b/jni/com_android_bluetooth_avrcp_controller.cpp
@@ -49,6 +49,7 @@
static jmethodID method_handleAddressedPlayerChanged;
static jmethodID method_handleNowPlayingContentChanged;
static jmethodID method_onAvailablePlayerChanged;
+static jmethodID method_getRcPsm;
static jclass class_AvrcpItem;
static jclass class_AvrcpPlayer;
@@ -722,27 +723,50 @@
static void btavrcp_available_player_changed_callback (
const RawAddress& bd_addr) {
- ALOGI("%s", __func__);
-
- std::shared_lock<std::shared_timed_mutex> lock(sCallbacks_mutex);
-
- CallbackEnv sCallbackEnv(__func__);
- if (!sCallbacksObj) {
- ALOGE("%s: sCallbacksObj is null", __func__);
- return;
- }
- if (!sCallbackEnv.valid()) return;
- ScopedLocalRef<jbyteArray> addr(
- sCallbackEnv.get(), sCallbackEnv->NewByteArray(sizeof(RawAddress)));
- if (!addr.get()) {
- ALOGE("%s: Failed to allocate a new byte array", __func__);
+ ALOGI("%s", __func__);
+ std::shared_lock<std::shared_timed_mutex> lock(sCallbacks_mutex);
+ CallbackEnv sCallbackEnv(__func__);
+ if (!sCallbacksObj) {
+ ALOGE("%s: sCallbacksObj is null", __func__);
return;
- }
+ }
+ if (!sCallbackEnv.valid()) return;
- sCallbackEnv->SetByteArrayRegion(addr.get(), 0, sizeof(RawAddress),
- (jbyte*)&bd_addr);
- sCallbackEnv->CallVoidMethod(
- sCallbacksObj, method_onAvailablePlayerChanged, addr.get());
+ ScopedLocalRef<jbyteArray> addr(
+ sCallbackEnv.get(), sCallbackEnv->NewByteArray(sizeof(RawAddress)));
+ if (!addr.get()) {
+ ALOGE("%s: Failed to allocate a new byte array", __func__);
+ return;
+ }
+
+ sCallbackEnv->SetByteArrayRegion(addr.get(), 0, sizeof(RawAddress),
+ (jbyte*)&bd_addr);
+ sCallbackEnv->CallVoidMethod(
+ sCallbacksObj, method_onAvailablePlayerChanged, addr.get());
+}
+
+static void btavrcp_get_rcpsm_callback(const RawAddress& bd_addr,
+ uint16_t psm) {
+ ALOGE("%s -> psm received of %d", __func__, psm);
+ std::shared_lock<std::shared_timed_mutex> lock(sCallbacks_mutex);
+ CallbackEnv sCallbackEnv(__func__);
+ if (!sCallbacksObj) {
+ ALOGE("%s: sCallbacksObj is null", __func__);
+ return;
+ }
+ if (!sCallbackEnv.valid()) return;
+
+ ScopedLocalRef<jbyteArray> addr(
+ sCallbackEnv.get(), sCallbackEnv->NewByteArray(sizeof(RawAddress)));
+ if (!addr.get()) {
+ ALOGE("%s: Failed to allocate a new byte array", __func__);
+ return;
+ }
+
+ sCallbackEnv->SetByteArrayRegion(addr.get(), 0, sizeof(RawAddress),
+ (jbyte*)&bd_addr.address);
+ sCallbackEnv->CallVoidMethod(sCallbacksObj, method_getRcPsm, addr.get(),
+ (jint)psm);
}
static btrc_ctrl_callbacks_t sBluetoothAvrcpCallbacks = {
@@ -765,7 +789,8 @@
btavrcp_set_addressed_player_callback,
btavrcp_addressed_player_changed_callback,
btavrcp_now_playing_content_changed_callback,
- btavrcp_available_player_changed_callback};
+ btavrcp_available_player_changed_callback,
+ btavrcp_get_rcpsm_callback};
static void classInitNative(JNIEnv* env, jclass clazz) {
method_handlePassthroughRsp =
@@ -779,6 +804,8 @@
method_getRcFeatures = env->GetMethodID(clazz, "getRcFeatures", "([BI)V");
+ method_getRcPsm = env->GetMethodID(clazz, "getRcPsm", "([BI)V");
+
method_setplayerappsettingrsp =
env->GetMethodID(clazz, "setPlayerAppSettingRsp", "([BB)V");
diff --git a/res/values/config.xml b/res/values/config.xml
index 711993e..376d996 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -72,6 +72,9 @@
<!-- If true, device requests audio focus and start avrcp updates on source start or play -->
<bool name="a2dp_sink_automatically_request_audio_focus">false</bool>
+ <!-- For enabling the AVRCP Controller Cover Artwork feature -->
+ <bool name="avrcp_controller_enable_cover_art">false</bool>
+
<!-- For enabling the hfp client connection service -->
<bool name="hfp_client_connection_service_enabled">false</bool>
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpBipClient.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpBipClient.java
new file mode 100644
index 0000000..c53954b
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpBipClient.java
@@ -0,0 +1,403 @@
+/*
+ * 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.bluetooth.BluetoothSocket;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+
+import com.android.bluetooth.BluetoothObexTransport;
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+
+import javax.obex.ClientSession;
+import javax.obex.HeaderSet;
+import javax.obex.ResponseCodes;
+
+/**
+ * A client to a remote device's BIP Image Pull Server, as defined by a PSM passed in at
+ * construction time.
+ *
+ * Once the client connection is established you can use this client to get image properties and
+ * download images. The connection to the server is held open to service multiple requests.
+ *
+ * Client is good for one connection lifecycle. Please call shutdown() to clean up safely. Once a
+ * disconnection has occurred, please create a new client.
+ */
+public class AvrcpBipClient {
+ private static final String TAG = "AvrcpBipClient";
+ private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+
+ // AVRCP Controller BIP Image Initiator/Cover Art UUID - AVRCP 1.6 Section 5.14.2.1
+ private static final byte[] BLUETOOTH_UUID_AVRCP_COVER_ART = new byte[] {
+ (byte) 0x71,
+ (byte) 0x63,
+ (byte) 0xDD,
+ (byte) 0x54,
+ (byte) 0x4A,
+ (byte) 0x7E,
+ (byte) 0x11,
+ (byte) 0xE2,
+ (byte) 0xB4,
+ (byte) 0x7C,
+ (byte) 0x00,
+ (byte) 0x50,
+ (byte) 0xC2,
+ (byte) 0x49,
+ (byte) 0x00,
+ (byte) 0x48
+ };
+
+ private static final int CONNECT = 0;
+ private static final int DISCONNECT = 1;
+ private static final int REQUEST = 2;
+
+ private final Handler mHandler;
+ private final HandlerThread mThread;
+
+ private final BluetoothDevice mDevice;
+ private final int mPsm;
+ private int mState = BluetoothProfile.STATE_DISCONNECTED;
+
+ private BluetoothSocket mSocket;
+ private BluetoothObexTransport mTransport;
+ private ClientSession mSession;
+
+ private final Callback mCallback;
+
+ /**
+ * Callback object used to be notified of when a request has been completed.
+ */
+ interface Callback {
+
+ /**
+ * Notify of a connection state change in the client
+ *
+ * @param oldState The old state of the client
+ * @param newState The new state of the client
+ */
+ void onConnectionStateChanged(int oldState, int newState);
+
+ /**
+ * Notify of a get image properties completing
+ *
+ * @param status A status code to indicate a success or error
+ * @param properties The BipImageProperties object returned if successful, null otherwise
+ */
+ void onGetImagePropertiesComplete(int status, String imageHandle,
+ BipImageProperties properties);
+
+ /**
+ * Notify of a get image operation completing
+ *
+ * @param status A status code of the request. success or error
+ * @param image The BipImage object returned if successful, null otherwise
+ */
+ void onGetImageComplete(int status, String imageHandle, BipImage image);
+ }
+
+ /**
+ * Creates a BIP image pull client and connects to a remote device's BIP image push server.
+ */
+ public AvrcpBipClient(BluetoothDevice remoteDevice, int psm, Callback callback) {
+ if (remoteDevice == null) {
+ throw new NullPointerException("Remote device is null");
+ }
+ if (callback == null) {
+ throw new NullPointerException("Callback is null");
+ }
+
+ mDevice = remoteDevice;
+ mPsm = psm;
+ mCallback = callback;
+
+ mThread = new HandlerThread("AvrcpBipClient");
+ mThread.start();
+
+ Looper looper = mThread.getLooper();
+
+ mHandler = new AvrcpBipClientHandler(looper, this);
+ mHandler.obtainMessage(CONNECT).sendToTarget();
+ }
+
+ /**
+ * Safely disconnects the client from the server
+ */
+ public void shutdown() {
+ debug("Shutdown client");
+ try {
+ mHandler.obtainMessage(DISCONNECT).sendToTarget();
+ } catch (IllegalStateException e) {
+ // Means we haven't been started or we're already stopped. Doing this makes this call
+ // always safe no matter the state.
+ return;
+ }
+ mThread.quitSafely();
+ }
+
+ /**
+ * Determines if this client is connected to the server
+ *
+ * @return True if connected, False otherwise
+ */
+ public synchronized int getState() {
+ return mState;
+ }
+
+ /**
+ * Determines if this client is connected to the server
+ *
+ * @return True if connected, False otherwise
+ */
+ public boolean isConnected() {
+ return getState() == BluetoothProfile.STATE_CONNECTED;
+ }
+
+ /**
+ * Retrieve the image properties associated with the given imageHandle
+ */
+ public boolean getImageProperties(String imageHandle) {
+ RequestGetImageProperties request = new RequestGetImageProperties(imageHandle);
+ boolean status = mHandler.sendMessage(mHandler.obtainMessage(REQUEST, request));
+ if (!status) {
+ error("Adding messages failed, connection state: " + isConnected());
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Download the image object associated with the given imageHandle
+ */
+ public boolean getImage(String imageHandle, BipImageDescriptor descriptor) {
+ RequestGetImage request = new RequestGetImage(imageHandle, descriptor);
+ boolean status = mHandler.sendMessage(mHandler.obtainMessage(REQUEST, request));
+ if (!status) {
+ error("Adding messages failed, connection state: " + isConnected());
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Update our client's connection state and notify of the new status
+ */
+ private void setConnectionState(int state) {
+ int oldState = -1;
+ synchronized (this) {
+ oldState = mState;
+ mState = state;
+ }
+ if (oldState != state) {
+ mCallback.onConnectionStateChanged(oldState, mState);
+ }
+ }
+
+ /**
+ * Connects to the remote device's BIP Image Pull server
+ */
+ private synchronized void connect() {
+ debug("Connect using psm: " + mPsm);
+ if (isConnected()) {
+ warn("Already connected");
+ return;
+ }
+
+ try {
+ setConnectionState(BluetoothProfile.STATE_CONNECTING);
+
+ mSocket = mDevice.createL2capSocket(mPsm);
+ mSocket.connect();
+
+ mTransport = new BluetoothObexTransport(mSocket);
+ mSession = new ClientSession(mTransport);
+
+ HeaderSet headerSet = new HeaderSet();
+ headerSet.setHeader(HeaderSet.TARGET, BLUETOOTH_UUID_AVRCP_COVER_ART);
+
+ headerSet = mSession.connect(headerSet);
+ int responseCode = headerSet.getResponseCode();
+ if (responseCode == ResponseCodes.OBEX_HTTP_OK) {
+ setConnectionState(BluetoothProfile.STATE_CONNECTED);
+ } else {
+ error("Error connecting, code: " + responseCode);
+ disconnect();
+ }
+ debug("Connection established");
+
+ } catch (IOException e) {
+ error("Exception while connecting to AVRCP BIP server", e);
+ disconnect();
+ }
+ }
+
+ /**
+ * Permanently disconnects this client from the remote device's BIP server and notifies of the
+ * new connection status.
+ *
+ */
+ private synchronized void disconnect() {
+ if (mSession != null) {
+ setConnectionState(BluetoothProfile.STATE_DISCONNECTING);
+
+ try {
+ mSession.disconnect(null);
+ } catch (IOException e) {
+ error("Exception while disconnecting from AVRCP BIP server: " + e.toString());
+ }
+
+ try {
+ mSession.close();
+ } catch (IOException e) {
+ error("Exception while closing AVRCP BIP session: " + e.toString());
+ }
+
+ mSession = null;
+ }
+ setConnectionState(BluetoothProfile.STATE_DISCONNECTED);
+ }
+
+ private void executeRequest(BipRequest request) {
+ if (!isConnected()) {
+ error("Cannot execute request " + request.toString()
+ + ", we're not connected");
+ notifyCaller(request);
+ return;
+ }
+
+ try {
+ request.execute(mSession);
+ notifyCaller(request);
+ debug("Completed request - " + request.toString());
+ } catch (IOException e) {
+ error("Request failed: " + request.toString());
+ notifyCaller(request);
+ disconnect();
+ }
+ }
+
+ private void notifyCaller(BipRequest request) {
+ int type = request.getType();
+ int responseCode = request.getResponseCode();
+ String imageHandle = null;
+
+ debug("Notifying caller of request complete - " + request.toString());
+ switch (type) {
+ case BipRequest.TYPE_GET_IMAGE_PROPERTIES:
+ imageHandle = ((RequestGetImageProperties) request).getImageHandle();
+ BipImageProperties properties =
+ ((RequestGetImageProperties) request).getImageProperties();
+ mCallback.onGetImagePropertiesComplete(responseCode, imageHandle, properties);
+ break;
+ case BipRequest.TYPE_GET_IMAGE:
+ imageHandle = ((RequestGetImage) request).getImageHandle();
+ BipImage image = ((RequestGetImage) request).getImage();
+ mCallback.onGetImageComplete(responseCode, imageHandle, image); // TODO: add handle
+ break;
+ }
+ }
+
+ /**
+ * Handles this AVRCP BIP Image Pull Client's requests
+ */
+ private static class AvrcpBipClientHandler extends Handler {
+ WeakReference<AvrcpBipClient> mInst;
+
+ AvrcpBipClientHandler(Looper looper, AvrcpBipClient inst) {
+ super(looper);
+ mInst = new WeakReference<>(inst);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ AvrcpBipClient inst = mInst.get();
+ switch (msg.what) {
+ case CONNECT:
+ if (!inst.isConnected()) {
+ inst.connect();
+ }
+ break;
+
+ case DISCONNECT:
+ if (inst.isConnected()) {
+ inst.disconnect();
+ }
+ break;
+
+ case REQUEST:
+ if (inst.isConnected()) {
+ inst.executeRequest((BipRequest) msg.obj);
+ }
+ break;
+ }
+ }
+ }
+
+ private String getStateName() {
+ int state = getState();
+ switch (state) {
+ case BluetoothProfile.STATE_DISCONNECTED:
+ return "Disconnected";
+ case BluetoothProfile.STATE_CONNECTING:
+ return "Connecting";
+ case BluetoothProfile.STATE_CONNECTED:
+ return "Connected";
+ case BluetoothProfile.STATE_DISCONNECTING:
+ return "Disconnecting";
+ }
+ return "Unknown";
+ }
+
+ @Override
+ public String toString() {
+ return "<AvrcpBipClient" + " device=" + mDevice.getAddress() + " psm=" + mPsm
+ + " state=" + getStateName() + ">";
+ }
+
+ /**
+ * Print to debug if debug is enabled for this class
+ */
+ private void debug(String msg) {
+ if (DBG) {
+ Log.d(TAG, "[" + mDevice.getAddress() + "] " + msg);
+ }
+ }
+
+ /**
+ * Print to warn
+ */
+ private void warn(String msg) {
+ Log.w(TAG, "[" + mDevice.getAddress() + "] " + msg);
+ }
+
+ /**
+ * Print to error
+ */
+ private void error(String msg) {
+ Log.e(TAG, "[" + mDevice.getAddress() + "] " + msg);
+ }
+
+ private void error(String msg, Throwable e) {
+ Log.e(TAG, "[" + mDevice.getAddress() + "] " + msg, e);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java
index 059feeb..a58e4d6 100755
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java
@@ -26,6 +26,7 @@
import android.support.v4.media.session.PlaybackStateCompat;
import android.util.Log;
+import com.android.bluetooth.R;
import com.android.bluetooth.Utils;
import com.android.bluetooth.btservice.ProfileService;
@@ -94,6 +95,28 @@
protected Map<BluetoothDevice, AvrcpControllerStateMachine> mDeviceStateMap =
new ConcurrentHashMap<>(1);
+ private boolean mCoverArtEnabled;
+ protected AvrcpCoverArtManager mCoverArtManager;
+
+ private class ImageDownloadCallback implements AvrcpCoverArtManager.Callback {
+ @Override
+ public void onImageDownloadComplete(BluetoothDevice device,
+ AvrcpCoverArtManager.DownloadEvent event) {
+ if (DBG) {
+ Log.d(TAG, "Image downloaded [device: " + device + ", handle: " + event.getHandle()
+ + ", uri: " + event.getUri());
+ }
+ AvrcpControllerStateMachine stateMachine = getStateMachine(device);
+ if (stateMachine == null) {
+ Log.e(TAG, "No state machine found for device " + device);
+ mCoverArtManager.removeImage(device, event.getHandle());
+ return;
+ }
+ stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_IMAGE_DOWNLOADED,
+ event);
+ }
+ }
+
static {
classInitNative();
}
@@ -106,6 +129,10 @@
@Override
protected boolean start() {
initNative();
+ mCoverArtEnabled = getResources().getBoolean(R.bool.avrcp_controller_enable_cover_art);
+ if (mCoverArtEnabled) {
+ mCoverArtManager = new AvrcpCoverArtManager(this, new ImageDownloadCallback());
+ }
sBrowseTree = new BrowseTree(null);
sService = this;
@@ -125,6 +152,8 @@
sService = null;
sBrowseTree = null;
+ mCoverArtManager.cleanup();
+ mCoverArtManager = null;
return true;
}
@@ -324,6 +353,17 @@
/* Do Nothing. */
}
+ // Called by JNI to notify Avrcp of a remote device's Cover Art PSM
+ private void getRcPsm(byte[] address, int psm) {
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
+ if (DBG) Log.d(TAG, "getRcPsm(device=" + device + ", psm=" + psm + ")");
+ AvrcpControllerStateMachine stateMachine = getOrCreateStateMachine(device);
+ if (stateMachine != null) {
+ stateMachine.sendMessage(
+ AvrcpControllerStateMachine.MESSAGE_PROCESS_RECEIVED_COVER_ART_PSM, psm);
+ }
+ }
+
// Called by JNI
private void setPlayerAppSettingRsp(byte[] address, byte accepted) {
/* Do Nothing. */
@@ -371,7 +411,6 @@
aib.setItemType(AvrcpItem.TYPE_MEDIA);
aib.setUuid(UUID.randomUUID().toString());
AvrcpItem item = aib.build();
-
stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_TRACK_CHANGED,
item);
}
@@ -492,7 +531,6 @@
}
}
-
void handleGetPlayerItemsRsp(byte[] address, AvrcpPlayer[] items) {
if (DBG) {
Log.d(TAG, "handleGetFolderItemsRsp called with " + items.length + " items.");
@@ -529,7 +567,6 @@
aib.setUuid(UUID.randomUUID().toString());
aib.setPlayable(true);
AvrcpItem item = aib.build();
-
return item;
}
@@ -689,6 +726,10 @@
return stateMachine;
}
+ protected AvrcpCoverArtManager getCoverArtManager() {
+ return mCoverArtManager;
+ }
+
List<BluetoothDevice> getDevicesMatchingConnectionStates(int[] states) {
if (DBG) Log.d(TAG, "getDevicesMatchingConnectionStates" + Arrays.toString(states));
List<BluetoothDevice> deviceList = new ArrayList<>();
@@ -725,6 +766,11 @@
stateMachine.dump(sb);
}
sb.append("\n sBrowseTree: " + sBrowseTree.toString());
+
+ sb.append("\n Cover Artwork Enabled: " + (mCoverArtEnabled ? "True" : "False"));
+ if (mCoverArtManager != null) {
+ sb.append("\n " + mCoverArtManager.toString());
+ }
}
/*JNI*/
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
index a133d72..df92ad7 100755
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
@@ -22,6 +22,7 @@
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
+import android.net.Uri;
import android.os.Bundle;
import android.os.Message;
import android.support.v4.media.session.MediaSessionCompat;
@@ -57,6 +58,7 @@
//100->199 Internal Events
protected static final int CLEANUP = 100;
private static final int CONNECT_TIMEOUT = 101;
+ static final int MESSAGE_INTERNAL_ABS_VOL_TIMEOUT = 102;
//200->299 Events from Native
static final int STACK_EVENT = 200;
@@ -79,6 +81,7 @@
static final int MESSAGE_PROCESS_SUPPORTED_APPLICATION_SETTINGS = 217;
static final int MESSAGE_PROCESS_CURRENT_APPLICATION_SETTINGS = 218;
static final int MESSAGE_PROCESS_AVAILABLE_PLAYER_CHANGED = 219;
+ static final int MESSAGE_PROCESS_RECEIVED_COVER_ART_PSM = 220;
//300->399 Events for Browsing
static final int MESSAGE_GET_FOLDER_ITEMS = 300;
@@ -87,7 +90,8 @@
static final int MSG_AVRCP_SET_SHUFFLE = 303;
static final int MSG_AVRCP_SET_REPEAT = 304;
- static final int MESSAGE_INTERNAL_ABS_VOL_TIMEOUT = 404;
+ //400->499 Events for Cover Artwork
+ static final int MESSAGE_PROCESS_IMAGE_DOWNLOADED = 400;
/*
* Base value for absolute volume from JNI
@@ -107,6 +111,8 @@
protected final BluetoothDevice mDevice;
protected final byte[] mDeviceAddress;
protected final AvrcpControllerService mService;
+ protected int mCoverArtPsm;
+ protected final AvrcpCoverArtManager mCoverArtManager;
protected final Disconnected mDisconnected;
protected final Connecting mConnecting;
protected final Connected mConnected;
@@ -135,6 +141,8 @@
mDevice = device;
mDeviceAddress = Utils.getByteAddress(mDevice);
mService = service;
+ mCoverArtPsm = 0;
+ mCoverArtManager = service.getCoverArtManager();
logD(device.toString());
mBrowseTree = new BrowseTree(mDevice);
@@ -278,6 +286,22 @@
mBrowsingConnected = false;
}
+ synchronized void connectCoverArt() {
+ // Called from "connected" state, which assumes either control or browse is connected
+ if (mCoverArtManager != null && mCoverArtPsm != 0) {
+ logD("Attempting to connect to AVRCP BIP, psm: " + mCoverArtPsm);
+ mCoverArtManager.connect(mDevice, /* psm */ mCoverArtPsm);
+ }
+ }
+
+ synchronized void disconnectCoverArt() {
+ // Safe to call even if we're not connected
+ if (mCoverArtManager != null) {
+ logD("Disconnect BIP cover artwork");
+ mCoverArtManager.disconnect(mDevice);
+ }
+ }
+
private void notifyChanged(BrowseTree.BrowseNode node) {
BluetoothMediaBrowserService.notifyChanged(node);
}
@@ -315,6 +339,9 @@
@Override
public boolean processMessage(Message message) {
switch (message.what) {
+ case MESSAGE_PROCESS_RECEIVED_COVER_ART_PSM:
+ mCoverArtPsm = message.arg1;
+ break;
case CONNECT:
logD("Connect");
transitionTo(mConnecting);
@@ -348,6 +375,7 @@
BluetoothMediaBrowserService.addressedPlayerChanged(mSessionCallbacks);
BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
broadcastConnectionStateChanged(BluetoothProfile.STATE_CONNECTED);
+ connectCoverArt(); // only works if we have a valid PSM
} else {
logD("ReEnteringConnected");
}
@@ -396,6 +424,7 @@
case MESSAGE_PROCESS_TRACK_CHANGED:
AvrcpItem track = (AvrcpItem) msg.obj;
+ downloadImageIfNeeded(track);
mAddressedPlayer.updateCurrentTrack(track);
if (isActive()) {
BluetoothMediaBrowserService.trackChanged(track);
@@ -458,6 +487,31 @@
processAvailablePlayerChanged();
return true;
+ case MESSAGE_PROCESS_RECEIVED_COVER_ART_PSM:
+ mCoverArtPsm = msg.arg1;
+ connectCoverArt();
+ return true;
+
+ case MESSAGE_PROCESS_IMAGE_DOWNLOADED:
+ AvrcpCoverArtManager.DownloadEvent event =
+ (AvrcpCoverArtManager.DownloadEvent) msg.obj;
+ String handle = event.getHandle();
+ Uri uri = event.getUri();
+ logD("Received image for " + handle + " at " + uri.toString());
+
+ // Let the addressed player know we got an image so it can see if the current
+ // track now has cover artwork
+ boolean addedArtwork = mAddressedPlayer.notifyImageDownload(handle, uri);
+ if (addedArtwork) {
+ BluetoothMediaBrowserService.trackChanged(
+ mAddressedPlayer.getCurrentTrack());
+ }
+
+ // Let the browse tree know of the newly downloaded image so it can attach it to
+ // all the items that need it
+ mBrowseTree.notifyImageDownload(handle, uri);
+ return true;
+
case DISCONNECT:
transitionTo(mDisconnecting);
return true;
@@ -581,6 +635,11 @@
logD("GetFolderItems: End " + endIndicator
+ " received " + folderList.size());
+ // Queue up image download if the item has an image and we don't have it yet
+ for (AvrcpItem track : folderList) {
+ downloadImageIfNeeded(track);
+ }
+
// Always update the node so that the user does not wait forever
// for the list to populate.
int newSize = mBrowseNode.addChildren(folderList);
@@ -779,6 +838,7 @@
protected class Disconnecting extends State {
@Override
public void enter() {
+ disconnectCoverArt();
onBrowsingDisconnected();
if (isActive()) {
sActiveDevice = null;
@@ -846,6 +906,20 @@
return newIndex;
}
+ private void downloadImageIfNeeded(AvrcpItem track) {
+ if (mCoverArtManager == null) return;
+ String handle = track.getCoverArtHandle();
+ Uri imageUri = null;
+ if (handle != null) {
+ imageUri = mCoverArtManager.getImageUri(mDevice, handle);
+ if (imageUri != null) {
+ track.setCoverArtLocation(imageUri);
+ } else {
+ mCoverArtManager.downloadImage(mDevice, handle);
+ }
+ }
+ }
+
MediaSessionCompat.Callback mSessionCallbacks = new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtManager.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtManager.java
new file mode 100644
index 0000000..72efdfb
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtManager.java
@@ -0,0 +1,320 @@
+/*
+ * 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.content.Context;
+import android.net.Uri;
+import android.util.Log;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.obex.ResponseCodes;
+
+/**
+ * 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);
+
+ private final Context mContext;
+ protected final Map<BluetoothDevice, AvrcpBipClient> mClients = new ConcurrentHashMap<>(1);
+ private final AvrcpCoverArtStorage mCoverArtStorage;
+ private final Callback mCallback;
+
+ /**
+ * An object representing an image download event. Contains the information necessary to
+ * retrieve the image from storage.
+ */
+ public class DownloadEvent {
+ final String mImageHandle;
+ final Uri mUri;
+ public DownloadEvent(String handle, Uri uri) {
+ mImageHandle = handle;
+ mUri = uri;
+ }
+ public String getHandle() {
+ return mImageHandle;
+ }
+ public Uri getUri() {
+ return mUri;
+ }
+ }
+
+ interface Callback {
+ /**
+ * Notify of a get image download completing
+ *
+ * @param device The device the image handle belongs to
+ * @param imageHandle The handle of the requested image
+ * @param uri The Uri that the image is available at in storage
+ */
+ void onImageDownloadComplete(BluetoothDevice device, DownloadEvent event);
+ }
+
+ public AvrcpCoverArtManager(Context context, Callback callback) {
+ mContext = context;
+ mCoverArtStorage = new AvrcpCoverArtStorage(mContext);
+ mCallback = callback;
+ }
+
+ /**
+ * 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.getAddress() + ", psm: " + psm);
+ if (mClients.containsKey(device)) return false;
+ AvrcpBipClient client = new AvrcpBipClient(device, psm, new BipClientCallback(device));
+ mClients.put(device, client);
+ return true;
+ }
+
+ /**
+ * Disconnect from a remote device's BIP Image Pull Server
+ *
+ * @param device The remote Bluetooth device you wish to connect to
+ * @return True if the connection is successfully queued, False otherwise.
+ */
+ public synchronized boolean disconnect(BluetoothDevice device) {
+ debug("Disconnect " + device.getAddress());
+ if (!mClients.containsKey(device)) {
+ warn("No client for " + device.getAddress());
+ return false;
+ }
+ AvrcpBipClient client = getClient(device);
+ client.shutdown();
+ mClients.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 = mClients.get(device);
+ if (client == null) return BluetoothProfile.STATE_DISCONNECTED;
+ return client.getState();
+ }
+
+ /**
+ * 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 imageHandle The handle 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 imageHandle) {
+ if (mCoverArtStorage.doesImageExist(device, imageHandle)) {
+ return AvrcpCoverArtProvider.getImageUri(device, imageHandle);
+ }
+ 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 imageHandle The handle associated with the image you wish to download
+ * @return A Uri that will be assign to the image once the download is complete
+ */
+ public Uri downloadImage(BluetoothDevice device, String imageHandle) {
+ debug("Download Image - device: " + device.getAddress() + ", Handle: " + imageHandle);
+ 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, imageHandle)) {
+ debug("Image is already downloaded");
+ return AvrcpCoverArtProvider.getImageUri(device, imageHandle);
+ }
+
+ // 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.
+ 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, imageHandle);
+ }
+
+ /**
+ * Remote a specific downloaded image if it exists
+ *
+ * @param device The remote Bluetooth device associated with the image
+ * @param imageHandle The handle associated with the image you wish to remove
+ */
+ public void removeImage(BluetoothDevice device, String imageHandle) {
+ mCoverArtStorage.removeImage(device, imageHandle);
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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) {
+ // AVRCP 1.6.2 defined "thumbnail" size is guaranteed so we'll do that for now
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(BipEncoding.JPEG);
+ builder.setFixedDimensions(200, 200);
+ 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.getAddress() + ": " + oldState + " -> " + newState);
+ if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ disconnect(mDevice);
+ }
+ }
+
+ @Override
+ public void onGetImagePropertiesComplete(int status, String imageHandle,
+ BipImageProperties properties) {
+ if (status != ResponseCodes.OBEX_HTTP_OK || properties == null) {
+ warn(mDevice.getAddress() + ": GetImageProperties() failed - Handle: " + imageHandle
+ + ", Code: " + status);
+ return;
+ }
+ BipImageDescriptor descriptor = determineImageDescriptor(properties);
+ debug(mDevice.getAddress() + ": Download image - handle='" + imageHandle + "'");
+
+ AvrcpBipClient client = getClient(mDevice);
+ if (client == null) {
+ warn(mDevice.getAddress() + ": 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.getAddress() + ": GetImage() failed - Handle: " + imageHandle
+ + ", Code: " + status);
+ return;
+ }
+ debug(mDevice.getAddress() + ": Received image data for handle: " + imageHandle
+ + ", image: " + image);
+ Uri uri = mCoverArtStorage.addImage(mDevice, imageHandle, image.getImage());
+ if (uri == null) {
+ error("Could not store downloaded image");
+ return;
+ }
+ DownloadEvent event = new DownloadEvent(imageHandle, uri);
+ if (mCallback != null) mCallback.onImageDownloadComplete(mDevice, event);
+ }
+ }
+
+ @Override
+ public String toString() {
+ String s = "CoverArtManager:\n";
+ for (BluetoothDevice device : mClients.keySet()) {
+ AvrcpBipClient client = getClient(device);
+ s += " " + client.toString() + "\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);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtProvider.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtProvider.java
new file mode 100644
index 0000000..24f93f9
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtProvider.java
@@ -0,0 +1,145 @@
+/*
+ * 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.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+
+/**
+ * A provider of downloaded cover art images.
+ *
+ * Cover art images are downloaded from remote devices and are promised to be "good" for the life of
+ * a connection.
+ *
+ * Android applications are provided a Uri with their MediaMetadata and MediaItem objects that
+ * points back to this provider. Uris are in the following format:
+ *
+ * content://com.android.bluetooth.avrcpcontroller.AvrcpCoverArtProvider/<device>/<image-handle>
+ *
+ * It's expected by the Media framework that artwork at URIs will be available using the
+ * ContentResolver#openInputStream and BitmapFactory#decodeStream functions. Our provider must
+ * enable that usage pattern.
+ */
+public class AvrcpCoverArtProvider extends ContentProvider {
+ private static final String TAG = "AvrcpCoverArtProvider";
+ private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+
+ private BluetoothAdapter mAdapter;
+ private AvrcpCoverArtStorage mStorage;
+
+ public AvrcpCoverArtProvider() {
+ }
+
+ static final String AUTHORITY = "com.android.bluetooth.avrcpcontroller.AvrcpCoverArtProvider";
+ static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY);
+
+ /**
+ * Get the Uri for a cover art image based on the device and image handle
+ *
+ * @param device The Bluetooth device from which an image originated
+ * @param imageHandle The provided handle of the cover artwork
+ * @return The Uri this provider will store the downloaded image at
+ */
+ public static Uri getImageUri(BluetoothDevice device, String imageHandle) {
+ if (device == null || imageHandle == null || "".equals(imageHandle)) return null;
+ Uri uri = CONTENT_URI.buildUpon().appendQueryParameter("device", device.getAddress())
+ .appendQueryParameter("handle", imageHandle)
+ .build();
+ debug("getImageUri -> " + uri.toString());
+ return uri;
+ }
+
+ private ParcelFileDescriptor getImageDescriptor(BluetoothDevice device, String imageHandle)
+ throws FileNotFoundException {
+ debug("getImageDescriptor(" + device + ", " + imageHandle + ")");
+ File file = mStorage.getImageFile(device, imageHandle);
+ if (file == null) throw new FileNotFoundException();
+ ParcelFileDescriptor pdf = ParcelFileDescriptor.open(file,
+ ParcelFileDescriptor.MODE_READ_ONLY);
+ return pdf;
+ }
+
+ @Override
+ public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+ debug("openFile(" + uri + ", '" + mode + "')");
+ String address = null;
+ String imageHandle = null;
+ BluetoothDevice device = null;
+ try {
+ address = uri.getQueryParameter("device");
+ imageHandle = uri.getQueryParameter("handle");
+ } catch (NullPointerException e) {
+ throw new FileNotFoundException();
+ }
+
+ try {
+ device = mAdapter.getRemoteDevice(address);
+ } catch (IllegalArgumentException e) {
+ throw new FileNotFoundException();
+ }
+
+ return getImageDescriptor(device, imageHandle);
+ }
+
+ @Override
+ public boolean onCreate() {
+ mAdapter = BluetoothAdapter.getDefaultAdapter();
+ mStorage = new AvrcpCoverArtStorage(getContext());
+ return true;
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
+ String sortOrder) {
+ return null;
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ return null;
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ return null;
+ }
+
+ private static void debug(String msg) {
+ if (DBG) {
+ Log.d(TAG, msg);
+ }
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtStorage.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtStorage.java
new file mode 100644
index 0000000..43ffdb4
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtStorage.java
@@ -0,0 +1,207 @@
+/*
+ * 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.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+/**
+ * An abstraction of the file system storage of the downloaded cover art images.
+ */
+public class AvrcpCoverArtStorage {
+ private static final String TAG = "AvrcpCoverArtStorage";
+ private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+
+ private final Context mContext;
+
+ /**
+ * Create and initialize this Cover Art storage interface
+ */
+ public AvrcpCoverArtStorage(Context context) {
+ mContext = context;
+ }
+
+ /**
+ * Determine if an image already exists in storage
+ *
+ * @param device - The device the images was downloaded from
+ * @param imageHandle - The handle that identifies the image
+ */
+ public boolean doesImageExist(BluetoothDevice device, String imageHandle) {
+ if (device == null || imageHandle == null || "".equals(imageHandle)) return false;
+ String path = getImagePath(device, imageHandle);
+ if (path == null) return false;
+ File file = new File(path);
+ return file.exists();
+ }
+
+ /**
+ * Retrieve an image file from storage
+ *
+ * @param device - The device the images was downloaded from
+ * @param imageHandle - The handle that identifies the image
+ * @return A file descriptor for the image
+ */
+ public File getImageFile(BluetoothDevice device, String imageHandle) {
+ if (device == null || imageHandle == null || "".equals(imageHandle)) return null;
+ String path = getImagePath(device, imageHandle);
+ if (path == null) return null;
+ File file = new File(path);
+ return file.exists() ? file : null;
+ }
+
+ /**
+ * Add an image to storage
+ *
+ * @param device - The device the images was downloaded from
+ * @param imageHandle - The handle that identifies the image
+ * @param image - The image
+ */
+ public Uri addImage(BluetoothDevice device, String imageHandle, Bitmap image) {
+ debug("Storing image '" + imageHandle + "' from device " + device);
+ if (device == null || imageHandle == null || "".equals(imageHandle) || image == null) {
+ debug("Cannot store image. Improper aruguments");
+ return null;
+ }
+
+ String path = getImagePath(device, imageHandle);
+ if (path == null) {
+ debug("Cannot store image. Cannot provide a valid path to storage");
+ return null;
+ }
+
+ try {
+ File deviceDirectory = new File(getDevicePath(device));
+ if (!deviceDirectory.exists()) {
+ deviceDirectory.mkdirs();
+ }
+
+ FileOutputStream outputStream = new FileOutputStream(path);
+ image.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
+ outputStream.flush();
+ outputStream.close();
+ } catch (IOException e) {
+ error("Failed to store '" + imageHandle + "' to '" + path + "'");
+ return null;
+ }
+ Uri uri = AvrcpCoverArtProvider.getImageUri(device, imageHandle);
+ mContext.getContentResolver().notifyChange(uri, null);
+ debug("Image stored at '" + path + "'");
+ return uri;
+ }
+
+ /**
+ * Remove a specific image
+ *
+ * @param device The device you wish to have images removed for
+ * @param imageHandle The handle that identifies the image to delete
+ */
+ public void removeImage(BluetoothDevice device, String imageHandle) {
+ debug("Removing image '" + imageHandle + "' from device " + device);
+ if (device == null || imageHandle == null || "".equals(imageHandle)) return;
+ String path = getImagePath(device, imageHandle);
+ File file = new File(path);
+ if (!file.exists()) return;
+ file.delete();
+ debug("Image deleted at '" + path + "'");
+ }
+
+ /**
+ * Remove all stored images associated with a device
+ *
+ * @param device The device you wish to have images removed for
+ */
+ public void removeImagesForDevice(BluetoothDevice device) {
+ if (device == null) return;
+ debug("Remove cover art for device " + device.getAddress());
+ File deviceDirectory = new File(getDevicePath(device));
+ File[] files = deviceDirectory.listFiles();
+ if (files == null) {
+ debug("No cover art files to delete");
+ return;
+ }
+ for (int i = 0; i < files.length; i++) {
+ debug("Deleted " + files[i].getAbsolutePath());
+ files[i].delete();
+ }
+ deviceDirectory.delete();
+ }
+
+ private String getStorageDirectory() {
+ String dir = null;
+ if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
+ dir = mContext.getExternalFilesDir(null).getAbsolutePath() + "/coverart";
+ } else {
+ error("Cannot get storage directory, state=" + Environment.getExternalStorageState());
+ }
+ return dir;
+ }
+
+ private String getDevicePath(BluetoothDevice device) {
+ String storageDir = getStorageDirectory();
+ if (storageDir == null) return null;
+ return storageDir + "/" + device.getAddress().replace(":", "");
+ }
+
+ private String getImagePath(BluetoothDevice device, String imageHandle) {
+ String deviceDir = getDevicePath(device);
+ if (deviceDir == null) return null;
+ return deviceDir + "/" + imageHandle + ".png";
+ }
+
+ @Override
+ public String toString() {
+ String s = "CoverArtStorage:\n";
+ String storageDirectory = getStorageDirectory();
+ s += " Storage Directory: " + storageDirectory + "\n";
+ if (storageDirectory == null) {
+ return s;
+ }
+
+ File storage = new File(storageDirectory);
+ File[] devices = storage.listFiles();
+ if (devices != null) {
+ for (File deviceDirectory : devices) {
+ s += " " + deviceDirectory.getName() + ":\n";
+ File[] images = deviceDirectory.listFiles();
+ if (images == null) continue;
+ for (File image : images) {
+ s += " " + image.getName() + "\n";
+ }
+ }
+ }
+ return s;
+ }
+
+ private void debug(String msg) {
+ if (DBG) {
+ Log.d(TAG, msg);
+ }
+ }
+
+ private void error(String msg) {
+ Log.e(TAG, msg);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpItem.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpItem.java
index 3864394..1ddcfda 100644
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpItem.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpItem.java
@@ -39,7 +39,8 @@
public static final int TYPE_FOLDER = 0x2;
public static final int TYPE_MEDIA = 0x3;
- // AVRCP Specification defined folder item sub types
+ // AVRCP Specification defined folder item sub types. These match with the Media Framework's
+ // definition of the constants as well.
public static final int FOLDER_MIXED = 0x00;
public static final int FOLDER_TITLES = 0x01;
public static final int FOLDER_ALBUMS = 0x02;
@@ -59,6 +60,8 @@
private int mItemType;
// Sub type of item, dependant on whether it's a folder or media item
+ // Folder -> FOLDER_* constants
+ // Media -> MEDIA_* constants
private int mType;
// Bluetooth Device this piece of metadata came from
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java
index a693421..ba4beaa 100644
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java
@@ -17,6 +17,7 @@
package com.android.bluetooth.avrcpcontroller;
import android.bluetooth.BluetoothDevice;
+import android.net.Uri;
import android.os.SystemClock;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
@@ -184,6 +185,15 @@
mCurrentTrack = update;
}
+ public synchronized boolean notifyImageDownload(String handle, Uri imageUri) {
+ if (DBG) Log.d(TAG, "Got an image download -- handle=" + handle + ", uri=" + imageUri);
+ if (mCurrentTrack != null && mCurrentTrack.getCoverArtHandle() == handle) {
+ mCurrentTrack.setCoverArtLocation(imageUri);
+ return true;
+ }
+ return false;
+ }
+
public synchronized AvrcpItem getCurrentTrack() {
return mCurrentTrack;
}
diff --git a/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java b/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java
index 0af07d2..c9737e3 100644
--- a/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java
+++ b/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java
@@ -17,6 +17,7 @@
package com.android.bluetooth.avrcpcontroller;
import android.bluetooth.BluetoothDevice;
+import android.net.Uri;
import android.support.v4.media.MediaBrowserCompat.MediaItem;
import android.util.Log;
@@ -61,6 +62,10 @@
final BrowseNode mNavigateUpNode;
final BrowseNode mNowPlayingNode;
+ // In support of Cover Artwork, Cover Art URI <-> List of UUIDs using that artwork
+ private final HashMap<String, ArrayList<String>> mCoverArtMap =
+ new HashMap<String, ArrayList<String>>();
+
BrowseTree(BluetoothDevice device) {
if (device == null) {
mRootNode = new BrowseNode(new AvrcpItem.Builder()
@@ -91,6 +96,7 @@
public void clear() {
// Clearing the map should garbage collect everything.
mBrowseMap.clear();
+ mCoverArtMap.clear();
}
void onConnected(BluetoothDevice device) {
@@ -199,6 +205,7 @@
synchronized void removeChild(BrowseNode node) {
mChildren.remove(node);
mBrowseMap.remove(node.getID());
+ indicateCoverArtUnused(node.getID(), node.getCoverArtHandle());
}
synchronized int getChildrenCount() {
@@ -224,6 +231,14 @@
return mItem.getDevice();
}
+ synchronized String getCoverArtHandle() {
+ return mItem.getCoverArtHandle();
+ }
+
+ synchronized void setCoverArtUri(Uri uri) {
+ mItem.setCoverArtLocation(uri);
+ }
+
synchronized List<MediaItem> getContents() {
if (mChildren.size() > 0 || mCached) {
List<MediaItem> contents = new ArrayList<MediaItem>(mChildren.size());
@@ -253,6 +268,7 @@
if (!cached) {
for (BrowseNode child : mChildren) {
mBrowseMap.remove(child.getID());
+ indicateCoverArtUnused(child.getID(), child.getCoverArtHandle());
}
mChildren.clear();
}
@@ -396,11 +412,64 @@
return mCurrentAddressedPlayer;
}
+ /**
+ * Indicate that a node in the tree is using a specific piece of cover art, identified by the
+ * given image handle.
+ */
+ synchronized void indicateCoverArtUsed(String nodeId, String handle) {
+ mCoverArtMap.putIfAbsent(handle, new ArrayList<String>());
+ mCoverArtMap.get(handle).add(nodeId);
+ }
+
+ /**
+ * Indicate that a node in the tree no longer needs a specific piece of cover art.
+ */
+ synchronized void indicateCoverArtUnused(String nodeId, String handle) {
+ if (mCoverArtMap.containsKey(handle) && mCoverArtMap.get(handle).contains(nodeId)) {
+ mCoverArtMap.get(handle).remove(nodeId);
+ if (mCoverArtMap.get(handle).isEmpty()) {
+ mCoverArtMap.remove(handle);
+ }
+ }
+ }
+
+ /**
+ * Get a list of items using the piece of cover art identified by the given handle.
+ */
+ synchronized ArrayList<String> getNodesUsingCoverArt(String handle) {
+ if (!mCoverArtMap.containsKey(handle)) return new ArrayList<String>();
+ return (ArrayList<String>) mCoverArtMap.get(handle).clone();
+ }
+
+ /**
+ * Adds the Uri of a newly downloaded image to all tree nodes using that specific handle.
+ */
+ synchronized void notifyImageDownload(String handle, Uri uri) {
+ if (DBG) Log.d(TAG, "Received downloaded image handle to cascade to BrowseNodes using it");
+ ArrayList<String> nodes = getNodesUsingCoverArt(handle);
+ for (String nodeId : nodes) {
+ BrowseNode node = findBrowseNodeByID(nodeId);
+ if (node == null) {
+ Log.e(TAG, "Node was removed without clearing its cover art status");
+ indicateCoverArtUnused(nodeId, handle);
+ continue;
+ }
+ node.setCoverArtUri(uri);
+ indicateCoverArtUsed(nodeId, handle);
+ BluetoothMediaBrowserService.notifyChanged(node);
+ }
+ }
+
+
@Override
public String toString() {
String serialized = "Size: " + mBrowseMap.size();
if (VDBG) {
serialized += mRootNode.toString();
+ serialized += "\n Image handles in use (" + mCoverArtMap.size() + "):";
+ for (String handle : mCoverArtMap.keySet()) {
+ serialized += " " + handle + "\n";
+ }
}
return serialized;
}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipAttachmentFormat.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipAttachmentFormat.java
new file mode 100644
index 0000000..3760cb0
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipAttachmentFormat.java
@@ -0,0 +1,172 @@
+/*
+ * 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.util.Log;
+
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * Represents BIP attachment metadata arriving from a GetImageProperties request.
+ *
+ * Content type is the only spec-required field.
+ *
+ * Examples:
+ * <attachment content-type="text/plain" name="ABCD1234.txt" size="5120"/>
+ * <attachment content-type="audio/basic" name="ABCD1234.wav" size="102400"/>
+ */
+public class BipAttachmentFormat {
+ private static final String TAG = "avrcpcontroller.BipAttachmentFormat";
+
+ /**
+ * MIME content type of the image attachment, i.e. "text/plain"
+ *
+ * This is required by the specification
+ */
+ private final String mContentType;
+
+ /**
+ * MIME character set of the image attachment, i.e. "ISO-8859-1"
+ */
+ private final String mCharset;
+
+ /**
+ * File name of the image attachment
+ *
+ * This is required by the specification
+ */
+ private final String mName;
+
+ /**
+ * Size of the image attachment in bytes
+ */
+ private final int mSize;
+
+ /**
+ * Date the image attachment was created
+ */
+ private final BipDateTime mCreated;
+
+ /**
+ * Date the image attachment was last modified
+ */
+ private final BipDateTime mModified;
+
+ public BipAttachmentFormat(String contentType, String charset, String name, String size,
+ String created, String modified) {
+ if (contentType == null) {
+ throw new ParseException("ContentType is required and must be valid");
+ }
+ if (name == null) {
+ throw new ParseException("Name is required and must be valid");
+ }
+
+ mContentType = contentType;
+ mName = name;
+ mCharset = charset;
+ mSize = parseInt(size);
+
+ BipDateTime bipCreated = null;
+ try {
+ bipCreated = new BipDateTime(created);
+ } catch (ParseException e) {
+ bipCreated = null;
+ }
+ mCreated = bipCreated;
+
+ BipDateTime bipModified = null;
+ try {
+ bipModified = new BipDateTime(modified);
+ } catch (ParseException e) {
+ bipModified = null;
+ }
+ mModified = bipModified;
+ }
+
+ public BipAttachmentFormat(String contentType, String charset, String name, int size,
+ Date created, Date modified) {
+ mContentType = Objects.requireNonNull(contentType, "Content-Type cannot be null");
+ mName = Objects.requireNonNull(name, "Name cannot be null");
+ mCharset = charset;
+ mSize = size;
+ mCreated = created != null ? new BipDateTime(created) : null;
+ mModified = modified != null ? new BipDateTime(modified) : null;
+ }
+
+ private static int parseInt(String s) {
+ if (s == null) return -1;
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ Log.e(TAG, "Invalid number format for '" + s + "'");
+ }
+ return -1;
+ }
+
+ public String getContentType() {
+ return mContentType;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public String getCharset() {
+ return mCharset;
+ }
+
+ public int getSize() {
+ return mSize;
+ }
+
+ public BipDateTime getCreatedDate() {
+ return mCreated;
+ }
+
+ public BipDateTime getModifiedDate() {
+ return mModified;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (!(o instanceof BipAttachmentFormat)) return false;
+
+ BipAttachmentFormat a = (BipAttachmentFormat) o;
+ return a.getContentType() == getContentType()
+ && a.getName() == getName()
+ && a.getCharset() == getCharset()
+ && a.getSize() == getSize()
+ && a.getCreatedDate() == getCreatedDate()
+ && a.getModifiedDate() == getModifiedDate();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("<attachment");
+ sb.append(" content-type=\"" + mContentType + "\"");
+ if (mCharset != null) sb.append(" charset=\"" + mCharset + "\"");
+ sb.append(" name=\"" + mName + "\"");
+ if (mSize > -1) sb.append(" size=\"" + mSize + "\"");
+ if (mCreated != null) sb.append(" created=\"" + mCreated.toString() + "\"");
+ if (mModified != null) sb.append(" modified=\"" + mModified.toString() + "\"");
+ sb.append(" />");
+ return sb.toString();
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipDateTime.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipDateTime.java
new file mode 100644
index 0000000..bd648a4
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipDateTime.java
@@ -0,0 +1,152 @@
+/*
+ * 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 java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.TimeZone;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * An object representing a DateTime sent over the Basic Imaging Profile
+ *
+ * Date-time format is as follows:
+ *
+ * YYYYMMDDTHHMMSSZ, where
+ * Y/M/D/H/M/S - years, months, days, hours, minutes, seconds
+ * T - A delimiter
+ * Z - An optional, but recommended, character indicating the time is in UTC. If UTC
+ * is not used then we're to assume "local timezone" instead.
+ *
+ * Example date-time values:
+ * 20000101T000000Z
+ * 20000101T235959Z
+ * 20000101T000000
+ */
+public class BipDateTime {
+ private static final String TAG = "avrcpcontroller.BipDateTime";
+
+ private Date mDate = null;
+ private boolean mIsUtc = false;
+
+ public BipDateTime(String time) {
+ try {
+ /*
+ * Match groups for the timestamp are numbered as follows:
+ *
+ * YYYY MM DD T HH MM SS Z
+ * ^^^^ ^^ ^^ ^^ ^^ ^^ ^
+ * 1 2 3 4 5 6 7
+ */
+ Pattern p = Pattern.compile("(\\d{4})(\\d{2})(\\d{2})T(\\d{2})(\\d{2})(\\d{2})([Z])?");
+ Matcher m = p.matcher(time);
+
+ if (m.matches()) {
+ /* Default to system default and assume it knows best what our local timezone is */
+ Calendar.Builder builder = new Calendar.Builder();
+
+ /* Throw exceptions when given bad values */
+ builder.setLenient(false);
+
+ /* Note that Calendar months are zero-based in Java framework */
+ builder.setDate(Integer.parseInt(m.group(1)), /* year */
+ Integer.parseInt(m.group(2)) - 1, /* month */
+ Integer.parseInt(m.group(3))); /* day of month */
+
+ /* Note the timestamp doesn't have milliseconds and we're explicitly setting to 0 */
+ builder.setTimeOfDay(Integer.parseInt(m.group(4)), /* hours */
+ Integer.parseInt(m.group(5)), /* minutes */
+ Integer.parseInt(m.group(6)), /* seconds */
+ 0); /* milliseconds */
+
+ /* If the 7th group is matched then we have UTC based timestamp */
+ if (m.group(7) != null) {
+ TimeZone tz = TimeZone.getTimeZone("UTC");
+ tz.setRawOffset(0);
+ builder.setTimeZone(tz);
+ mIsUtc = true;
+ } else {
+ mIsUtc = false;
+ }
+
+ /* Note: Java dates are UTC and any date generated will be offset by the timezone */
+ mDate = builder.build().getTime();
+ return;
+ }
+ } catch (IllegalArgumentException e) {
+ // Let calendar bad values be caught and fall through
+ } catch (NullPointerException e) {
+ // Let null strings while matching fall through
+ }
+
+ // If we reach here then we've failed to parse the input string into a time
+ throw new ParseException("Failed to parse time '" + time + "'");
+ }
+
+ public BipDateTime(Date date) {
+ mDate = Objects.requireNonNull(date, "Date cannot be null");
+ mIsUtc = true; // All Java Date objects store timestamps as UTC
+ }
+
+ public boolean isUtc() {
+ return mIsUtc;
+ }
+
+ public Date getTime() {
+ return mDate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (!(o instanceof BipDateTime)) return false;
+
+ BipDateTime d = (BipDateTime) o;
+ return d.isUtc() == isUtc() && d.getTime() == getTime();
+ }
+
+ @Override
+ public String toString() {
+ Date d = getTime();
+ if (d == null) {
+ return null;
+ }
+
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(d);
+
+ /* Note that months are numbered stating from 0 */
+ if (isUtc()) {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ cal.setTimeZone(utc);
+ return String.format(Locale.US, "%04d%02d%02dT%02d%02d%02dZ", cal.get(Calendar.YEAR),
+ cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DATE),
+ cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE),
+ cal.get(Calendar.SECOND));
+ } else {
+ cal.setTimeZone(TimeZone.getDefault());
+ return String.format(Locale.US, "%04d%02d%02dT%02d%02d%02d", cal.get(Calendar.YEAR),
+ cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DATE),
+ cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE),
+ cal.get(Calendar.SECOND));
+ }
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipEncoding.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipEncoding.java
new file mode 100644
index 0000000..c4c401e
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipEncoding.java
@@ -0,0 +1,194 @@
+/*
+ * 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.util.SparseArray;
+
+import java.util.HashMap;
+
+/**
+ * Represents an encoding method in which a BIP image is available.
+ *
+ * The encodings supported by this profile include:
+ * - JPEG
+ * - GIF
+ * - WBMP
+ * - PNG
+ * - JPEG2000
+ * - BMP
+ * - USR-xxx
+ *
+ * The tag USR-xxx is used to represent proprietary encodings. The tag shall begin with the string
+ * “USR-” but the implementer assigns the characters of the second half of the string. This tag can
+ * be used by a manufacturer to enable its devices to exchange images under a proprietary encoding.
+ *
+ * Example proprietary encoding:
+ *
+ * - USR-NOKIA-FORMAT1
+ */
+public class BipEncoding {
+ public static final int JPEG = 0;
+ public static final int PNG = 1;
+ public static final int BMP = 2;
+ public static final int GIF = 3;
+ public static final int JPEG2000 = 4;
+ public static final int WBMP = 5;
+ public static final int USR_XXX = 6;
+ public static final int UNKNOWN = 7; // i.e 'not assigned' or 'not assigned anything valid'
+
+ private static final HashMap sEncodingNamesToIds = new HashMap<String, Integer>();
+ static {
+ sEncodingNamesToIds.put("JPEG", JPEG);
+ sEncodingNamesToIds.put("GIF", GIF);
+ sEncodingNamesToIds.put("WBMP", WBMP);
+ sEncodingNamesToIds.put("PNG", PNG);
+ sEncodingNamesToIds.put("JPEG2000", JPEG2000);
+ sEncodingNamesToIds.put("BMP", BMP);
+ }
+
+ private static final SparseArray sIdsToEncodingNames = new SparseArray<String>();
+ static {
+ sIdsToEncodingNames.put(JPEG, "JPEG");
+ sIdsToEncodingNames.put(GIF, "GIF");
+ sIdsToEncodingNames.put(WBMP, "WBMP");
+ sIdsToEncodingNames.put(PNG, "PNG");
+ sIdsToEncodingNames.put(JPEG2000, "JPEG2000");
+ sIdsToEncodingNames.put(BMP, "BMP");
+ sIdsToEncodingNames.put(UNKNOWN, "UNKNOWN");
+ }
+
+ /**
+ * The integer ID of the type that this encoding is
+ */
+ private final int mType;
+
+ /**
+ * If an encoding is type USR_XXX then it has an extension that defines the encoding
+ */
+ private final String mProprietaryEncodingId;
+
+ /**
+ * Create an encoding object based on a AVRCP specification defined string of the encoding name
+ *
+ * @param encoding The encoding name
+ */
+ public BipEncoding(String encoding) {
+ if (encoding == null) {
+ throw new ParseException("Encoding input invalid");
+ }
+ encoding = encoding.trim();
+ mType = determineEncoding(encoding.toUpperCase());
+
+ String proprietaryEncodingId = null;
+ if (mType == USR_XXX) {
+ proprietaryEncodingId = encoding.substring(4).toUpperCase();
+ }
+ mProprietaryEncodingId = proprietaryEncodingId;
+
+ // If we don't have a type by now, we've failed to parse the encoding
+ if (mType == UNKNOWN) {
+ throw new ParseException("Failed to determine type of '" + encoding + "'");
+ }
+ }
+
+ /**
+ * Create an encoding object based on one of the constants for the available formats
+ *
+ * @param encoding A constant representing an available encoding
+ * @param proprietaryId A string representing the Id of a propreitary encoding. Only used if the
+ * encoding type is BipEncoding.USR_XXX
+ */
+ public BipEncoding(int encoding, String proprietaryId) {
+ if (encoding < 0 || encoding > USR_XXX) {
+ throw new IllegalArgumentException("Received invalid encoding type '" + encoding + "'");
+ }
+ mType = encoding;
+
+ String proprietaryEncodingId = null;
+ if (mType == USR_XXX) {
+ if (proprietaryId == null) {
+ throw new IllegalArgumentException("Received invalid user defined encoding id '"
+ + proprietaryId + "'");
+ }
+ proprietaryEncodingId = proprietaryId.toUpperCase();
+ }
+ mProprietaryEncodingId = proprietaryEncodingId;
+ }
+
+ public BipEncoding(int encoding) {
+ this(encoding, null);
+ }
+
+ /**
+ * Returns the encoding type
+ *
+ * @return Integer type ID of the encoding
+ */
+ public int getType() {
+ return mType;
+ }
+
+ /**
+ * Returns the ID portion of an encoding if it's a proprietary encoding
+ *
+ * @return String ID of a proprietary encoding, or null if the encoding is not proprietary
+ */
+ public String getProprietaryEncodingId() {
+ return mProprietaryEncodingId;
+ }
+
+ /**
+ * Determines if an encoding is supported by Android's Graphics Framework
+ *
+ * Android's Bitmap/BitmapFactory can handle BMP, GIF, JPEG, PNG, WebP, and HEIF formats.
+ *
+ * @return True if the encoding is supported, False otherwise.
+ */
+ public boolean isAndroidSupported() {
+ return mType == BipEncoding.JPEG || mType == BipEncoding.PNG || mType == BipEncoding.BMP
+ || mType == BipEncoding.GIF;
+ }
+
+ /**
+ * Determine the encoding type based on an input string
+ */
+ private static int determineEncoding(String encoding) {
+ Integer type = (Integer) sEncodingNamesToIds.get(encoding);
+ if (type != null) return type.intValue();
+ if (encoding != null && encoding.length() >= 4 && encoding.substring(0, 4).equals("USR-")) {
+ return USR_XXX;
+ }
+ return UNKNOWN;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (!(o instanceof BipEncoding)) return false;
+
+ BipEncoding e = (BipEncoding) o;
+ return e.getType() == getType()
+ && e.getProprietaryEncodingId() == getProprietaryEncodingId();
+ }
+
+ @Override
+ public String toString() {
+ if (mType == USR_XXX) return "USR-" + mProprietaryEncodingId;
+ String encoding = (String) sIdsToEncodingNames.get(mType);
+ return encoding;
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipImage.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipImage.java
new file mode 100644
index 0000000..cb31f43
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipImage.java
@@ -0,0 +1,57 @@
+/*
+ * 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.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import java.io.InputStream;
+
+/**
+ * An image object sent over BIP.
+ *
+ * The image is sent as bytes in the payload of a GetImage request. The format of those bytes is
+ * determined by the BipImageDescriptor used when making the request.
+ */
+public class BipImage {
+ private final String mImageHandle;
+ private Bitmap mImage = null;
+
+ public BipImage(String imageHandle, InputStream inputStream) {
+ mImageHandle = imageHandle;
+ parse(inputStream);
+ }
+
+ public BipImage(String imageHandle, Bitmap image) {
+ mImageHandle = imageHandle;
+ mImage = image;
+ }
+
+ private void parse(InputStream inputStream) {
+ // BitmapFactory can handle BMP, GIF, JPEG, PNG, WebP, and HEIF formats. Returns null if
+ // the stream couldn't be parsed.
+ mImage = BitmapFactory.decodeStream(inputStream);
+ }
+
+ public String getImageHandle() {
+ return mImageHandle;
+ }
+
+ public Bitmap getImage() {
+ return mImage;
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipImageDescriptor.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipImageDescriptor.java
new file mode 100644
index 0000000..e6ba605
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipImageDescriptor.java
@@ -0,0 +1,329 @@
+/*
+ * 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.util.Log;
+
+import com.android.internal.util.FastXmlSerializer;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.io.UnsupportedEncodingException;
+
+/**
+ * Contains the metadata that describes either (1) the desired size of a image to be downloaded or
+ * (2) the extact size of an image to be uploaded.
+ *
+ * When using this to assert the size of an image to download/pull, it's best to derive this
+ * specific descriptor from any of the available BipImageFormat options returned from a
+ * RequestGetImageProperties. Note that if a BipImageFormat is not of a fixed size type then you
+ * must arrive on a desired fixed size for this descriptor.
+ *
+ * When using this to denote the size of an image when pushing an image for transfer this descriptor
+ * must match the metadata of the image being sent.
+ *
+ * Note, the encoding and pixel values are mandatory by specification. The version number is fixed.
+ * All other values are optional. The transformation field is to have *one* selected option in it.
+ *
+ * Example:
+ * < image-descriptor version=“1.0” >
+ * < image encoding=“JPEG” pixel=“1280*960” size=“500000”/>
+ * < /image-descriptor >
+ */
+public class BipImageDescriptor {
+ private static final String TAG = "avrcpcontroller.BipImageDescriptor";
+ private static final String sVersion = "1.0";
+
+ /**
+ * A Builder for an ImageDescriptor object
+ */
+ public static class Builder {
+ private BipImageDescriptor mImageDescriptor = new BipImageDescriptor();
+ /**
+ * Set the encoding for the descriptor you're building using a BipEncoding object
+ *
+ * @param encoding The encoding you would like to set
+ * @return This object so you can continue building
+ */
+ public Builder setEncoding(BipEncoding encoding) {
+ mImageDescriptor.mEncoding = encoding;
+ return this;
+ }
+
+ /**
+ * Set the encoding for the descriptor you're building using a BipEncoding.* type value
+ *
+ * @param encoding The encoding you would like to set as a BipEncoding.* type value
+ * @return This object so you can continue building
+ */
+ public Builder setEncoding(int encoding) {
+ mImageDescriptor.mEncoding = new BipEncoding(encoding, null);
+ return this;
+ }
+
+ /**
+ * Set the encoding for the descriptor you're building using a BIP defined string name of
+ * the encoding you want
+ *
+ * @param encoding The encoding you would like to set as a BIP spec defined string
+ * @return This object so you can continue building
+ */
+ public Builder setPropietaryEncoding(String encoding) {
+ mImageDescriptor.mEncoding = new BipEncoding(BipEncoding.USR_XXX, encoding);
+ return this;
+ }
+
+ /**
+ * Set the fixed X by Y image dimensions for the descriptor you're building
+ *
+ * @param width The number of pixels in width of the image
+ * @param height The number of pixels in height of the image
+ * @return This object so you can continue building
+ */
+ public Builder setFixedDimensions(int width, int height) {
+ mImageDescriptor.mPixel = BipPixel.createFixed(width, height);
+ return this;
+ }
+
+ /**
+ * Set the transformation used for the descriptor you're building
+ *
+ * @param transformation The BipTransformation.* type value of the used transformation
+ * @return This object so you can continue building
+ */
+ public Builder setTransformation(int transformation) {
+ mImageDescriptor.mTransformation = new BipTransformation(transformation);
+ return this;
+ }
+
+ /**
+ * Set the image file size for the descriptor you're building
+ *
+ * @param size The image size in bytes
+ * @return This object so you can continue building
+ */
+ public Builder setFileSize(int size) {
+ mImageDescriptor.mSize = size;
+ return this;
+ }
+
+ /**
+ * Set the max file size of the image for the descriptor you're building
+ *
+ * @param size The maxe image size in bytes
+ * @return This object so you can continue building
+ */
+ public Builder setMaxFileSize(int size) {
+ mImageDescriptor.mMaxSize = size;
+ return this;
+ }
+
+ /**
+ * Build the object
+ *
+ * @return A BipImageDescriptor object
+ */
+ public BipImageDescriptor build() {
+ return mImageDescriptor;
+ }
+ }
+
+ /**
+ * The version of the image-descriptor XML string
+ */
+ private String mVersion = null;
+
+ /**
+ * The encoding of the image, required by the specification
+ */
+ private BipEncoding mEncoding = null;
+
+ /**
+ * The width and height of the image, required by the specification
+ */
+ private BipPixel mPixel = null;
+
+ /**
+ * The transformation to be applied to the image, *one* of BipTransformation.STRETCH,
+ * BipTransformation.CROP, or BipTransformation.FILL placed into a BipTransformation object
+ */
+ private BipTransformation mTransformation = null;
+
+ /**
+ * The size in bytes of the image
+ */
+ private int mSize = -1;
+
+ /**
+ * The max size in bytes of the image.
+ *
+ * Optional, used only when describing an image to pull
+ */
+ private int mMaxSize = -1;
+
+ private BipImageDescriptor() {
+ mVersion = sVersion;
+ }
+
+ public BipImageDescriptor(InputStream inputStream) {
+ parse(inputStream);
+ }
+
+ private void parse(InputStream inputStream) {
+ try {
+ XmlPullParser xpp = XmlPullParserFactory.newInstance().newPullParser();
+ xpp.setInput(inputStream, "utf-8");
+ int event = xpp.getEventType();
+ while (event != XmlPullParser.END_DOCUMENT) {
+ switch (event) {
+ case XmlPullParser.START_TAG:
+ String tag = xpp.getName();
+ if (tag.equals("image-descriptor")) {
+ mVersion = xpp.getAttributeValue(null, "version");
+ } else if (tag.equals("image")) {
+ mEncoding = new BipEncoding(xpp.getAttributeValue(null, "encoding"));
+ mPixel = new BipPixel(xpp.getAttributeValue(null, "pixel"));
+ mSize = parseInt(xpp.getAttributeValue(null, "size"));
+ mMaxSize = parseInt(xpp.getAttributeValue(null, "maxsize"));
+ mTransformation = new BipTransformation(
+ xpp.getAttributeValue(null, "transformation"));
+ } else {
+ Log.w(TAG, "Unrecognized tag in x-bt/img-Description object: " + tag);
+ }
+ break;
+ case XmlPullParser.END_TAG:
+ break;
+ }
+ event = xpp.next();
+ }
+ return;
+ } catch (XmlPullParserException e) {
+ Log.e(TAG, "XML parser error when parsing XML", e);
+ } catch (IOException e) {
+ Log.e(TAG, "I/O error when parsing XML", e);
+ }
+ throw new ParseException("Failed to parse image-descriptor from stream");
+ }
+
+ private static int parseInt(String s) {
+ if (s == null) return -1;
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ error("Failed to parse '" + s + "'");
+ }
+ return -1;
+ }
+
+ public BipEncoding getEncoding() {
+ return mEncoding;
+ }
+
+ public BipPixel getPixel() {
+ return mPixel;
+ }
+
+ public BipTransformation getTransformation() {
+ return mTransformation;
+ }
+
+ public int getSize() {
+ return mSize;
+ }
+
+ public int getMaxSize() {
+ return mMaxSize;
+ }
+
+ /**
+ * Serialize this object into a byte array ready for transfer overOBEX
+ *
+ * @return A byte array containing this object's info, or null on error.
+ */
+ public byte[] serialize() {
+ String s = toString();
+ try {
+ return s != null ? s.getBytes("UTF-8") : null;
+ } catch (UnsupportedEncodingException e) {
+ return null;
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (!(o instanceof BipImageDescriptor)) return false;
+
+ BipImageDescriptor d = (BipImageDescriptor) o;
+ return d.getEncoding() == getEncoding()
+ && d.getPixel() == getPixel()
+ && d.getTransformation() == getTransformation()
+ && d.getSize() == getSize()
+ && d.getMaxSize() == getMaxSize();
+ }
+
+ @Override
+ public String toString() {
+ if (mEncoding == null || mPixel == null) {
+ error("Missing required fields [ " + (mEncoding == null ? "encoding " : "")
+ + (mPixel == null ? "pixel " : ""));
+ return null;
+ }
+ StringWriter writer = new StringWriter();
+ XmlSerializer xmlMsgElement = new FastXmlSerializer();
+ try {
+ xmlMsgElement.setOutput(writer);
+ xmlMsgElement.startDocument("UTF-8", true);
+ xmlMsgElement.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
+ xmlMsgElement.startTag(null, "image-descriptor");
+ xmlMsgElement.attribute(null, "version", sVersion);
+ xmlMsgElement.startTag(null, "image");
+ xmlMsgElement.attribute(null, "encoding", mEncoding.toString());
+ xmlMsgElement.attribute(null, "pixel", mPixel.toString());
+ if (mSize != -1) {
+ xmlMsgElement.attribute(null, "size", Integer.toString(mSize));
+ }
+ if (mMaxSize != -1) {
+ xmlMsgElement.attribute(null, "maxsize", Integer.toString(mMaxSize));
+ }
+ if (mTransformation != null && mTransformation.supportsAny()) {
+ xmlMsgElement.attribute(null, "transformation", mTransformation.toString());
+ }
+ xmlMsgElement.endTag(null, "image");
+ xmlMsgElement.endTag(null, "image-descriptor");
+ xmlMsgElement.endDocument();
+ return writer.toString();
+ } catch (IllegalArgumentException e) {
+ error(e.toString());
+ } catch (IllegalStateException e) {
+ error(e.toString());
+ } catch (IOException e) {
+ error(e.toString());
+ }
+ return null;
+ }
+
+ private static void error(String msg) {
+ Log.e(TAG, msg);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipImageFormat.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipImageFormat.java
new file mode 100644
index 0000000..43cbef9
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipImageFormat.java
@@ -0,0 +1,228 @@
+/*
+ * 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.util.Log;
+
+import java.util.Objects;
+
+/**
+ * Describes a single native or variant format available for an image, coming from a
+ * BipImageProperties object.
+ *
+ * This is not an object by specification, per say. It abstracts all the various native and variant
+ * formats available in a given set of image properties.
+ *
+ * This BipImageFormat can be used to choose a specific BipImageDescriptor when downloading an image
+ *
+ * Examples:
+ * <native encoding="JPEG" pixel="1280*1024” size="1048576"/>
+ * <variant encoding="JPEG" pixel="640*480"/>
+ * <variant encoding="JPEG" pixel="160*120"/>
+ * <variant encoding="GIF" pixel="80*60-640*480"/>
+ */
+public class BipImageFormat {
+ private static final String TAG = "avrcpcontroller.BipImageFormat";
+
+ public static final int FORMAT_NATIVE = 0;
+ public static final int FORMAT_VARIANT = 1;
+
+ /**
+ * Create a native BipImageFormat from the given string fields
+ */
+ public static BipImageFormat parseNative(String encoding, String pixel, String size) {
+ return new BipImageFormat(BipImageFormat.FORMAT_NATIVE, encoding, pixel, size, null, null);
+ }
+
+ /**
+ * Create a variant BipImageFormat from the given string fields
+ */
+ public static BipImageFormat parseVariant(String encoding, String pixel, String maxSize,
+ String transformation) {
+ return new BipImageFormat(BipImageFormat.FORMAT_VARIANT, encoding, pixel, null, maxSize,
+ transformation);
+ }
+
+ /**
+ * Create a native BipImageFormat from the given parameters
+ */
+ public static BipImageFormat createNative(BipEncoding encoding, BipPixel pixel, int size) {
+ return new BipImageFormat(BipImageFormat.FORMAT_NATIVE, encoding, pixel, size, -1, null);
+ }
+
+ /**
+ * Create a variant BipImageFormat from the given parameters
+ */
+ public static BipImageFormat createVariant(BipEncoding encoding, BipPixel pixel, int maxSize,
+ BipTransformation transformation) {
+ return new BipImageFormat(BipImageFormat.FORMAT_VARIANT, encoding, pixel, -1, maxSize,
+ transformation);
+ }
+
+ /**
+ * The 'flavor' of this image format, from the format constants above.
+ */
+ private final int mFormatType;
+
+ /**
+ * The encoding method in which this image is available, required by the specification
+ */
+ private final BipEncoding mEncoding;
+
+ /**
+ * The pixel size or range of pixel sizes in which the image is available, required by the
+ * specification
+ */
+ private final BipPixel mPixel;
+
+ /**
+ * The list of supported image transformation methods, any of:
+ * - 'stretch' : Image server is capable of stretching the image to fit a space
+ * - 'fill' : Image server is capable of filling the image padding data to fit a space
+ * - 'crop' : Image server is capable of cropping the image down to fit a space
+ *
+ * Used by the variant type only
+ */
+ private final BipTransformation mTransformation;
+
+ /**
+ * Size in bytes of the image.
+ *
+ * Used by the native type only
+ */
+ private final int mSize;
+
+ /**
+ * The estimated maximum size of an image after a transformation is performed.
+ *
+ * Used by the variant type only
+ */
+ private final int mMaxSize;
+
+ private BipImageFormat(int type, BipEncoding encoding, BipPixel pixel, int size, int maxSize,
+ BipTransformation transformation) {
+ mFormatType = type;
+ mEncoding = Objects.requireNonNull(encoding, "Encoding cannot be null");
+ mPixel = Objects.requireNonNull(pixel, "Pixel cannot be null");
+ mTransformation = transformation;
+ mSize = size;
+ mMaxSize = maxSize;
+ }
+
+ private BipImageFormat(int type, String encoding, String pixel, String size, String maxSize,
+ String transformation) {
+ mFormatType = type;
+ mEncoding = new BipEncoding(encoding);
+ mPixel = new BipPixel(pixel);
+ mTransformation = new BipTransformation(transformation);
+ mSize = parseInt(size);
+ mMaxSize = parseInt(maxSize);
+ }
+
+ private static int parseInt(String s) {
+ if (s == null) return -1;
+ try {
+ return Integer.parseInt(s);
+ } catch (NumberFormatException e) {
+ error("Failed to parse '" + s + "'");
+ }
+ return -1;
+ }
+
+ public int getType() {
+ return mFormatType;
+ }
+
+ public BipEncoding getEncoding() {
+ return mEncoding;
+ }
+
+ public BipPixel getPixel() {
+ return mPixel;
+ }
+
+ public BipTransformation getTransformation() {
+ return mTransformation;
+ }
+
+ public int getSize() {
+ return mSize;
+ }
+
+ public int getMaxSize() {
+ return mMaxSize;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (!(o instanceof BipImageFormat)) return false;
+
+ BipImageFormat f = (BipImageFormat) o;
+ return f.getType() == getType()
+ && f.getEncoding() == getEncoding()
+ && f.getPixel() == getPixel()
+ && f.getTransformation() == getTransformation()
+ && f.getSize() == getSize()
+ && f.getMaxSize() == getMaxSize();
+ }
+
+ @Override
+ public String toString() {
+ if (mEncoding == null || mEncoding.getType() == BipEncoding.UNKNOWN || mPixel == null
+ || mPixel.getType() == BipPixel.TYPE_UNKNOWN) {
+ error("Missing required fields [ " + (mEncoding == null ? "encoding " : "")
+ + (mPixel == null ? "pixel " : ""));
+ return null;
+ }
+
+ StringBuilder sb = new StringBuilder();
+ switch (mFormatType) {
+ case FORMAT_NATIVE:
+ sb.append("<native");
+ sb.append(" encoding=\"" + mEncoding.toString() + "\"");
+ sb.append(" pixel=\"" + mPixel.toString() + "\"");
+ if (mSize > -1) {
+ sb.append(" size=\"" + mSize + "\"");
+ }
+ sb.append(" />");
+ return sb.toString();
+ case FORMAT_VARIANT:
+ sb.append("<variant");
+ sb.append(" encoding=\"" + mEncoding.toString() + "\"");
+ sb.append(" pixel=\"" + mPixel.toString() + "\"");
+ if (mTransformation != null && mTransformation.supportsAny()) {
+ sb.append(" transformation=\"" + mTransformation.toString() + "\"");
+ }
+ if (mSize > -1) {
+ sb.append(" size=\"" + mSize + "\"");
+ }
+ if (mMaxSize > -1) {
+ sb.append(" maxsize=\"" + mMaxSize + "\"");
+ }
+ sb.append(" />");
+ return sb.toString();
+ default:
+ error("Unsupported format type '" + mFormatType + "'");
+ }
+ return null;
+ }
+
+ private static void error(String msg) {
+ Log.e(TAG, msg);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipImageProperties.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipImageProperties.java
new file mode 100644
index 0000000..70d935a
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipImageProperties.java
@@ -0,0 +1,379 @@
+/*
+ * 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.util.Log;
+
+import com.android.internal.util.FastXmlSerializer;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.Objects;
+
+/**
+ * Represents the return value of a BIP GetImageProperties request, giving a detailed description of
+ * an image and its available descriptors before download.
+ *
+ * Format is as described by version 1.2.1 of the Basic Image Profile Specification. The
+ * specification describes three types of metadata that can arrive with an image -- native, variant
+ * and attachment. Native describes which native formats a particular image is available in.
+ * Variant describes which other types of encodings/sizes can be created from the native image using
+ * various transformations. Attachments describes other items that can be downloaded that are
+ * associated with the image (text, sounds, etc.)
+ *
+ * Example:
+ * <image-properties version="1.0" handle="123456789">
+ * <native encoding="JPEG" pixel="1280*1024" size="1048576"/>
+ * <variant encoding="JPEG" pixel="640*480" />
+ * <variant encoding="JPEG" pixel="160*120" />
+ * <variant encoding="GIF" pixel="80*60-640*480" transformation="stretch fill crop"/>
+ * <attachment content-type="text/plain" name="ABCD1234.txt" size="5120"/>
+ * <attachment content-type="audio/basic" name="ABCD1234.wav" size="102400"/>
+ * </image-properties>
+ */
+public class BipImageProperties {
+ private static final String TAG = "avrcpcontroller.BipImageProperties";
+ private static final String sVersion = "1.0";
+
+ /**
+ * A Builder for a BipImageProperties object
+ */
+ public static class Builder {
+ private BipImageProperties mProperties = new BipImageProperties();
+ /**
+ * Set the image handle field for the object you're building
+ *
+ * @param handle The image handle you want to add to the object
+ * @return The builder object to keep building on top of
+ */
+ public Builder setImageHandle(String handle) {
+ mProperties.mImageHandle = handle;
+ return this;
+ }
+
+ /**
+ * Set the FriendlyName field for the object you're building
+ *
+ * @param friendlyName The friendly name you want to add to the object
+ * @return The builder object to keep building on top of
+ */
+ public Builder setFriendlyName(String friendlyName) {
+ mProperties.mFriendlyName = friendlyName;
+ return this;
+ }
+
+ /**
+ * Add a native format for the object you're building
+ *
+ * @param format The format you want to add to the object
+ * @return The builder object to keep building on top of
+ */
+ public Builder addNativeFormat(BipImageFormat format) {
+ mProperties.addNativeFormat(format);
+ return this;
+ }
+
+ /**
+ * Add a variant format for the object you're building
+ *
+ * @param format The format you want to add to the object
+ * @return The builder object to keep building on top of
+ */
+ public Builder addVariantFormat(BipImageFormat format) {
+ mProperties.addVariantFormat(format);
+ return this;
+ }
+
+ /**
+ * Add an attachment entry for the object you're building
+ *
+ * @param format The format you want to add to the object
+ * @return The builder object to keep building on top of
+ */
+ public Builder addAttachment(BipAttachmentFormat format) {
+ mProperties.addAttachment(format);
+ return this;
+ }
+
+ /**
+ * Build the object
+ *
+ * @return A BipImageProperties object
+ */
+ public BipImageProperties build() {
+ return mProperties;
+ }
+ }
+
+ /**
+ * The image handle associated with this set of properties.
+ */
+ private String mImageHandle = null;
+
+ /**
+ * The version of the properties object, used to encode and decode.
+ */
+ private String mVersion = null;
+
+ /**
+ * An optional friendly name for the associated image. The specification suggests the file name.
+ */
+ private String mFriendlyName = null;
+
+ /**
+ * The various sets of available formats.
+ */
+ private ArrayList<BipImageFormat> mNativeFormats;
+ private ArrayList<BipImageFormat> mVariantFormats;
+ private ArrayList<BipAttachmentFormat> mAttachments;
+
+ private BipImageProperties() {
+ mVersion = sVersion;
+ mNativeFormats = new ArrayList<BipImageFormat>();
+ mVariantFormats = new ArrayList<BipImageFormat>();
+ mAttachments = new ArrayList<BipAttachmentFormat>();
+ }
+
+ public BipImageProperties(InputStream inputStream) {
+ mNativeFormats = new ArrayList<BipImageFormat>();
+ mVariantFormats = new ArrayList<BipImageFormat>();
+ mAttachments = new ArrayList<BipAttachmentFormat>();
+ parse(inputStream);
+ }
+
+ private void parse(InputStream inputStream) {
+ try {
+ XmlPullParser xpp = XmlPullParserFactory.newInstance().newPullParser();
+ xpp.setInput(inputStream, "utf-8");
+ int event = xpp.getEventType();
+ while (event != XmlPullParser.END_DOCUMENT) {
+ switch (event) {
+ case XmlPullParser.START_TAG:
+ String tag = xpp.getName();
+ if (tag.equals("image-properties")) {
+ mVersion = xpp.getAttributeValue(null, "version");
+ mImageHandle = xpp.getAttributeValue(null, "handle");
+ mFriendlyName = xpp.getAttributeValue(null, "friendly-name");
+ } else if (tag.equals("native")) {
+ String encoding = xpp.getAttributeValue(null, "encoding");
+ String pixel = xpp.getAttributeValue(null, "pixel");
+ String size = xpp.getAttributeValue(null, "size");
+ addNativeFormat(BipImageFormat.parseNative(encoding, pixel, size));
+ } else if (tag.equals("variant")) {
+ String encoding = xpp.getAttributeValue(null, "encoding");
+ String pixel = xpp.getAttributeValue(null, "pixel");
+ String maxSize = xpp.getAttributeValue(null, "maxsize");
+ String trans = xpp.getAttributeValue(null, "transformation");
+ addVariantFormat(
+ BipImageFormat.parseVariant(encoding, pixel, maxSize, trans));
+ } else if (tag.equals("attachment")) {
+ String contentType = xpp.getAttributeValue(null, "content-type");
+ String name = xpp.getAttributeValue(null, "name");
+ String charset = xpp.getAttributeValue(null, "charset");
+ String size = xpp.getAttributeValue(null, "size");
+ String created = xpp.getAttributeValue(null, "created");
+ String modified = xpp.getAttributeValue(null, "modified");
+ addAttachment(
+ new BipAttachmentFormat(contentType, charset, name, size,
+ created, modified));
+ } else {
+ warn("Unrecognized tag in x-bt/img-properties object: " + tag);
+ }
+ break;
+ case XmlPullParser.END_TAG:
+ break;
+ }
+ event = xpp.next();
+ }
+ return;
+ } catch (XmlPullParserException e) {
+ error("XML parser error when parsing XML", e);
+ } catch (IOException e) {
+ error("I/O error when parsing XML", e);
+ }
+ throw new ParseException("Failed to parse image-properties from stream");
+ }
+
+ public String getImageHandle() {
+ return mImageHandle;
+ }
+
+ public String getFriendlyName() {
+ return mFriendlyName;
+ }
+
+ public ArrayList<BipImageFormat> getNativeFormats() {
+ return mNativeFormats;
+ }
+
+ public ArrayList<BipImageFormat> getVariantFormats() {
+ return mVariantFormats;
+ }
+
+ public ArrayList<BipAttachmentFormat> getAttachments() {
+ return mAttachments;
+ }
+
+ private void addNativeFormat(BipImageFormat format) {
+ Objects.requireNonNull(format);
+ if (format.getType() != BipImageFormat.FORMAT_NATIVE) {
+ throw new IllegalArgumentException("Format type '" + format.getType()
+ + "' but expected '" + BipImageFormat.FORMAT_NATIVE + "'");
+ }
+ mNativeFormats.add(format);
+ }
+
+ private void addVariantFormat(BipImageFormat format) {
+ Objects.requireNonNull(format);
+ if (format.getType() != BipImageFormat.FORMAT_VARIANT) {
+ throw new IllegalArgumentException("Format type '" + format.getType()
+ + "' but expected '" + BipImageFormat.FORMAT_VARIANT + "'");
+ }
+ mVariantFormats.add(format);
+ }
+
+ private void addAttachment(BipAttachmentFormat format) {
+ Objects.requireNonNull(format);
+ mAttachments.add(format);
+ }
+
+ @Override
+ public String toString() {
+ StringWriter writer = new StringWriter();
+ XmlSerializer xmlMsgElement = new FastXmlSerializer();
+ try {
+ xmlMsgElement.setOutput(writer);
+ xmlMsgElement.startDocument("UTF-8", true);
+ xmlMsgElement.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
+ xmlMsgElement.startTag(null, "image-properties");
+ xmlMsgElement.attribute(null, "version", mVersion);
+ xmlMsgElement.attribute(null, "handle", mImageHandle);
+
+ for (BipImageFormat format : mNativeFormats) {
+ BipEncoding encoding = format.getEncoding();
+ BipPixel pixel = format.getPixel();
+ int size = format.getSize();
+ if (encoding == null || pixel == null) {
+ error("Native format " + format.toString() + " is invalid.");
+ continue;
+ }
+ xmlMsgElement.startTag(null, "native");
+ xmlMsgElement.attribute(null, "encoding", encoding.toString());
+ xmlMsgElement.attribute(null, "pixel", pixel.toString());
+ if (size >= 0) {
+ xmlMsgElement.attribute(null, "size", Integer.toString(size));
+ }
+ xmlMsgElement.endTag(null, "native");
+ }
+
+ for (BipImageFormat format : mVariantFormats) {
+ BipEncoding encoding = format.getEncoding();
+ BipPixel pixel = format.getPixel();
+ int maxSize = format.getMaxSize();
+ BipTransformation trans = format.getTransformation();
+ if (encoding == null || pixel == null) {
+ error("Variant format " + format.toString() + " is invalid.");
+ continue;
+ }
+ xmlMsgElement.startTag(null, "variant");
+ xmlMsgElement.attribute(null, "encoding", encoding.toString());
+ xmlMsgElement.attribute(null, "pixel", pixel.toString());
+ if (maxSize >= 0) {
+ xmlMsgElement.attribute(null, "maxsize", Integer.toString(maxSize));
+ }
+ if (trans != null && trans.supportsAny()) {
+ xmlMsgElement.attribute(null, "transformation", trans.toString());
+ }
+ xmlMsgElement.endTag(null, "variant");
+ }
+
+ for (BipAttachmentFormat format : mAttachments) {
+ String contentType = format.getContentType();
+ String charset = format.getCharset();
+ String name = format.getName();
+ int size = format.getSize();
+ BipDateTime created = format.getCreatedDate();
+ BipDateTime modified = format.getModifiedDate();
+ if (contentType == null || name == null) {
+ error("Attachment format " + format.toString() + " is invalid.");
+ continue;
+ }
+ xmlMsgElement.startTag(null, "attachment");
+ xmlMsgElement.attribute(null, "content-type", contentType.toString());
+ if (charset != null) {
+ xmlMsgElement.attribute(null, "charset", charset.toString());
+ }
+ xmlMsgElement.attribute(null, "name", name.toString());
+ if (size >= 0) {
+ xmlMsgElement.attribute(null, "size", Integer.toString(size));
+ }
+ if (created != null) {
+ xmlMsgElement.attribute(null, "created", created.toString());
+ }
+ if (modified != null) {
+ xmlMsgElement.attribute(null, "modified", modified.toString());
+ }
+ xmlMsgElement.endTag(null, "attachment");
+ }
+
+ xmlMsgElement.endTag(null, "image-properties");
+ xmlMsgElement.endDocument();
+ return writer.toString();
+ } catch (IllegalArgumentException e) {
+ error("Falied to serialize ImageProperties", e);
+ } catch (IllegalStateException e) {
+ error("Falied to serialize ImageProperties", e);
+ } catch (IOException e) {
+ error("Falied to serialize ImageProperties", e);
+ }
+ return null;
+ }
+
+ /**
+ * Serialize this object into a byte array
+ *
+ * @return Byte array representing this object, ready to send over OBEX, or null on error.
+ */
+ public byte[] serialize() {
+ String s = toString();
+ try {
+ return s != null ? s.getBytes("UTF-8") : null;
+ } catch (UnsupportedEncodingException e) {
+ return null;
+ }
+ }
+
+ private static void warn(String msg) {
+ Log.w(TAG, msg);
+ }
+
+ private static void error(String msg) {
+ Log.e(TAG, msg);
+ }
+
+ private static void error(String msg, Throwable e) {
+ Log.e(TAG, msg, e);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipPixel.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipPixel.java
new file mode 100644
index 0000000..bba0cef
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipPixel.java
@@ -0,0 +1,243 @@
+/*
+ * 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 java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The pixel size or range of pixel sizes in which the image is available
+ *
+ * A FIXED size is represented as the following, where W is width and H is height. The domain
+ * of values is [0, 65535]
+ *
+ * W*H
+ *
+ * A RESIZABLE size that allows a modified aspect ratio is represented as the following, where
+ * W_1*H_1 is the minimum width and height pair and W2*H2 is the maximum width and height pair.
+ * The domain of values is [0, 65535]
+ *
+ * W_1*H_1-W2*H2
+ *
+ * A RESIZABLE size that allows a fixed aspect ratio is represented as the following, where
+ * W_1 is the minimum width and W2*H2 is the maximum width and height pair.
+ * The domain of values is [0, 65535]
+ *
+ * W_1**-W2*H2
+ *
+ * For each possible intermediate width value, the corresponding height is calculated using the
+ * formula
+ *
+ * H=(W*H2)/W2
+ */
+public class BipPixel {
+ private static final String TAG = "avrcpcontroller.BipPixel";
+
+ // The BIP specification declares this as the max size to be transferred. You can optionally
+ // use this value to indicate there is no upper bound on pixel size.
+ public static final int PIXEL_MAX = 65535;
+
+ // Note that the integer values also map to the number of '*' delimiters that exist in each
+ // formatted string
+ public static final int TYPE_UNKNOWN = 0;
+ public static final int TYPE_FIXED = 1;
+ public static final int TYPE_RESIZE_MODIFIED_ASPECT_RATIO = 2;
+ public static final int TYPE_RESIZE_FIXED_ASPECT_RATIO = 3;
+
+ private final int mType;
+ private final int mMinWidth;
+ private final int mMinHeight;
+ private final int mMaxWidth;
+ private final int mMaxHeight;
+
+ /**
+ * Create a fixed size BipPixel object
+ */
+ public static BipPixel createFixed(int width, int height) {
+ return new BipPixel(TYPE_FIXED, width, height, width, height);
+ }
+
+ /**
+ * Create a resizable modifiable aspect ratio BipPixel object
+ */
+ public static BipPixel createResizableModified(int minWidth, int minHeight, int maxWidth,
+ int maxHeight) {
+ return new BipPixel(TYPE_RESIZE_MODIFIED_ASPECT_RATIO, minWidth, minHeight, maxWidth,
+ maxHeight);
+ }
+
+ /**
+ * Create a resizable fixed aspect ratio BipPixel object
+ */
+ public static BipPixel createResizableFixed(int minWidth, int maxWidth, int maxHeight) {
+ int minHeight = (minWidth * maxHeight) / maxWidth;
+ return new BipPixel(TYPE_RESIZE_FIXED_ASPECT_RATIO, minWidth, minHeight,
+ maxWidth, maxHeight);
+ }
+
+ /**
+ * Directly create a BipPixel object knowing your exact type and dimensions. Internal use only
+ */
+ private BipPixel(int type, int minWidth, int minHeight, int maxWidth, int maxHeight) {
+ if (isDimensionInvalid(minWidth) || isDimensionInvalid(maxWidth)
+ || isDimensionInvalid(minHeight) || isDimensionInvalid(maxHeight)) {
+ throw new IllegalArgumentException("Dimension's must be in [0, " + PIXEL_MAX + "]");
+ }
+
+ mType = type;
+ mMinWidth = minWidth;
+ mMinHeight = minHeight;
+ mMaxWidth = maxWidth;
+ mMaxHeight = maxHeight;
+ }
+
+ /**
+ * Create a BipPixel object from an Image Format pixel attribute string
+ */
+ public BipPixel(String pixel) {
+ int type = TYPE_UNKNOWN;
+ int minWidth = -1;
+ int minHeight = -1;
+ int maxWidth = -1;
+ int maxHeight = -1;
+
+ int typeHint = determinePixelType(pixel);
+ switch (typeHint) {
+ case TYPE_FIXED:
+ Pattern fixed = Pattern.compile("^(\\d{1,5})\\*(\\d{1,5})$");
+ Matcher m1 = fixed.matcher(pixel);
+ if (m1.matches()) {
+ type = TYPE_FIXED;
+ minWidth = Integer.parseInt(m1.group(1));
+ maxWidth = Integer.parseInt(m1.group(1));
+ minHeight = Integer.parseInt(m1.group(2));
+ maxHeight = Integer.parseInt(m1.group(2));
+ }
+ break;
+ case TYPE_RESIZE_MODIFIED_ASPECT_RATIO:
+ Pattern modifiedRatio = Pattern.compile(
+ "^(\\d{1,5})\\*(\\d{1,5})-(\\d{1,5})\\*(\\d{1,5})$");
+ Matcher m2 = modifiedRatio.matcher(pixel);
+ if (m2.matches()) {
+ type = TYPE_RESIZE_MODIFIED_ASPECT_RATIO;
+ minWidth = Integer.parseInt(m2.group(1));
+ minHeight = Integer.parseInt(m2.group(2));
+ maxWidth = Integer.parseInt(m2.group(3));
+ maxHeight = Integer.parseInt(m2.group(4));
+ }
+ break;
+ case TYPE_RESIZE_FIXED_ASPECT_RATIO:
+ Pattern fixedRatio = Pattern.compile("^(\\d{1,5})\\*\\*-(\\d{1,5})\\*(\\d{1,5})$");
+ Matcher m3 = fixedRatio.matcher(pixel);
+ if (m3.matches()) {
+ type = TYPE_RESIZE_FIXED_ASPECT_RATIO;
+ minWidth = Integer.parseInt(m3.group(1));
+ maxWidth = Integer.parseInt(m3.group(2));
+ maxHeight = Integer.parseInt(m3.group(3));
+ minHeight = (minWidth * maxHeight) / maxWidth;
+ }
+ break;
+ default:
+ break;
+ }
+ if (type == TYPE_UNKNOWN) {
+ throw new ParseException("Failed to determine type of '" + pixel + "'");
+ }
+ if (isDimensionInvalid(minWidth) || isDimensionInvalid(maxWidth)
+ || isDimensionInvalid(minHeight) || isDimensionInvalid(maxHeight)) {
+ throw new ParseException("Parsed dimensions must be in [0, " + PIXEL_MAX + "]");
+ }
+
+ mType = type;
+ mMinWidth = minWidth;
+ mMinHeight = minHeight;
+ mMaxWidth = maxWidth;
+ mMaxHeight = maxHeight;
+ }
+
+ public int getType() {
+ return mType;
+ }
+
+ public int getMinWidth() {
+ return mMinWidth;
+ }
+
+ public int getMaxWidth() {
+ return mMaxWidth;
+ }
+
+ public int getMinHeight() {
+ return mMinHeight;
+ }
+
+ public int getMaxHeight() {
+ return mMaxHeight;
+ }
+
+ /**
+ * Determines the type of the pixel string by counting the number of '*' delimiters in the
+ * string.
+ *
+ * Note that the overall maximum size of any pixel string is 23 characters in length due to the
+ * max size of each dimension
+ *
+ * @return The corresponding type we should assume the given pixel string is
+ */
+ private static int determinePixelType(String pixel) {
+ if (pixel == null || pixel.length() > 23) return TYPE_UNKNOWN;
+ int delimCount = 0;
+ for (char c : pixel.toCharArray()) {
+ if (c == '*') delimCount++;
+ }
+ return delimCount > 0 && delimCount <= 3 ? delimCount : TYPE_UNKNOWN;
+ }
+
+ protected static boolean isDimensionInvalid(int dimension) {
+ return dimension < 0 || dimension > PIXEL_MAX;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (!(o instanceof BipPixel)) return false;
+
+ BipPixel p = (BipPixel) o;
+ return p.getType() == getType()
+ && p.getMinWidth() == getMinWidth()
+ && p.getMaxWidth() == getMaxWidth()
+ && p.getMinHeight() == getMinHeight()
+ && p.getMaxHeight() == getMaxHeight();
+ }
+
+ @Override
+ public String toString() {
+ String s = null;
+ switch (mType) {
+ case TYPE_FIXED:
+ s = mMaxWidth + "*" + mMaxHeight;
+ break;
+ case TYPE_RESIZE_MODIFIED_ASPECT_RATIO:
+ s = mMinWidth + "*" + mMinHeight + "-" + mMaxWidth + "*" + mMaxHeight;
+ break;
+ case TYPE_RESIZE_FIXED_ASPECT_RATIO:
+ s = mMinWidth + "**-" + mMaxWidth + "*" + mMaxHeight;
+ break;
+ }
+ return s;
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipRequest.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipRequest.java
new file mode 100644
index 0000000..dd2083c
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipRequest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.util.Log;
+
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.obex.ClientOperation;
+import javax.obex.ClientSession;
+import javax.obex.HeaderSet;
+import javax.obex.ResponseCodes;
+
+/**
+ * This is a base class for implementing AVRCP Controller Basic Image Profile (BIP) requests
+ */
+abstract class BipRequest {
+ private static final String TAG = "avrcpcontroller.BipRequest";
+ private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+
+ // User defined OBEX header identifiers
+ protected static final byte HEADER_ID_IMG_HANDLE = 0x30;
+ protected static final byte HEADER_ID_IMG_DESCRIPTOR = 0x71;
+
+ // Request types
+ public static final int TYPE_GET_IMAGE_PROPERTIES = 0;
+ public static final int TYPE_GET_IMAGE = 1;
+
+ protected HeaderSet mHeaderSet;
+ protected ClientOperation mOperation = null;
+ protected int mResponseCode;
+
+ BipRequest() {
+ mHeaderSet = new HeaderSet();
+ mResponseCode = -1;
+ }
+
+ /**
+ * A function that returns the type of the request.
+ *
+ * Used to determine type instead of using 'instanceof'
+ */
+ public abstract int getType();
+
+ /**
+ * A single point of entry for kicking off a AVRCP BIP request.
+ *
+ * Child classes are expected to implement this interface, filling in the details of the request
+ * (headers, operation type, error handling, etc).
+ */
+ public abstract void execute(ClientSession session) throws IOException;
+
+ /**
+ * A generica GET operation, providing overridable hooks to read response headers and content.
+ */
+ protected void executeGet(ClientSession session) throws IOException {
+ debug("Exeucting GET");
+ setOperation(null);
+ try {
+ ClientOperation operation = (ClientOperation) session.get(mHeaderSet);
+ setOperation(operation);
+ operation.setGetFinalFlag(true);
+ operation.continueOperation(true, false);
+ readResponseHeaders(operation.getReceivedHeader());
+ InputStream inputStream = operation.openInputStream();
+ readResponse(inputStream);
+ inputStream.close();
+ operation.close();
+ mResponseCode = operation.getResponseCode();
+ } catch (IOException e) {
+ mResponseCode = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
+ error("GET threw an exeception: " + e);
+ throw e;
+ }
+ debug("GET final response code is '" + mResponseCode + "'");
+ }
+
+ /**
+ * A generica PUT operation, providing overridable hooks to read response headers.
+ */
+ protected void executePut(ClientSession session, byte[] body) throws IOException {
+ debug("Exeucting PUT");
+ setOperation(null);
+ mHeaderSet.setHeader(HeaderSet.LENGTH, Long.valueOf(body.length));
+ try {
+ ClientOperation operation = (ClientOperation) session.put(mHeaderSet);
+ setOperation(operation);
+ DataOutputStream outputStream = mOperation.openDataOutputStream();
+ outputStream.write(body);
+ outputStream.close();
+ readResponseHeaders(operation.getReceivedHeader());
+ operation.close();
+ mResponseCode = operation.getResponseCode();
+ } catch (IOException e) {
+ mResponseCode = ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
+ error("PUT threw an exeception: " + e);
+ throw e;
+ }
+ debug("PUT final response code is '" + mResponseCode + "'");
+ }
+
+ /**
+ * Determine if the request was a success
+ *
+ * @return True if the request was successful, false otherwise
+ */
+ public final boolean isSuccess() {
+ return (mResponseCode == ResponseCodes.OBEX_HTTP_OK);
+ }
+
+ /**
+ * Get the actual response code associated with the request
+ *
+ * @return The response code as in integer
+ */
+ public final int getResponseCode() {
+ return mResponseCode;
+ }
+
+ /**
+ * A callback for subclasses to add logic to make determinations against the content of the
+ * returned headers.
+ */
+ protected void readResponseHeaders(HeaderSet headerset) {
+ /* nothing here by default */
+ }
+
+ /**
+ * A callback for subclasses to add logic to make determinations against the content of the
+ * returned response body.
+ */
+ protected void readResponse(InputStream stream) throws IOException {
+ /* nothing here by default */
+ }
+
+ private synchronized ClientOperation getOperation() {
+ return mOperation;
+ }
+
+ private synchronized void setOperation(ClientOperation operation) {
+ mOperation = operation;
+ }
+
+ @Override
+ public String toString() {
+ return TAG + " (type: " + getType() + ", mResponseCode: " + mResponseCode + ")";
+ }
+
+ /**
+ * Print to debug if debug is enabled for this class
+ */
+ protected void debug(String msg) {
+ if (DBG) {
+ Log.d(TAG, msg);
+ }
+ }
+
+ /**
+ * Print to warn
+ */
+ protected void warn(String msg) {
+ Log.w(TAG, msg);
+ }
+
+ /**
+ * Print to error
+ */
+ protected void error(String msg) {
+ Log.e(TAG, msg);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/BipTransformation.java b/src/com/android/bluetooth/avrcpcontroller/bip/BipTransformation.java
new file mode 100644
index 0000000..9922f61
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/BipTransformation.java
@@ -0,0 +1,181 @@
+/*
+ * 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.util.Log;
+
+import java.util.HashSet;
+
+/**
+ * Represents the set of possible transformations available for a variant of an image to get the
+ * image to a particular pixel size.
+ *
+ * The transformations supported by BIP v1.2.1 include:
+ * - Stretch
+ * - Fill
+ * - Crop
+ *
+ * Example in an image properties/format:
+ * <variant encoding=“GIF” pixel=“80*60-640*480” transformation="stretch fill"/>
+ * <variant encoding=“GIF” pixel=“80*60-640*480” transformation="fill"/>
+ * <variant encoding=“GIF” pixel=“80*60-640*480” transformation="stretch fill crop"/>
+ *
+ * Example in an image descriptor:
+ * <image-descriptor version=“1.0”>
+ * <image encoding=“JPEG” pixel=“1280*960” size=“500000” transformation="stretch"/>
+ * </image-descriptor>
+ */
+public class BipTransformation {
+ private static final String TAG = "avrcpcontroller.BipTransformation";
+
+ public static final int UNKNOWN = -1;
+ public static final int STRETCH = 0;
+ public static final int FILL = 1;
+ public static final int CROP = 2;
+
+ public final HashSet<Integer> mSupportedTransformations = new HashSet<Integer>(3);
+
+ /**
+ * Create an empty set of BIP Transformations
+ */
+ public BipTransformation() {
+ }
+
+ /**
+ * Create a set of BIP Transformations from an attribute value from an Image Format string
+ */
+ public BipTransformation(String transformations) {
+ if (transformations == null) return;
+
+ transformations = transformations.trim().toLowerCase();
+ String[] tokens = transformations.split(" ");
+ for (String token : tokens) {
+ switch (token) {
+ case "stretch":
+ addTransformation(STRETCH);
+ break;
+ case "fill":
+ addTransformation(FILL);
+ break;
+ case "crop":
+ addTransformation(CROP);
+ break;
+ default:
+ Log.e(TAG, "Found unknown transformation '" + token + "'");
+ break;
+ }
+ }
+ }
+
+ /**
+ * Create a set of BIP Transformations from a single supported transformation
+ */
+ public BipTransformation(int transformation) {
+ addTransformation(transformation);
+ }
+
+ /**
+ * Create a set of BIP Transformations from a set of supported transformations
+ */
+ public BipTransformation(int[] transformations) {
+ for (int transformation : transformations) {
+ addTransformation(transformation);
+ }
+ }
+
+ /**
+ * Add a supported Transformation
+ *
+ * @param transformation - The transformation you with to support
+ */
+ public void addTransformation(int transformation) {
+ if (!isValid(transformation)) {
+ throw new IllegalArgumentException("Invalid transformation ID '" + transformation
+ + "'");
+ }
+ mSupportedTransformations.add(transformation);
+ }
+
+ /**
+ * Remove a supported Transformation
+ *
+ * @param transformation - The transformation you with to remove support for
+ */
+ public void removeTransformation(int transformation) {
+ if (!isValid(transformation)) {
+ throw new IllegalArgumentException("Invalid transformation ID '" + transformation
+ + "'");
+ }
+ mSupportedTransformations.remove(transformation);
+ }
+
+ /**
+ * Determine if a given transformations is valid
+ *
+ * @param transformation The integer encoding ID of the transformation. Should be one of the
+ * BipTransformation.* constants, but doesn't *have* to be
+ * @return True if the transformation constant is valid, False otherwise
+ */
+ private boolean isValid(int transformation) {
+ return transformation >= STRETCH && transformation <= CROP;
+ }
+
+ /**
+ * Determine if this set of transformations supports a desired transformation
+ *
+ * @param transformation The ID of the desired transformation, STRETCH, FILL, or CROP
+ * @return True if this set supports the transformation, False otherwise
+ */
+ public boolean isSupported(int transformation) {
+ return mSupportedTransformations.contains(transformation);
+ }
+
+ /**
+ * Determine if this object supports any transformations at all
+ *
+ * @return True if any valid transformations are supported, False otherwise
+ */
+ public boolean supportsAny() {
+ return !mSupportedTransformations.isEmpty();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == this) return true;
+ if (o == null && !supportsAny()) return true;
+ if (!(o instanceof BipTransformation)) return false;
+
+ BipTransformation t = (BipTransformation) o;
+ return mSupportedTransformations.equals(t.mSupportedTransformations);
+ }
+
+ @Override
+ public String toString() {
+ if (!supportsAny()) return null;
+ String transformations = "";
+ if (isSupported(STRETCH)) {
+ transformations += "stretch ";
+ }
+ if (isSupported(FILL)) {
+ transformations += "fill ";
+ }
+ if (isSupported(CROP)) {
+ transformations += "crop ";
+ }
+ return transformations.trim();
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/ParseException.java b/src/com/android/bluetooth/avrcpcontroller/bip/ParseException.java
new file mode 100644
index 0000000..8dbb736
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/ParseException.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 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;
+
+/**
+ * Thrown when has parsing.
+ */
+public class ParseException extends RuntimeException {
+ public ParseException(String msg) {
+ super(msg);
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/RequestGetImage.java b/src/com/android/bluetooth/avrcpcontroller/bip/RequestGetImage.java
new file mode 100644
index 0000000..42b376c
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/RequestGetImage.java
@@ -0,0 +1,86 @@
+/*
+ * 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 java.io.IOException;
+import java.io.InputStream;
+
+import javax.obex.ClientSession;
+import javax.obex.HeaderSet;
+
+/**
+ * This implements a GetImage request, allowing a user to retrieve an image from the remote device
+ * with a specified format, encoding, etc.
+ */
+public class RequestGetImage extends BipRequest {
+ // Expected inputs
+ private final String mImageHandle;
+ private final BipImageDescriptor mImageDescriptor;
+
+ // Expected return type
+ private static final String TYPE = "x-bt/img-img";
+ private BipImage mImage = null;
+
+ public RequestGetImage(String imageHandle, BipImageDescriptor descriptor) {
+ mHeaderSet = new HeaderSet();
+ mResponseCode = -1;
+
+ mImageHandle = imageHandle;
+ mImageDescriptor = descriptor;
+
+ debug("GetImage - handle: " + mImageHandle + ", descriptor: "
+ + mImageDescriptor.toString());
+
+ mHeaderSet.setHeader(HeaderSet.TYPE, TYPE);
+ mHeaderSet.setHeader(HEADER_ID_IMG_HANDLE, mImageHandle);
+ mHeaderSet.setHeader(HEADER_ID_IMG_DESCRIPTOR, mImageDescriptor.serialize());
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_GET_IMAGE;
+ }
+
+ @Override
+ public void execute(ClientSession session) throws IOException {
+ executeGet(session);
+ }
+
+ @Override
+ protected void readResponse(InputStream stream) throws IOException {
+ mImage = new BipImage(mImageHandle, stream);
+ debug("Response GetImage - handle:" + mImageHandle + ", image: " + mImage);
+ }
+
+ /**
+ * Get the image handle associated with this request
+ *
+ * @return image handle used with this request
+ */
+ public String getImageHandle() {
+ return mImageHandle;
+ }
+
+ /**
+ * Get the downloaded image sent from the remote device
+ *
+ * @return A BipImage object containing the downloaded image Bitmap
+ */
+ public BipImage getImage() {
+ return mImage;
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/bip/RequestGetImageProperties.java b/src/com/android/bluetooth/avrcpcontroller/bip/RequestGetImageProperties.java
new file mode 100644
index 0000000..54813cc
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/bip/RequestGetImageProperties.java
@@ -0,0 +1,87 @@
+/*
+ * 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 java.io.IOException;
+import java.io.InputStream;
+
+import javax.obex.ClientSession;
+import javax.obex.HeaderSet;
+
+/**
+ * This implements a GetImageProperties request, allowing a user to retrieve information regarding
+ * the image formats, encodings, etc. available for an image.
+ */
+public class RequestGetImageProperties extends BipRequest {
+ // Expected inputs
+ private String mImageHandle = null;
+
+ // Expected return type
+ private static final String TYPE = "x-bt/img-properties";
+ private BipImageProperties mImageProperties = null;
+
+ public RequestGetImageProperties(String imageHandle) {
+ mHeaderSet = new HeaderSet();
+ mResponseCode = -1;
+ mImageHandle = imageHandle;
+
+ debug("GetImageProperties - handle: " + mImageHandle);
+
+ mHeaderSet.setHeader(HeaderSet.TYPE, TYPE);
+ mHeaderSet.setHeader(HEADER_ID_IMG_HANDLE, mImageHandle);
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_GET_IMAGE_PROPERTIES;
+ }
+
+ @Override
+ public void execute(ClientSession session) throws IOException {
+ executeGet(session);
+ }
+
+ @Override
+ protected void readResponse(InputStream stream) throws IOException {
+ try {
+ mImageProperties = new BipImageProperties(stream);
+ debug("Response GetImageProperties - handle: " + mImageHandle + ", properties: "
+ + mImageProperties.toString());
+ } catch (ParseException e) {
+ error("Failed to parse incoming properties object");
+ mImageProperties = null;
+ }
+ }
+
+ /**
+ * Get the image handle associated with this request
+ *
+ * @return image handle used with this request
+ */
+ public String getImageHandle() {
+ return mImageHandle;
+ }
+
+ /**
+ * Get the requested set of image properties sent from the remote device
+ *
+ * @return A BipImageProperties object
+ */
+ public BipImageProperties getImageProperties() {
+ return mImageProperties;
+ }
+}