Merge "Use getConnectionState to determine which profiles to disconnect in AdapterService#disconnectAllEnabledProfiles()"
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index a9b7186..6dc6cab 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -46,6 +46,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.CONNECTIVITY_INTERNAL" />
+ <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
<uses-permission android:name="android.permission.NETWORK_FACTORY" />
<uses-permission android:name="android.permission.TETHER_PRIVILEGED" />
<uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
@@ -67,6 +68,7 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_ROUTING" />
<uses-permission android:name="android.permission.UPDATE_DEVICE_STATS" />
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
+ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-sdk android:minSdkVersion="14"/>
@@ -343,6 +345,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_a2dp_sink.cpp b/jni/com_android_bluetooth_a2dp_sink.cpp
index 8518944..87668ff 100644
--- a/jni/com_android_bluetooth_a2dp_sink.cpp
+++ b/jni/com_android_bluetooth_a2dp_sink.cpp
@@ -218,6 +218,29 @@
sBluetoothA2dpInterface->set_audio_track_gain((float)gain);
}
+static jboolean setActiveDeviceNative(JNIEnv* env, jobject object,
+ jbyteArray address) {
+ if (!sBluetoothA2dpInterface) return JNI_FALSE;
+
+ ALOGI("%s: sBluetoothA2dpInterface: %p", __func__, sBluetoothA2dpInterface);
+
+ jbyte* addr = env->GetByteArrayElements(address, NULL);
+ if (!addr) {
+ jniThrowIOException(env, EINVAL);
+ return JNI_FALSE;
+ }
+
+ RawAddress rawAddress;
+ rawAddress.FromOctets((uint8_t*)addr);
+ bt_status_t status = sBluetoothA2dpInterface->set_active_device(rawAddress);
+ if (status != BT_STATUS_SUCCESS) {
+ ALOGE("Failed sending passthru command, status: %d", status);
+ }
+ env->ReleaseByteArrayElements(address, addr, 0);
+
+ return (status == BT_STATUS_SUCCESS) ? JNI_TRUE : JNI_FALSE;
+}
+
static JNINativeMethod sMethods[] = {
{"classInitNative", "()V", (void*)classInitNative},
{"initNative", "()V", (void*)initNative},
@@ -226,6 +249,7 @@
{"disconnectA2dpNative", "([B)Z", (void*)disconnectA2dpNative},
{"informAudioFocusStateNative", "(I)V", (void*)informAudioFocusStateNative},
{"informAudioTrackGainNative", "(F)V", (void*)informAudioTrackGainNative},
+ {"setActiveDeviceNative", "([B)Z", (void*)setActiveDeviceNative},
};
int register_com_android_bluetooth_a2dp_sink(JNIEnv* env) {
diff --git a/jni/com_android_bluetooth_avrcp_controller.cpp b/jni/com_android_bluetooth_avrcp_controller.cpp
index 050da06..55a6af6 100755
--- a/jni/com_android_bluetooth_avrcp_controller.cpp
+++ b/jni/com_android_bluetooth_avrcp_controller.cpp
@@ -49,8 +49,9 @@
static jmethodID method_handleAddressedPlayerChanged;
static jmethodID method_handleNowPlayingContentChanged;
static jmethodID method_onAvailablePlayerChanged;
+static jmethodID method_getRcPsm;
-static jclass class_MediaBrowser_MediaItem;
+static jclass class_AvrcpItem;
static jclass class_AvrcpPlayer;
static const btrc_ctrl_interface_t* sBluetoothAvrcpInterface = NULL;
@@ -458,7 +459,7 @@
sCallbackEnv->NewObjectArray((jint)count, class_AvrcpPlayer, 0));
} else {
itemArray.reset(sCallbackEnv->NewObjectArray(
- (jint)count, class_MediaBrowser_MediaItem, 0));
+ (jint)count, class_AvrcpItem, 0));
}
if (!itemArray.get()) {
ALOGE("%s itemArray allocation failed.", __func__);
@@ -511,11 +512,11 @@
ScopedLocalRef<jobject> mediaObj(
sCallbackEnv.get(),
(jobject)sCallbackEnv->CallObjectMethod(
- sCallbacksObj, method_createFromNativeMediaItem, uid,
- (jint)item->media.type, mediaName.get(), attrIdArray.get(),
- attrValArray.get()));
+ sCallbacksObj, method_createFromNativeMediaItem, addr.get(),
+ uid, (jint)item->media.type, mediaName.get(),
+ attrIdArray.get(), attrValArray.get()));
if (!mediaObj.get()) {
- ALOGE("%s failed to creae MediaItem for type ITEM_MEDIA", __func__);
+ ALOGE("%s failed to create AvrcpItem for type ITEM_MEDIA", __func__);
return;
}
sCallbackEnv->SetObjectArrayElement(itemArray.get(), i, mediaObj.get());
@@ -536,11 +537,11 @@
ScopedLocalRef<jobject> folderObj(
sCallbackEnv.get(),
(jobject)sCallbackEnv->CallObjectMethod(
- sCallbacksObj, method_createFromNativeFolderItem, uid,
- (jint)item->folder.type, folderName.get(),
+ sCallbacksObj, method_createFromNativeFolderItem, addr.get(),
+ uid, (jint)item->folder.type, folderName.get(),
(jint)item->folder.playable));
if (!folderObj.get()) {
- ALOGE("%s failed to create MediaItem for type ITEM_FOLDER", __func__);
+ ALOGE("%s failed to create AvrcpItem for type ITEM_FOLDER", __func__);
return;
}
sCallbackEnv->SetObjectArrayElement(itemArray.get(), i,
@@ -576,8 +577,8 @@
ScopedLocalRef<jobject> playerObj(
sCallbackEnv.get(),
(jobject)sCallbackEnv->CallObjectMethod(
- sCallbacksObj, method_createFromNativePlayerItem, id,
- playerName.get(), featureBitArray.get(), playStatus,
+ sCallbacksObj, method_createFromNativePlayerItem, addr.get(),
+ id, playerName.get(), featureBitArray.get(), playStatus,
playerType));
if (!playerObj.get()) {
ALOGE("%s failed to create AvrcpPlayer from ITEM_PLAYER", __func__);
@@ -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");
@@ -805,21 +832,23 @@
method_handleGetFolderItemsRsp =
env->GetMethodID(clazz, "handleGetFolderItemsRsp",
- "([BI[Landroid/media/browse/MediaBrowser$MediaItem;)V");
+ "([BI[Lcom/android/bluetooth/avrcpcontroller/"
+ "AvrcpItem;)V");
method_handleGetPlayerItemsRsp = env->GetMethodID(
clazz, "handleGetPlayerItemsRsp",
"([B[Lcom/android/bluetooth/avrcpcontroller/AvrcpPlayer;)V");
method_createFromNativeMediaItem =
env->GetMethodID(clazz, "createFromNativeMediaItem",
- "(JILjava/lang/String;[I[Ljava/lang/String;)Landroid/"
- "media/browse/MediaBrowser$MediaItem;");
+ "([BJILjava/lang/String;[I[Ljava/lang/String;)Lcom/"
+ "android/bluetooth/avrcpcontroller/AvrcpItem;");
method_createFromNativeFolderItem = env->GetMethodID(
clazz, "createFromNativeFolderItem",
- "(JILjava/lang/String;I)Landroid/media/browse/MediaBrowser$MediaItem;");
+ "([BJILjava/lang/String;I)Lcom/android/bluetooth/avrcpcontroller/"
+ "AvrcpItem;");
method_createFromNativePlayerItem =
env->GetMethodID(clazz, "createFromNativePlayerItem",
- "(ILjava/lang/String;[BII)Lcom/android/bluetooth/"
+ "([BILjava/lang/String;[BII)Lcom/android/bluetooth/"
"avrcpcontroller/AvrcpPlayer;");
method_handleChangeFolderRsp =
env->GetMethodID(clazz, "handleChangeFolderRsp", "([BI)V");
@@ -840,9 +869,9 @@
static void initNative(JNIEnv* env, jobject object) {
std::unique_lock<std::shared_timed_mutex> lock(sCallbacks_mutex);
- jclass tmpMediaItem =
- env->FindClass("android/media/browse/MediaBrowser$MediaItem");
- class_MediaBrowser_MediaItem = (jclass)env->NewGlobalRef(tmpMediaItem);
+ jclass tmpAvrcpItem =
+ env->FindClass("com/android/bluetooth/avrcpcontroller/AvrcpItem");
+ class_AvrcpItem = (jclass)env->NewGlobalRef(tmpAvrcpItem);
jclass tmpBtPlayer =
env->FindClass("com/android/bluetooth/avrcpcontroller/AvrcpPlayer");
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/a2dpsink/A2dpSinkService.java b/src/com/android/bluetooth/a2dpsink/A2dpSinkService.java
index 4bcd9e1..efd827c 100644
--- a/src/com/android/bluetooth/a2dpsink/A2dpSinkService.java
+++ b/src/com/android/bluetooth/a2dpsink/A2dpSinkService.java
@@ -75,6 +75,17 @@
return sService;
}
+ /**
+ * Testing API to inject a mockA2dpSinkService.
+ * @hide
+ */
+ @VisibleForTesting
+ public static void setA2dpSinkService(A2dpSinkService service) {
+ sService = service;
+ sService.mA2dpSinkStreamHandler = new A2dpSinkStreamHandler(sService, sService);
+ }
+
+
public A2dpSinkService() {
mAdapter = BluetoothAdapter.getDefaultAdapter();
}
@@ -414,6 +425,17 @@
native boolean disconnectA2dpNative(byte[] address);
/**
+ * set A2DP state machine as the active device
+ * the active device is the only one that will receive passthrough commands and the only one
+ * that will have its audio decoded
+ *
+ * @hide
+ * @param address
+ * @return active device request has been scheduled
+ */
+ public native boolean setActiveDeviceNative(byte[] address);
+
+ /**
* inform A2DP decoder of the current audio focus
*
* @param focusGranted
diff --git a/src/com/android/bluetooth/avrcp/AvrcpEventLogger.java b/src/com/android/bluetooth/avrcp/AvrcpEventLogger.java
new file mode 100644
index 0000000..b2cf805
--- /dev/null
+++ b/src/com/android/bluetooth/avrcp/AvrcpEventLogger.java
@@ -0,0 +1,79 @@
+/*
+ * 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.avrcp;
+
+import android.util.Log;
+
+import com.android.bluetooth.Utils;
+
+import com.google.common.collect.EvictingQueue;
+
+
+// This class is to store logs for Avrcp for given size.
+public class AvrcpEventLogger {
+ private final String mTitle;
+ private final EvictingQueue<Event> mEvents;
+
+ // Event class contain timestamp and log context.
+ private class Event {
+ private final String mTimeStamp;
+ private final String mMsg;
+
+ Event(String msg) {
+ mTimeStamp = Utils.getLocalTimeString();
+ mMsg = msg;
+ }
+
+ public String toString() {
+ return (new StringBuilder(mTimeStamp)
+ .append(" ").append(mMsg).toString());
+ }
+ }
+
+ AvrcpEventLogger(int size, String title) {
+ mEvents = EvictingQueue.create(size);
+ mTitle = title;
+ }
+
+ synchronized void add(String msg) {
+ Event event = new Event(msg);
+ mEvents.add(event);
+ }
+
+ synchronized void logv(String tag, String msg) {
+ add(msg);
+ Log.v(tag, msg);
+ }
+
+ synchronized void logd(String tag, String msg) {
+ logd(true, tag, msg);
+ }
+
+ synchronized void logd(boolean debug, String tag, String msg) {
+ add(msg);
+ if (debug) {
+ Log.d(tag, msg);
+ }
+ }
+
+ synchronized void dump(StringBuilder sb) {
+ sb.append("Avrcp ").append(mTitle).append(":\n");
+ for (Event event : mEvents) {
+ sb.append(" ").append(event.toString()).append("\n");
+ }
+ }
+}
diff --git a/src/com/android/bluetooth/avrcp/AvrcpTargetService.java b/src/com/android/bluetooth/avrcp/AvrcpTargetService.java
index fc35fc1..17e5a10 100644
--- a/src/com/android/bluetooth/avrcp/AvrcpTargetService.java
+++ b/src/com/android/bluetooth/avrcp/AvrcpTargetService.java
@@ -50,7 +50,11 @@
private static final String AVRCP_ENABLE_PROPERTY = "persist.bluetooth.enablenewavrcp";
private static final int AVRCP_MAX_VOL = 127;
+ private static final int MEDIA_KEY_EVENT_LOGGER_SIZE = 20;
+ private static final String MEDIA_KEY_EVENT_LOGGER_TITLE = "Media Key Events";
private static int sDeviceMaxVolume = 0;
+ private final AvrcpEventLogger mMediaKeyEventLogger = new AvrcpEventLogger(
+ MEDIA_KEY_EVENT_LOGGER_SIZE, MEDIA_KEY_EVENT_LOGGER_TITLE);
private MediaPlayerList mMediaPlayerList;
private AudioManager mAudioManager;
@@ -365,7 +369,11 @@
// TODO (apanicke): Handle key events here in the service. Currently it was more convenient to
// handle them there but logically they make more sense handled here.
void sendMediaKeyEvent(int event, boolean pushed) {
- if (DEBUG) Log.d(TAG, "getMediaKeyEvent: event=" + event + " pushed=" + pushed);
+ BluetoothDevice activeDevice = getA2dpActiveDevice();
+ MediaPlayerWrapper player = mMediaPlayerList.getActivePlayer();
+ mMediaKeyEventLogger.logd(DEBUG, TAG, "getMediaKeyEvent:" + " device=" + activeDevice
+ + " event=" + event + " pushed=" + pushed
+ + " to " + (player == null ? null : player.getPackageName()));
mMediaPlayerList.sendMediaKeyEvent(event, pushed);
}
@@ -394,6 +402,8 @@
tempBuilder.append("\nMedia Player List is empty\n");
}
+ mMediaKeyEventLogger.dump(tempBuilder);
+ tempBuilder.append("\n");
mVolumeManager.dump(tempBuilder);
// Tab everything over by two spaces
diff --git a/src/com/android/bluetooth/avrcp/AvrcpVolumeManager.java b/src/com/android/bluetooth/avrcp/AvrcpVolumeManager.java
index e48d428..585caed 100644
--- a/src/com/android/bluetooth/avrcp/AvrcpVolumeManager.java
+++ b/src/com/android/bluetooth/avrcp/AvrcpVolumeManager.java
@@ -38,10 +38,14 @@
// All volumes are stored at system volume values, not AVRCP values
private static final String VOLUME_MAP = "bluetooth_volume_map";
private static final String VOLUME_BLACKLIST = "absolute_volume_blacklist";
+ private static final String VOLUME_CHANGE_LOG_TITLE = "Volume Events";
private static final int AVRCP_MAX_VOL = 127;
private static final int STREAM_MUSIC = AudioManager.STREAM_MUSIC;
+ private static final int VOLUME_CHANGE_LOGGER_SIZE = 30;
private static int sDeviceMaxVolume = 0;
private static int sNewDeviceVolume = 0;
+ private final AvrcpEventLogger mVolumeEventLogger = new AvrcpEventLogger(
+ VOLUME_CHANGE_LOGGER_SIZE, VOLUME_CHANGE_LOG_TITLE);
Context mContext;
AudioManager mAudioManager;
@@ -80,7 +84,8 @@
// If absolute volume for the device is supported, set the volume for the device
if (mDeviceMap.get(device)) {
int avrcpVolume = systemToAvrcpVolume(savedVolume);
- Log.i(TAG, "switchVolumeDevice: Updating device volume: avrcpVolume=" + avrcpVolume);
+ mVolumeEventLogger.logd(TAG,
+ "switchVolumeDevice: Updating device volume: avrcpVolume=" + avrcpVolume);
mNativeInterface.sendVolumeChanged(device.getAddress(), avrcpVolume);
}
}
@@ -120,8 +125,8 @@
return;
}
SharedPreferences.Editor pref = getVolumeMap().edit();
- Log.i(TAG, "storeVolume: Storing stream volume level for device " + device
- + " : " + storeVolume);
+ mVolumeEventLogger.logd(TAG, "storeVolume: Storing stream volume level for device "
+ + device + " : " + storeVolume);
mVolumeMap.put(device, storeVolume);
pref.putInt(device.getAddress(), storeVolume);
// Always use apply() since it is asynchronous, otherwise the call can hang waiting for
@@ -139,7 +144,8 @@
return;
}
SharedPreferences.Editor pref = getVolumeMap().edit();
- Log.i(TAG, "RemoveStoredVolume: Remove stored stream volume level for device " + device);
+ mVolumeEventLogger.logd(TAG,
+ "RemoveStoredVolume: Remove stored stream volume level for device " + device);
mVolumeMap.remove(device);
pref.remove(device.getAddress());
// Always use apply() since it is asynchronous, otherwise the call can hang waiting for
@@ -164,11 +170,11 @@
void setVolume(@NonNull BluetoothDevice device, int avrcpVolume) {
int deviceVolume =
(int) Math.floor((double) avrcpVolume * sDeviceMaxVolume / AVRCP_MAX_VOL);
- if (DEBUG) {
- Log.d(TAG, "setVolume: avrcpVolume=" + avrcpVolume
- + " deviceVolume=" + deviceVolume
- + " sDeviceMaxVolume=" + sDeviceMaxVolume);
- }
+ mVolumeEventLogger.logd(DEBUG, TAG, "setVolume:"
+ + " device=" + device
+ + " avrcpVolume=" + avrcpVolume
+ + " deviceVolume=" + deviceVolume
+ + " sDeviceMaxVolume=" + sDeviceMaxVolume);
mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, deviceVolume,
AudioManager.FLAG_SHOW_UI | AudioManager.FLAG_BLUETOOTH_ABS_VOLUME);
storeVolumeForDevice(device);
@@ -178,11 +184,11 @@
int avrcpVolume =
(int) Math.floor((double) deviceVolume * AVRCP_MAX_VOL / sDeviceMaxVolume);
if (avrcpVolume > 127) avrcpVolume = 127;
- if (DEBUG) {
- Log.d(TAG, "sendVolumeChanged: avrcpVolume=" + avrcpVolume
- + " deviceVolume=" + deviceVolume
- + " sDeviceMaxVolume=" + sDeviceMaxVolume);
- }
+ mVolumeEventLogger.logd(DEBUG, TAG, "sendVolumeChanged:"
+ + " device=" + device
+ + " avrcpVolume=" + avrcpVolume
+ + " deviceVolume=" + deviceVolume
+ + " sDeviceMaxVolume=" + sDeviceMaxVolume);
mNativeInterface.sendVolumeChanged(device.getAddress(), avrcpVolume);
storeVolumeForDevice(device);
}
@@ -290,6 +296,12 @@
d.getAddress(), deviceName, (Integer) value, absoluteVolume));
}
}
+
+ StringBuilder tempBuilder = new StringBuilder();
+ mVolumeEventLogger.dump(tempBuilder);
+ // Tab volume event logs over by two spaces
+ sb.append(tempBuilder.toString().replaceAll("(?m)^", " "));
+ tempBuilder.append("\n");
}
static void d(String msg) {
diff --git a/src/com/android/bluetooth/avrcp/MediaPlayerList.java b/src/com/android/bluetooth/avrcp/MediaPlayerList.java
index e7cb96a..bd19898 100644
--- a/src/com/android/bluetooth/avrcp/MediaPlayerList.java
+++ b/src/com/android/bluetooth/avrcp/MediaPlayerList.java
@@ -69,6 +69,10 @@
private static final int NO_ACTIVE_PLAYER = 0;
private static final int BLUETOOTH_PLAYER_ID = 0;
private static final String BLUETOOTH_PLAYER_NAME = "Bluetooth Player";
+ private static final int ACTIVE_PLAYER_LOGGER_SIZE = 5;
+ private static final String ACTIVE_PLAYER_LOGGER_TITLE = "Active Player Events";
+ private static final int AUDIO_PLAYBACK_STATE_LOGGER_SIZE = 15;
+ private static final String AUDIO_PLAYBACK_STATE_LOGGER_TITLE = "Audio Playback State Events";
// mediaId's for the now playing list will be in the form of "NowPlayingId[XX]" where [XX]
// is the Queue ID for the requested item.
@@ -85,6 +89,10 @@
private MediaSessionManager mMediaSessionManager;
private MediaData mCurrMediaData = null;
private final AudioManager mAudioManager;
+ private final AvrcpEventLogger mActivePlayerLogger = new AvrcpEventLogger(
+ ACTIVE_PLAYER_LOGGER_SIZE, ACTIVE_PLAYER_LOGGER_TITLE);
+ private final AvrcpEventLogger mAudioPlaybackStateLogger = new AvrcpEventLogger(
+ AUDIO_PLAYBACK_STATE_LOGGER_SIZE, AUDIO_PLAYBACK_STATE_LOGGER_TITLE);
private Map<Integer, MediaPlayerWrapper> mMediaPlayers =
Collections.synchronizedMap(new HashMap<Integer, MediaPlayerWrapper>());
@@ -240,8 +248,6 @@
return mMediaPlayers.get(mActivePlayerId);
}
-
-
// In this case the displayed player is the Bluetooth Player, the number of items is equal
// to the number of players. The root ID will always be empty string in this case as well.
void getPlayerRoot(int playerId, GetPlayerRootCallback cb) {
@@ -527,7 +533,8 @@
mActivePlayerId = playerId;
getActivePlayer().registerCallback(mMediaPlayerCallback);
- Log.i(TAG, "setActivePlayer(): setting player to " + getActivePlayer().getPackageName());
+ mActivePlayerLogger.logd(TAG, "setActivePlayer(): setting player to "
+ + getActivePlayer().getPackageName());
// Ensure that metadata is synced on the new player
if (!getActivePlayer().isMetadataSynced()) {
@@ -674,7 +681,8 @@
1.0f);
currMediaData.state = builder.build();
}
- Log.i(TAG, "updateMediaForAudioPlayback: update state=" + currMediaData.state);
+ mAudioPlaybackStateLogger.logd(TAG, "updateMediaForAudioPlayback: update state="
+ + currMediaData.state);
sendMediaUpdate(currMediaData);
}
@@ -692,21 +700,24 @@
return;
}
boolean isActive = false;
- Log.v(TAG, "onPlaybackConfigChanged(): Configs list size=" + configs.size());
+ AudioPlaybackConfiguration activeConfig = null;
for (AudioPlaybackConfiguration config : configs) {
if (config.isActive() && (config.getAudioAttributes().getUsage()
== AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
&& (config.getAudioAttributes().getContentType()
== AudioAttributes.CONTENT_TYPE_SPEECH)) {
- if (DEBUG) {
- Log.d(TAG, "onPlaybackConfigChanged(): config=" + config);
- }
+ activeConfig = config;
isActive = true;
}
}
if (isActive != mAudioPlaybackIsActive) {
- Log.d(TAG, "onPlaybackConfigChanged isActive=" + isActive
- + ", mAudioPlaybackIsActive=" + mAudioPlaybackIsActive);
+ mAudioPlaybackStateLogger.logd(DEBUG, TAG, "onPlaybackConfigChanged: "
+ + (mAudioPlaybackIsActive ? "Active" : "Non-active") + " -> "
+ + (isActive ? "Active" : "Non-active"));
+ if (isActive) {
+ mAudioPlaybackStateLogger.logd(DEBUG, TAG, "onPlaybackConfigChanged: "
+ + "active config: " + activeConfig);
+ }
mAudioPlaybackIsActive = isActive;
updateMediaForAudioPlayback();
}
@@ -803,9 +814,12 @@
sb.append(player.toString().replaceAll("(?m)^", " "));
sb.append("\n");
}
- // TODO (apanicke): Add media key events
+
+ mActivePlayerLogger.dump(sb);
+ sb.append("\n");
+ mAudioPlaybackStateLogger.dump(sb);
+ sb.append("\n");
// TODO (apanicke): Add last sent data
- // TODO (apanicke): Add addressed player history
}
private static void e(String message) {
diff --git a/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java b/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java
index 38a270f..c9a2b11 100644
--- a/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java
+++ b/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java
@@ -42,10 +42,14 @@
private static final String TAG = "AvrcpMediaPlayerWrapper";
private static final boolean DEBUG = false;
static boolean sTesting = false;
+ private static final int PLAYBACK_STATE_CHANGE_EVENT_LOGGER_SIZE = 5;
+ private static final String PLAYBACK_STATE_CHANGE_LOGGER_EVENT_TITLE =
+ "Playback State change Event";
private MediaController mMediaController;
private String mPackageName;
private Looper mLooper;
+ private final AvrcpEventLogger mPlaybackStateChangeEventLogger;
private MediaData mCurrentData;
@@ -81,6 +85,8 @@
mMediaController = controller;
mPackageName = controller.getPackageName();
mLooper = looper;
+ mPlaybackStateChangeEventLogger = new AvrcpEventLogger(
+ PLAYBACK_STATE_CHANGE_EVENT_LOGGER_SIZE, PLAYBACK_STATE_CHANGE_LOGGER_EVENT_TITLE);
mCurrentData = new MediaData(null, null, null);
mCurrentData.queue = Util.toMetadataList(getQueue());
@@ -399,7 +405,8 @@
return;
}
- Log.v(TAG, "onPlaybackStateChanged(): " + mPackageName + " : " + state.toString());
+ mPlaybackStateChangeEventLogger.logv(TAG, "onPlaybackStateChanged(): "
+ + mPackageName + " : " + state.toString());
if (!playstateEquals(state, getPlaybackState())) {
e("The callback playback state doesn't match the current state");
@@ -513,6 +520,7 @@
for (Metadata data : mCurrentData.queue) {
sb.append(" " + data + "\n");
}
+ mPlaybackStateChangeEventLogger.dump(sb);
return sb.toString();
}
}
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 f46820c..a58e4d6 100755
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerService.java
@@ -22,12 +22,11 @@
import android.bluetooth.BluetoothProfile;
import android.bluetooth.IBluetoothAvrcpController;
import android.content.Intent;
-import android.media.MediaDescription;
-import android.media.browse.MediaBrowser.MediaItem;
-import android.media.session.PlaybackState;
-import android.os.Bundle;
+import android.support.v4.media.MediaBrowserCompat.MediaItem;
+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;
@@ -48,7 +47,6 @@
static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
static final boolean VDBG = Log.isLoggable(TAG, Log.VERBOSE);
- public static final String MEDIA_ITEM_UID_KEY = "media-item-uid-key";
/*
* Play State Values from JNI
*/
@@ -97,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();
}
@@ -109,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;
@@ -128,6 +152,8 @@
sService = null;
sBrowseTree = null;
+ mCoverArtManager.cleanup();
+ mCoverArtManager = null;
return true;
}
@@ -140,15 +166,33 @@
}
private void refreshContents(BrowseTree.BrowseNode node) {
- if (node.mDevice == null) {
+ BluetoothDevice device = node.getDevice();
+ if (device == null) {
return;
}
- AvrcpControllerStateMachine stateMachine = getStateMachine(node.mDevice);
+ AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.requestContents(node);
}
}
+ void playItem(String parentMediaId) {
+ if (DBG) Log.d(TAG, "playItem(" + parentMediaId + ")");
+ // Check if the requestedNode is a player rather than a song
+ BrowseTree.BrowseNode requestedNode = sBrowseTree.findBrowseNodeByID(parentMediaId);
+ if (requestedNode == null) {
+ for (AvrcpControllerStateMachine stateMachine : mDeviceStateMap.values()) {
+ // Check each state machine for the song and then play it
+ requestedNode = stateMachine.findNode(parentMediaId);
+ if (requestedNode != null) {
+ if (DBG) Log.d(TAG, "Found a node");
+ stateMachine.playItem(requestedNode);
+ break;
+ }
+ }
+ }
+ }
+
/*Java API*/
/**
@@ -284,7 +328,7 @@
// Called by JNI when a device has connected or disconnected.
private synchronized void onConnectionStateChanged(boolean remoteControlConnected,
boolean browsingConnected, byte[] address) {
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
if (DBG) {
Log.d(TAG, "onConnectionStateChanged " + remoteControlConnected + " "
+ browsingConnected + device);
@@ -309,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. */
@@ -319,7 +374,7 @@
if (DBG) {
Log.d(TAG, "handleRegisterNotificationAbsVol");
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.sendMessage(
@@ -332,7 +387,7 @@
if (DBG) {
Log.d(TAG, "handleSetAbsVolume ");
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_SET_ABS_VOL_CMD,
@@ -347,11 +402,17 @@
Log.d(TAG, "onTrackChanged");
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
+ AvrcpItem.Builder aib = new AvrcpItem.Builder();
+ aib.fromAvrcpAttributeArray(attributes, attribVals);
+ aib.setDevice(device);
+ aib.setItemType(AvrcpItem.TYPE_MEDIA);
+ aib.setUuid(UUID.randomUUID().toString());
+ AvrcpItem item = aib.build();
stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_TRACK_CHANGED,
- TrackInfo.getMetadata(attributes, attribVals));
+ item);
}
}
@@ -361,7 +422,7 @@
if (DBG) {
Log.d(TAG, "onPlayPositionChanged pos " + currSongPosition);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.sendMessage(
@@ -375,27 +436,27 @@
if (DBG) {
Log.d(TAG, "onPlayStatusChanged " + playStatus);
}
- int playbackState = PlaybackState.STATE_NONE;
+ int playbackState = PlaybackStateCompat.STATE_NONE;
switch (playStatus) {
case JNI_PLAY_STATUS_STOPPED:
- playbackState = PlaybackState.STATE_STOPPED;
+ playbackState = PlaybackStateCompat.STATE_STOPPED;
break;
case JNI_PLAY_STATUS_PLAYING:
- playbackState = PlaybackState.STATE_PLAYING;
+ playbackState = PlaybackStateCompat.STATE_PLAYING;
break;
case JNI_PLAY_STATUS_PAUSED:
- playbackState = PlaybackState.STATE_PAUSED;
+ playbackState = PlaybackStateCompat.STATE_PAUSED;
break;
case JNI_PLAY_STATUS_FWD_SEEK:
- playbackState = PlaybackState.STATE_FAST_FORWARDING;
+ playbackState = PlaybackStateCompat.STATE_FAST_FORWARDING;
break;
case JNI_PLAY_STATUS_REV_SEEK:
- playbackState = PlaybackState.STATE_REWINDING;
+ playbackState = PlaybackStateCompat.STATE_REWINDING;
break;
default:
- playbackState = PlaybackState.STATE_NONE;
+ playbackState = PlaybackStateCompat.STATE_NONE;
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.sendMessage(
@@ -409,7 +470,7 @@
if (DBG) {
Log.d(TAG, "handlePlayerAppSetting rspLen = " + rspLen);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
PlayerApplicationSettings supportedSettings =
@@ -425,7 +486,7 @@
if (DBG) {
Log.d(TAG, "onPlayerAppSettingChanged ");
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
@@ -450,49 +511,38 @@
}
// Browsing related JNI callbacks.
- void handleGetFolderItemsRsp(byte[] address, int status, MediaItem[] items) {
+ void handleGetFolderItemsRsp(byte[] address, int status, AvrcpItem[] items) {
if (DBG) {
Log.d(TAG, "handleGetFolderItemsRsp called with status " + status + " items "
+ items.length + " items.");
}
- for (MediaItem item : items) {
- if (VDBG) {
- Log.d(TAG, "media item: " + item + " uid: "
- + item.getDescription().getMediaId());
- }
- }
- ArrayList<MediaItem> itemsList = new ArrayList<>();
- for (MediaItem item : items) {
+
+ List<AvrcpItem> itemsList = new ArrayList<>();
+ for (AvrcpItem item : items) {
+ if (VDBG) Log.d(TAG, item.toString());
itemsList.add(item);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
-
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
-
stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_GET_FOLDER_ITEMS,
itemsList);
}
}
-
void handleGetPlayerItemsRsp(byte[] address, AvrcpPlayer[] items) {
if (DBG) {
Log.d(TAG, "handleGetFolderItemsRsp called with " + items.length + " items.");
}
- for (AvrcpPlayer item : items) {
- if (VDBG) {
- Log.d(TAG, "bt player item: " + item);
- }
- }
List<AvrcpPlayer> itemsList = new ArrayList<>();
- for (AvrcpPlayer p : items) {
- itemsList.add(p);
+ for (AvrcpPlayer item : items) {
+ if (VDBG) Log.d(TAG, "bt player item: " + item);
+ itemsList.add(item);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_GET_PLAYER_ITEMS,
@@ -501,60 +551,56 @@
}
// JNI Helper functions to convert native objects to java.
- MediaItem createFromNativeMediaItem(long uid, int type, String name, int[] attrIds,
- String[] attrVals) {
+ AvrcpItem createFromNativeMediaItem(byte[] address, long uid, int type, String name,
+ int[] attrIds, String[] attrVals) {
if (VDBG) {
- Log.d(TAG, "createFromNativeMediaItem uid: " + uid + " type " + type + " name "
- + name + " attrids " + attrIds + " attrVals " + attrVals);
+ Log.d(TAG, "createFromNativeMediaItem uid: " + uid + " type: " + type + " name: " + name
+ + " attrids: " + attrIds + " attrVals: " + attrVals);
}
- MediaDescription.Builder mdb = new MediaDescription.Builder();
- Bundle mdExtra = new Bundle();
- mdExtra.putLong(MEDIA_ITEM_UID_KEY, uid);
- mdb.setExtras(mdExtra);
-
-
- // Generate a random UUID. We do this since database unaware TGs can send multiple
- // items with same MEDIA_ITEM_UID_KEY.
- mdb.setMediaId(UUID.randomUUID().toString());
- // Concise readable name.
- mdb.setTitle(name);
-
- // We skip the attributes since we can query them using UID for the item above
- // Also MediaDescription does not give an easy way to provide this unless we pass
- // it as an MediaMetadata which is put inside the extras.
- return new MediaItem(mdb.build(), MediaItem.FLAG_PLAYABLE);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
+ AvrcpItem.Builder aib = new AvrcpItem.Builder().fromAvrcpAttributeArray(attrIds, attrVals);
+ aib.setDevice(device);
+ aib.setItemType(AvrcpItem.TYPE_MEDIA);
+ aib.setType(type);
+ aib.setUid(uid);
+ aib.setUuid(UUID.randomUUID().toString());
+ aib.setPlayable(true);
+ AvrcpItem item = aib.build();
+ return item;
}
- MediaItem createFromNativeFolderItem(long uid, int type, String name, int playable) {
+ AvrcpItem createFromNativeFolderItem(byte[] address, long uid, int type, String name,
+ int playable) {
if (VDBG) {
Log.d(TAG, "createFromNativeFolderItem uid: " + uid + " type " + type + " name "
+ name + " playable " + playable);
}
- MediaDescription.Builder mdb = new MediaDescription.Builder();
- Bundle mdExtra = new Bundle();
- mdExtra.putLong(MEDIA_ITEM_UID_KEY, uid);
- mdb.setExtras(mdExtra);
-
- // Generate a random UUID. We do this since database unaware TGs can send multiple
- // items with same MEDIA_ITEM_UID_KEY.
- mdb.setMediaId(UUID.randomUUID().toString());
- // Concise readable name.
- mdb.setTitle(name);
-
- return new MediaItem(mdb.build(), MediaItem.FLAG_BROWSABLE);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
+ AvrcpItem.Builder aib = new AvrcpItem.Builder();
+ aib.setDevice(device);
+ aib.setItemType(AvrcpItem.TYPE_FOLDER);
+ aib.setType(type);
+ aib.setUid(uid);
+ aib.setUuid(UUID.randomUUID().toString());
+ aib.setDisplayableName(name);
+ aib.setPlayable(playable == 0x01);
+ aib.setBrowsable(true);
+ return aib.build();
}
- AvrcpPlayer createFromNativePlayerItem(int id, String name, byte[] transportFlags,
- int playStatus, int playerType) {
+ AvrcpPlayer createFromNativePlayerItem(byte[] address, int id, String name,
+ byte[] transportFlags, int playStatus, int playerType) {
if (VDBG) {
Log.d(TAG,
"createFromNativePlayerItem name: " + name + " transportFlags "
+ transportFlags + " play status " + playStatus + " player type "
+ playerType);
}
- AvrcpPlayer player = new AvrcpPlayer(id, name, transportFlags, playStatus, playerType);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
+ AvrcpPlayer player = new AvrcpPlayer(device, id, name, transportFlags, playStatus,
+ playerType);
return player;
}
@@ -562,7 +608,7 @@
if (DBG) {
Log.d(TAG, "handleChangeFolderRsp count: " + count);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
stateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_FOLDER_PATH,
@@ -574,7 +620,7 @@
if (DBG) {
Log.d(TAG, "handleSetBrowsedPlayerRsp depth: " + depth);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
@@ -587,7 +633,7 @@
if (DBG) {
Log.d(TAG, "handleSetAddressedPlayerRsp status: " + status);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
@@ -600,7 +646,7 @@
if (DBG) {
Log.d(TAG, "handleAddressedPlayerChanged id: " + id);
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
@@ -613,7 +659,7 @@
if (DBG) {
Log.d(TAG, "handleNowPlayingContentChanged");
}
- BluetoothDevice device = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(address);
+ BluetoothDevice device = mAdapter.getRemoteDevice(address);
AvrcpControllerStateMachine stateMachine = getStateMachine(device);
if (stateMachine != null) {
@@ -680,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<>();
@@ -716,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 95867a9..df92ad7 100755
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachine.java
@@ -22,8 +22,7 @@
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
-import android.media.MediaMetadata;
-import android.media.browse.MediaBrowser.MediaItem;
+import android.net.Uri;
import android.os.Bundle;
import android.os.Message;
import android.support.v4.media.session.MediaSessionCompat;
@@ -39,6 +38,7 @@
import com.android.bluetooth.btservice.ProfileService;
import com.android.bluetooth.statemachine.State;
import com.android.bluetooth.statemachine.StateMachine;
+import com.android.internal.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.List;
@@ -58,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;
@@ -80,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;
@@ -88,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
@@ -101,12 +104,15 @@
private static final byte NOTIFICATION_RSP_TYPE_INTERIM = 0x00;
private static final byte NOTIFICATION_RSP_TYPE_CHANGED = 0x01;
+ private static BluetoothDevice sActiveDevice;
private final AudioManager mAudioManager;
private final boolean mIsVolumeFixed;
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);
@@ -206,6 +214,42 @@
public void dump(StringBuilder sb) {
ProfileService.println(sb, "mDevice: " + mDevice.getAddress() + "("
+ mDevice.getName() + ") " + this.toString());
+ ProfileService.println(sb, "isActive: " + isActive());
+ }
+
+ @VisibleForTesting
+ boolean isActive() {
+ return mDevice == sActiveDevice;
+ }
+
+ /*
+ * requestActive
+ *
+ * Set the current device active if nothing an already connected device isn't playing
+ */
+ private boolean requestActive() {
+ if (sActiveDevice == null
+ || BluetoothMediaBrowserService.getPlaybackState()
+ != PlaybackStateCompat.STATE_PLAYING) {
+ return setActive();
+ }
+ return false;
+ }
+
+ /*
+ * setActive
+ *
+ * Set this state machine as the active device and update media browse service
+ */
+ private boolean setActive() {
+ if (DBG) Log.d(TAG, "setActive" + mDevice);
+ if (A2dpSinkService.getA2dpSinkService().setActiveDeviceNative(mDeviceAddress)) {
+ sActiveDevice = mDevice;
+ BluetoothMediaBrowserService.addressedPlayerChanged(mSessionCallbacks);
+ BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
+ BluetoothMediaBrowserService.notifyChanged(mBrowseTree.mNowPlayingNode);
+ }
+ return mDevice == sActiveDevice;
}
@Override
@@ -232,7 +276,9 @@
mAddressedPlayer.setPlayStatus(PlaybackStateCompat.STATE_ERROR);
mAddressedPlayer.updateCurrentTrack(null);
mBrowseTree.mNowPlayingNode.setCached(false);
- BluetoothMediaBrowserService.notifyChanged(mBrowseTree.mNowPlayingNode);
+ if (isActive()) {
+ BluetoothMediaBrowserService.notifyChanged(mBrowseTree.mNowPlayingNode);
+ }
mService.sBrowseTree.mRootNode.removeChild(
mBrowseTree.mRootNode);
BluetoothMediaBrowserService.notifyChanged(mService
@@ -240,16 +286,41 @@
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);
}
+ private void notifyChanged(PlaybackStateCompat state) {
+ if (isActive()) {
+ BluetoothMediaBrowserService.notifyChanged(state);
+ }
+ }
+
void requestContents(BrowseTree.BrowseNode node) {
sendMessage(MESSAGE_GET_FOLDER_ITEMS, node);
-
logD("Fetching " + node);
}
+ public void playItem(BrowseTree.BrowseNode node) {
+ sendMessage(MESSAGE_PLAY_ITEM, node);
+ }
+
void nowPlayingContentChanged() {
mBrowseTree.mNowPlayingNode.setCached(false);
sendMessage(MESSAGE_GET_FOLDER_ITEMS, mBrowseTree.mNowPlayingNode);
@@ -268,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);
@@ -297,9 +371,11 @@
@Override
public void enter() {
if (mMostRecentState == BluetoothProfile.STATE_CONNECTING) {
+ requestActive();
BluetoothMediaBrowserService.addressedPlayerChanged(mSessionCallbacks);
BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
broadcastConnectionStateChanged(BluetoothProfile.STATE_CONNECTED);
+ connectCoverArt(); // only works if we have a valid PSM
} else {
logD("ReEnteringConnected");
}
@@ -331,7 +407,7 @@
case MESSAGE_PLAY_ITEM:
//Set Addressed Player
- playItem((BrowseTree.BrowseNode) msg.obj);
+ processPlayItem((BrowseTree.BrowseNode) msg.obj);
return true;
case MSG_AVRCP_PASSTHRU:
@@ -347,21 +423,26 @@
return true;
case MESSAGE_PROCESS_TRACK_CHANGED:
- mAddressedPlayer.updateCurrentTrack((MediaMetadata) msg.obj);
- BluetoothMediaBrowserService.trackChanged((MediaMetadata) msg.obj);
+ AvrcpItem track = (AvrcpItem) msg.obj;
+ downloadImageIfNeeded(track);
+ mAddressedPlayer.updateCurrentTrack(track);
+ if (isActive()) {
+ BluetoothMediaBrowserService.trackChanged(track);
+ }
return true;
case MESSAGE_PROCESS_PLAY_STATUS_CHANGED:
mAddressedPlayer.setPlayStatus(msg.arg1);
- BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
+ BluetoothMediaBrowserService.notifyChanged(
+ mAddressedPlayer.getPlaybackState());
if (mAddressedPlayer.getPlaybackState().getState()
== PlaybackStateCompat.STATE_PLAYING
&& A2dpSinkService.getFocusState() == AudioManager.AUDIOFOCUS_NONE) {
if (shouldRequestFocus()) {
mSessionCallbacks.onPrepare();
} else {
- sendMessage(MSG_AVRCP_PASSTHRU,
- AvrcpControllerService.PASS_THRU_CMD_ID_PAUSE);
+ sendMessage(MSG_AVRCP_PASSTHRU,
+ AvrcpControllerService.PASS_THRU_CMD_ID_PAUSE);
}
}
return true;
@@ -369,9 +450,10 @@
case MESSAGE_PROCESS_PLAY_POS_CHANGED:
if (msg.arg2 != -1) {
mAddressedPlayer.setPlayTime(msg.arg2);
-
- BluetoothMediaBrowserService.notifyChanged(
- mAddressedPlayer.getPlaybackState());
+ if (isActive()) {
+ BluetoothMediaBrowserService.notifyChanged(
+ mAddressedPlayer.getPlaybackState());
+ }
}
return true;
@@ -392,19 +474,44 @@
case MESSAGE_PROCESS_SUPPORTED_APPLICATION_SETTINGS:
mAddressedPlayer.setSupportedPlayerApplicationSettings(
(PlayerApplicationSettings) msg.obj);
- BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
+ notifyChanged(mAddressedPlayer.getPlaybackState());
return true;
case MESSAGE_PROCESS_CURRENT_APPLICATION_SETTINGS:
mAddressedPlayer.setCurrentPlayerApplicationSettings(
(PlayerApplicationSettings) msg.obj);
- BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
+ notifyChanged(mAddressedPlayer.getPlaybackState());
return true;
case MESSAGE_PROCESS_AVAILABLE_PLAYER_CHANGED:
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;
@@ -415,7 +522,8 @@
}
- private void playItem(BrowseTree.BrowseNode node) {
+ private void processPlayItem(BrowseTree.BrowseNode node) {
+ setActive();
if (node == null) {
Log.w(TAG, "Invalid item to play");
} else {
@@ -522,14 +630,20 @@
logD(STATE_TAG + " processMessage " + msg.what);
switch (msg.what) {
case MESSAGE_PROCESS_GET_FOLDER_ITEMS:
- ArrayList<MediaItem> folderList = (ArrayList<MediaItem>) msg.obj;
+ ArrayList<AvrcpItem> folderList = (ArrayList<AvrcpItem>) msg.obj;
int endIndicator = mBrowseNode.getExpectedChildren() - 1;
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.
- mBrowseNode.addChildren(folderList);
+ int newSize = mBrowseNode.addChildren(folderList);
+ logD("Added " + newSize + " items to the browse tree");
notifyChanged(mBrowseNode);
if (mBrowseNode.getChildrenCount() >= endIndicator || folderList.size() == 0
@@ -640,6 +754,9 @@
int start = target.getChildrenCount();
int end = Math.min(target.getExpectedChildren(), target.getChildrenCount()
+ ITEM_PAGE_SIZE) - 1;
+ logD("fetchContents(title=" + target.getID() + ", scope=" + target.getScope()
+ + ", start=" + start + ", end=" + end + ", expected="
+ + target.getExpectedChildren() + ")");
switch (target.getScope()) {
case AvrcpControllerService.BROWSE_SCOPE_PLAYER_LIST:
mService.getPlayerListNative(mDeviceAddress,
@@ -721,9 +838,13 @@
protected class Disconnecting extends State {
@Override
public void enter() {
+ disconnectCoverArt();
onBrowsingDisconnected();
- BluetoothMediaBrowserService.trackChanged(null);
- BluetoothMediaBrowserService.addressedPlayerChanged(null);
+ if (isActive()) {
+ sActiveDevice = null;
+ BluetoothMediaBrowserService.trackChanged(null);
+ BluetoothMediaBrowserService.addressedPlayerChanged(null);
+ }
broadcastConnectionStateChanged(BluetoothProfile.STATE_DISCONNECTING);
transitionTo(mDisconnected);
}
@@ -785,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() {
@@ -815,7 +950,7 @@
@Override
public void onSkipToQueueItem(long id) {
- logD("onSkipToQueueItem" + id);
+ logD("onSkipToQueueItem id=" + id);
onPrepare();
BrowseTree.BrowseNode node = mBrowseTree.getTrackFromNowPlayingList((int) id);
if (node != null) {
@@ -856,7 +991,14 @@
// Play the item if possible.
onPrepare();
BrowseTree.BrowseNode node = mBrowseTree.findBrowseNodeByID(mediaId);
- sendMessage(MESSAGE_PLAY_ITEM, node);
+ if (node != null) {
+ // node was found on this bluetooth device
+ sendMessage(MESSAGE_PLAY_ITEM, node);
+ } else {
+ // node was not found on this device, pause here, and play on another device
+ sendMessage(MSG_AVRCP_PASSTHRU, AvrcpControllerService.PASS_THRU_CMD_ID_PAUSE);
+ mService.playItem(mediaId);
+ }
}
@Override
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
new file mode 100644
index 0000000..1ddcfda
--- /dev/null
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpItem.java
@@ -0,0 +1,488 @@
+/*
+ * Copyright (C) 2020 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.net.Uri;
+import android.os.Bundle;
+import android.support.v4.media.MediaBrowserCompat.MediaItem;
+import android.support.v4.media.MediaDescriptionCompat;
+import android.support.v4.media.MediaMetadataCompat;
+import android.util.Log;
+
+/**
+ * An object representing a single item returned from an AVRCP folder listing in the VFS scope.
+ *
+ * This object knows how to turn itself into each of the Android Media Framework objects so the
+ * metadata can easily be shared with the system.
+ */
+public class AvrcpItem {
+ private static final String TAG = "AvrcpItem";
+ private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+
+ // AVRCP Specification defined item types
+ public static final int TYPE_PLAYER = 0x1;
+ public static final int TYPE_FOLDER = 0x2;
+ public static final int TYPE_MEDIA = 0x3;
+
+ // 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;
+ public static final int FOLDER_ARTISTS = 0x03;
+ public static final int FOLDER_GENRES = 0x04;
+ public static final int FOLDER_PLAYLISTS = 0x05;
+ public static final int FOLDER_YEARS = 0x06;
+
+ // AVRCP Specification defined media item sub types
+ public static final int MEDIA_AUDIO = 0x00;
+ public static final int MEDIA_VIDEO = 0x01;
+
+ // Keys for packaging extra data with MediaItems
+ public static final String AVRCP_ITEM_KEY_UID = "avrcp-item-key-uid";
+
+ // Type of item, one of [TYPE_PLAYER, TYPE_FOLDER, TYPE_MEDIA]
+ 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
+ private BluetoothDevice mDevice;
+
+ // AVRCP Specification defined metadata for browsed media items
+ private long mUid;
+ private String mDisplayableName;
+
+ // AVRCP Specification defined set of available attributes
+ private String mTitle;
+ private String mArtistName;
+ private String mAlbumName;
+ private long mTrackNumber;
+ private long mTotalNumberOfTracks;
+ private String mGenre;
+ private long mPlayingTime;
+ private String mCoverArtHandle;
+
+ private boolean mPlayable = false;
+ private boolean mBrowsable = false;
+
+ // Our own book keeping value since database unaware players sometimes send repeat UIDs.
+ private String mUuid;
+
+ // Our owned internal Uri value that points to downloaded cover art image
+ private Uri mImageUri;
+
+ private AvrcpItem() {
+ }
+
+ public BluetoothDevice getDevice() {
+ return mDevice;
+ }
+
+ public long getUid() {
+ return mUid;
+ }
+
+ public String getUuid() {
+ return mUuid;
+ }
+
+ public String getDisplayableName() {
+ return mDisplayableName;
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getArtistName() {
+ return mArtistName;
+ }
+
+ public String getAlbumName() {
+ return mAlbumName;
+ }
+
+ public long getTrackNumber() {
+ return mTrackNumber;
+ }
+
+ public long getTotalNumberOfTracks() {
+ return mTotalNumberOfTracks;
+ }
+
+ public boolean isPlayable() {
+ return mPlayable;
+ }
+
+ public boolean isBrowsable() {
+ return mBrowsable;
+ }
+
+ public String getCoverArtHandle() {
+ return mCoverArtHandle;
+ }
+
+ public synchronized Uri getCoverArtLocation() {
+ return mImageUri;
+ }
+
+ public synchronized void setCoverArtLocation(Uri uri) {
+ mImageUri = uri;
+ }
+
+ /**
+ * Convert this item an Android Media Framework MediaMetadata
+ */
+ public MediaMetadataCompat toMediaMetadata() {
+ MediaMetadataCompat.Builder metaDataBuilder = new MediaMetadataCompat.Builder();
+ Uri coverArtUri = getCoverArtLocation();
+ String uriString = coverArtUri != null ? coverArtUri.toString() : null;
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mUuid);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, mDisplayableName);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, mTitle);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, mArtistName);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, mAlbumName);
+ metaDataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, mTrackNumber);
+ metaDataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, mTotalNumberOfTracks);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_GENRE, mGenre);
+ metaDataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, mPlayingTime);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, uriString);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, uriString);
+ metaDataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, uriString);
+ if (mItemType == TYPE_FOLDER) {
+ metaDataBuilder.putLong(MediaMetadataCompat.METADATA_KEY_BT_FOLDER_TYPE, mType);
+ }
+ return metaDataBuilder.build();
+ }
+
+ /**
+ * Convert this item an Android Media Framework MediaItem
+ */
+ public MediaItem toMediaItem() {
+ MediaDescriptionCompat.Builder descriptionBuilder = new MediaDescriptionCompat.Builder();
+
+ descriptionBuilder.setMediaId(mUuid);
+
+ String name = null;
+ if (mDisplayableName != null) {
+ name = mDisplayableName;
+ } else if (mTitle != null) {
+ name = mTitle;
+ }
+ descriptionBuilder.setTitle(name);
+
+ descriptionBuilder.setIconUri(getCoverArtLocation());
+
+ Bundle extras = new Bundle();
+ extras.putLong(AVRCP_ITEM_KEY_UID, mUid);
+ descriptionBuilder.setExtras(extras);
+
+ int flags = 0x0;
+ if (mPlayable) flags |= MediaItem.FLAG_PLAYABLE;
+ if (mBrowsable) flags |= MediaItem.FLAG_BROWSABLE;
+
+ return new MediaItem(descriptionBuilder.build(), flags);
+ }
+
+ @Override
+ public String toString() {
+ return "AvrcpItem{mUuid=" + mUuid + ", mUid=" + mUid + ", mItemType=" + mItemType
+ + ", mType=" + mType + ", mDisplayableName=" + mDisplayableName
+ + ", mTitle=" + mTitle + ", mPlayable=" + mPlayable + ", mBrowsable="
+ + mBrowsable + ", mCoverArtHandle=" + getCoverArtHandle() + "}";
+ }
+
+ /**
+ * Builder for an AvrcpItem
+ */
+ public static class Builder {
+ private static final String TAG = "AvrcpItem.Builder";
+ private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
+
+ // Attribute ID Values from AVRCP Specification
+ private static final int MEDIA_ATTRIBUTE_TITLE = 0x01;
+ private static final int MEDIA_ATTRIBUTE_ARTIST_NAME = 0x02;
+ private static final int MEDIA_ATTRIBUTE_ALBUM_NAME = 0x03;
+ private static final int MEDIA_ATTRIBUTE_TRACK_NUMBER = 0x04;
+ private static final int MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER = 0x05;
+ private static final int MEDIA_ATTRIBUTE_GENRE = 0x06;
+ private static final int MEDIA_ATTRIBUTE_PLAYING_TIME = 0x07;
+ private static final int MEDIA_ATTRIBUTE_COVER_ART_HANDLE = 0x08;
+
+ private AvrcpItem mAvrcpItem = new AvrcpItem();
+
+ /**
+ * Initialize all relevant AvrcpItem internals from the AVRCP specification defined set of
+ * item attributes
+ *
+ * @param attrIds The array of AVRCP specification defined IDs in the order they match to
+ * the value string attrMap
+ * @param attrMap The mapped values for each ID
+ * @return This object so you can continue building
+ */
+ public Builder fromAvrcpAttributeArray(int[] attrIds, String[] attrMap) {
+ int attributeCount = Math.max(attrIds.length, attrMap.length);
+ for (int i = 0; i < attributeCount; i++) {
+ if (DBG) Log.d(TAG, attrIds[i] + " = " + attrMap[i]);
+ switch (attrIds[i]) {
+ case MEDIA_ATTRIBUTE_TITLE:
+ mAvrcpItem.mTitle = attrMap[i];
+ break;
+ case MEDIA_ATTRIBUTE_ARTIST_NAME:
+ mAvrcpItem.mArtistName = attrMap[i];
+ break;
+ case MEDIA_ATTRIBUTE_ALBUM_NAME:
+ mAvrcpItem.mAlbumName = attrMap[i];
+ break;
+ case MEDIA_ATTRIBUTE_TRACK_NUMBER:
+ try {
+ mAvrcpItem.mTrackNumber = Long.valueOf(attrMap[i]);
+ } catch (java.lang.NumberFormatException e) {
+ // If Track Number doesn't parse, leave it unset
+ }
+ break;
+ case MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER:
+ try {
+ mAvrcpItem.mTotalNumberOfTracks = Long.valueOf(attrMap[i]);
+ } catch (java.lang.NumberFormatException e) {
+ // If Total Track Number doesn't parse, leave it unset
+ }
+ break;
+ case MEDIA_ATTRIBUTE_GENRE:
+ mAvrcpItem.mGenre = attrMap[i];
+ break;
+ case MEDIA_ATTRIBUTE_PLAYING_TIME:
+ try {
+ mAvrcpItem.mPlayingTime = Long.valueOf(attrMap[i]);
+ } catch (java.lang.NumberFormatException e) {
+ // If Playing Time doesn't parse, leave it unset
+ }
+ break;
+ case MEDIA_ATTRIBUTE_COVER_ART_HANDLE:
+ mAvrcpItem.mCoverArtHandle = attrMap[i];
+ break;
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Set the item type for the AvrcpItem you are building
+ *
+ * Type can be one of PLAYER, FOLDER, or MEDIA
+ *
+ * @param itemType The item type as an AvrcpItem.* type value
+ * @return This object, so you can continue building
+ */
+ public Builder setItemType(int itemType) {
+ mAvrcpItem.mItemType = itemType;
+ return this;
+ }
+
+ /**
+ * Set the type for the AvrcpItem you are building
+ *
+ * This is the type of the PLAYER, FOLDER, or MEDIA item.
+ *
+ * @param type The type as one of the AvrcpItem.MEDIA_* or FOLDER_* types
+ * @return This object, so you can continue building
+ */
+ public Builder setType(int type) {
+ mAvrcpItem.mType = type;
+ return this;
+ }
+
+ /**
+ * Set the device for the AvrcpItem you are building
+ *
+ * @param device The BluetoothDevice object that this item came from
+ * @return This object, so you can continue building
+ */
+ public Builder setDevice(BluetoothDevice device) {
+ mAvrcpItem.mDevice = device;
+ return this;
+ }
+
+ /**
+ * Note that the AvrcpItem you are building is playable
+ *
+ * @param playable True if playable, false otherwise
+ * @return This object, so you can continue building
+ */
+ public Builder setPlayable(boolean playable) {
+ mAvrcpItem.mPlayable = playable;
+ return this;
+ }
+
+ /**
+ * Note that the AvrcpItem you are building is browsable
+ *
+ * @param browsable True if browsable, false otherwise
+ * @return This object, so you can continue building
+ */
+ public Builder setBrowsable(boolean browsable) {
+ mAvrcpItem.mBrowsable = browsable;
+ return this;
+ }
+
+ /**
+ * Set the AVRCP defined UID assigned to the AvrcpItem you are building
+ *
+ * @param uid The UID given to this item by the remote device
+ * @return This object, so you can continue building
+ */
+ public Builder setUid(long uid) {
+ mAvrcpItem.mUid = uid;
+ return this;
+ }
+
+ /**
+ * Set the UUID you wish to associate with the AvrcpItem you are building
+ *
+ * @param uuid A string UUID value
+ * @return This object, so you can continue building
+ */
+ public Builder setUuid(String uuid) {
+ mAvrcpItem.mUuid = uuid;
+ return this;
+ }
+
+ /**
+ * Set the displayable name for the AvrcpItem you are building
+ *
+ * @param displayableName A string representing a friendly, displayable name
+ * @return This object, so you can continue building
+ */
+ public Builder setDisplayableName(String displayableName) {
+ mAvrcpItem.mDisplayableName = displayableName;
+ return this;
+ }
+
+ /**
+ * Set the title for the AvrcpItem you are building
+ *
+ * @param title The title as a string
+ * @return This object, so you can continue building
+ */
+ public Builder setTitle(String title) {
+ mAvrcpItem.mTitle = title;
+ return this;
+ }
+
+ /**
+ * Set the artist name for the AvrcpItem you are building
+ *
+ * @param artistName The artist name as a string
+ * @return This object, so you can continue building
+ */
+ public Builder setArtistName(String artistName) {
+ mAvrcpItem.mArtistName = artistName;
+ return this;
+ }
+
+ /**
+ * Set the album name for the AvrcpItem you are building
+ *
+ * @param albumName The album name as a string
+ * @return This object, so you can continue building
+ */
+ public Builder setAlbumName(String albumName) {
+ mAvrcpItem.mAlbumName = albumName;
+ return this;
+ }
+
+ /**
+ * Set the track number for the AvrcpItem you are building
+ *
+ * @param trackNumber The track number
+ * @return This object, so you can continue building
+ */
+ public Builder setTrackNumber(long trackNumber) {
+ mAvrcpItem.mTrackNumber = trackNumber;
+ return this;
+ }
+
+ /**
+ * Set the total number of tracks on the playlist or album that this AvrcpItem is on
+ *
+ * @param totalNumberOfTracks The total number of tracks along side this item
+ * @return This object, so you can continue building
+ */
+ public Builder setTotalNumberOfTracks(long totalNumberOfTracks) {
+ mAvrcpItem.mTotalNumberOfTracks = totalNumberOfTracks;
+ return this;
+ }
+
+ /**
+ * Set the genre name for the AvrcpItem you are building
+ *
+ * @param genre The genre as a string
+ * @return This object, so you can continue building
+ */
+ public Builder setGenre(String genre) {
+ mAvrcpItem.mGenre = genre;
+ return this;
+ }
+
+ /**
+ * Set the total playing time for the AvrcpItem you are building
+ *
+ * @param playingTime The playing time in seconds
+ * @return This object, so you can continue building
+ */
+ public Builder setPlayingTime(long playingTime) {
+ mAvrcpItem.mPlayingTime = playingTime;
+ return this;
+ }
+
+ /**
+ * Set the cover art handle for the AvrcpItem you are building
+ *
+ * @param coverArtHandle The cover art image handle provided by a remote device
+ * @return This object, so you can continue building
+ */
+ public Builder setCoverArtHandle(String coverArtHandle) {
+ mAvrcpItem.mCoverArtHandle = coverArtHandle;
+ return this;
+ }
+
+ /**
+ * Set the location of the downloaded cover art for the AvrcpItem you are building
+ *
+ * @param uri The URI where our storage has placed the image associated with this item
+ * @return This object, so you can continue building
+ */
+ public Builder setCoverArtLocation(Uri uri) {
+ mAvrcpItem.setCoverArtLocation(uri);
+ return this;
+ }
+
+ /**
+ * Build the AvrcpItem
+ *
+ * @return An AvrcpItem object
+ */
+ public AvrcpItem build() {
+ return mAvrcpItem;
+ }
+ }
+}
diff --git a/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java b/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java
index 4736acf..ba4beaa 100644
--- a/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java
+++ b/src/com/android/bluetooth/avrcpcontroller/AvrcpPlayer.java
@@ -16,7 +16,8 @@
package com.android.bluetooth.avrcpcontroller;
-import android.media.MediaMetadata;
+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;
@@ -42,6 +43,7 @@
public static final int FEATURE_PREVIOUS = 48;
public static final int FEATURE_BROWSING = 59;
+ private BluetoothDevice mDevice;
private int mPlayStatus = PlaybackStateCompat.STATE_NONE;
private long mPlayTime = PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN;
private long mPlayTimeUpdate = 0;
@@ -51,13 +53,14 @@
private int mPlayerType;
private byte[] mPlayerFeatures = new byte[16];
private long mAvailableActions = PlaybackStateCompat.ACTION_PREPARE;
- private MediaMetadata mCurrentTrack;
+ private AvrcpItem mCurrentTrack;
private PlaybackStateCompat mPlaybackStateCompat;
private PlayerApplicationSettings mSupportedPlayerApplicationSettings =
new PlayerApplicationSettings();
private PlayerApplicationSettings mCurrentPlayerApplicationSettings;
AvrcpPlayer() {
+ mDevice = null;
mId = INVALID_ID;
//Set Default Actions in case Player data isn't available.
mAvailableActions = PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_PLAY
@@ -69,7 +72,9 @@
mPlaybackStateCompat = playbackStateBuilder.build();
}
- AvrcpPlayer(int id, String name, byte[] playerFeatures, int playStatus, int playerType) {
+ AvrcpPlayer(BluetoothDevice device, int id, String name, byte[] playerFeatures, int playStatus,
+ int playerType) {
+ mDevice = device;
mId = id;
mName = name;
mPlayStatus = playStatus;
@@ -81,6 +86,10 @@
updateAvailableActions();
}
+ public BluetoothDevice getDevice() {
+ return mDevice;
+ }
+
public int getId() {
return mId;
}
@@ -166,9 +175,9 @@
return mPlaybackStateCompat;
}
- public synchronized void updateCurrentTrack(MediaMetadata update) {
+ public synchronized void updateCurrentTrack(AvrcpItem update) {
if (update != null) {
- long trackNumber = update.getLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER);
+ long trackNumber = update.getTrackNumber();
mPlaybackStateCompat = new PlaybackStateCompat.Builder(
mPlaybackStateCompat).setActiveQueueItemId(
trackNumber - 1).build();
@@ -176,7 +185,16 @@
mCurrentTrack = update;
}
- public synchronized MediaMetadata getCurrentTrack() {
+ 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/BluetoothMediaBrowserService.java b/src/com/android/bluetooth/avrcpcontroller/BluetoothMediaBrowserService.java
index df88b80..a47932d 100644
--- a/src/com/android/bluetooth/avrcpcontroller/BluetoothMediaBrowserService.java
+++ b/src/com/android/bluetooth/avrcpcontroller/BluetoothMediaBrowserService.java
@@ -18,12 +18,8 @@
import android.app.PendingIntent;
import android.content.Intent;
-import android.media.MediaMetadata;
-import android.media.browse.MediaBrowser.MediaItem;
import android.os.Bundle;
-import android.support.v4.media.MediaBrowserCompat;
-import android.support.v4.media.MediaDescriptionCompat;
-import android.support.v4.media.MediaMetadataCompat;
+import android.support.v4.media.MediaBrowserCompat.MediaItem;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
@@ -137,10 +133,9 @@
@Override
public synchronized void onLoadChildren(final String parentMediaId,
- final Result<List<MediaBrowserCompat.MediaItem>> result) {
+ final Result<List<MediaItem>> result) {
if (DBG) Log.d(TAG, "onLoadChildren parentMediaId=" + parentMediaId);
- List<MediaBrowserCompat.MediaItem> contents =
- MediaBrowserCompat.MediaItem.fromMediaItemList(getContents(parentMediaId));
+ List<MediaItem> contents = getContents(parentMediaId);
if (contents == null) {
result.detach();
} else {
@@ -161,7 +156,7 @@
if (songList != null) {
for (MediaItem song : songList) {
mMediaQueue.add(new MediaSessionCompat.QueueItem(
- MediaDescriptionCompat.fromMediaDescription(song.getDescription()),
+ song.getDescription(),
mMediaQueue.size()));
}
}
@@ -189,10 +184,15 @@
}
}
- static synchronized void trackChanged(MediaMetadata mediaMetadata) {
+ static synchronized void trackChanged(AvrcpItem track) {
+ if (DBG) Log.d(TAG, "trackChanged setMetadata=" + track);
if (sBluetoothMediaBrowserService != null) {
- sBluetoothMediaBrowserService.mSession.setMetadata(
- MediaMetadataCompat.fromMediaMetadata(mediaMetadata));
+ if (track != null) {
+ sBluetoothMediaBrowserService.mSession.setMetadata(track.toMediaMetadata());
+ } else {
+ sBluetoothMediaBrowserService.mSession.setMetadata(null);
+ }
+
} else {
Log.w(TAG, "trackChanged Unavailable");
}
diff --git a/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java b/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java
index 923282d..c9737e3 100644
--- a/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java
+++ b/src/com/android/bluetooth/avrcpcontroller/BrowseTree.java
@@ -17,10 +17,8 @@
package com.android.bluetooth.avrcpcontroller;
import android.bluetooth.BluetoothDevice;
-import android.media.MediaDescription;
-import android.media.browse.MediaBrowser;
-import android.media.browse.MediaBrowser.MediaItem;
-import android.os.Bundle;
+import android.net.Uri;
+import android.support.v4.media.MediaBrowserCompat.MediaItem;
import android.util.Log;
import java.util.ArrayList;
@@ -28,17 +26,22 @@
import java.util.List;
import java.util.UUID;
-// Browsing hierarchy.
-// Root:
-// Player1:
-// Now_Playing:
-// MediaItem1
-// MediaItem2
-// Folder1
-// Folder2
-// ....
-// Player2
-// ....
+/**
+ * An object that holds the browse tree of available media from a remote device.
+ *
+ * Browsing hierarchy follows the AVRCP specification's description of various scopes and
+ * looks like follows:
+ * Root:
+ * Player1:
+ * Now_Playing:
+ * MediaItem1
+ * MediaItem2
+ * Folder1
+ * Folder2
+ * ....
+ * Player2
+ * ....
+ */
public class BrowseTree {
private static final String TAG = "BrowseTree";
private static final boolean DBG = Log.isLoggable(TAG, Log.DEBUG);
@@ -59,28 +62,29 @@
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 MediaItem(new MediaDescription.Builder()
- .setMediaId(ROOT).setTitle(ROOT).build(), MediaItem.FLAG_BROWSABLE));
+ mRootNode = new BrowseNode(new AvrcpItem.Builder()
+ .setUuid(ROOT).setTitle(ROOT).setBrowsable(true).build());
mRootNode.setCached(true);
} else {
- mRootNode = new BrowseNode(new MediaItem(new MediaDescription.Builder()
- .setMediaId(ROOT + device.getAddress().toString()).setTitle(
- device.getName()).build(), MediaItem.FLAG_BROWSABLE));
- mRootNode.mDevice = device;
-
+ mRootNode = new BrowseNode(new AvrcpItem.Builder().setDevice(device)
+ .setUuid(ROOT + device.getAddress().toString())
+ .setTitle(device.getName()).setBrowsable(true).build());
}
mRootNode.mBrowseScope = AvrcpControllerService.BROWSE_SCOPE_PLAYER_LIST;
mRootNode.setExpectedChildren(255);
- mNavigateUpNode = new BrowseNode(new MediaItem(new MediaDescription.Builder()
- .setMediaId(UP).setTitle(UP).build(),
- MediaItem.FLAG_BROWSABLE));
+ mNavigateUpNode = new BrowseNode(new AvrcpItem.Builder()
+ .setUuid(UP).setTitle(UP).setBrowsable(true).build());
- mNowPlayingNode = new BrowseNode(new MediaItem(new MediaDescription.Builder()
- .setMediaId(NOW_PLAYING_PREFIX)
- .setTitle(NOW_PLAYING_PREFIX).build(), MediaItem.FLAG_BROWSABLE));
+ mNowPlayingNode = new BrowseNode(new AvrcpItem.Builder()
+ .setUuid(NOW_PLAYING_PREFIX).setTitle(NOW_PLAYING_PREFIX)
+ .setBrowsable(true).build());
mNowPlayingNode.mBrowseScope = AvrcpControllerService.BROWSE_SCOPE_NOW_PLAYING;
mNowPlayingNode.setExpectedChildren(255);
mBrowseMap.put(ROOT, mRootNode);
@@ -92,6 +96,7 @@
public void clear() {
// Clearing the map should garbage collect everything.
mBrowseMap.clear();
+ mCoverArtMap.clear();
}
void onConnected(BluetoothDevice device) {
@@ -105,11 +110,8 @@
// Each node of the tree is represented by Folder ID, Folder Name and the children.
class BrowseNode {
- // MediaItem to store the media related details.
- MediaItem mItem;
-
- BluetoothDevice mDevice;
- long mBluetoothId;
+ // AvrcpItem to store the media related details.
+ AvrcpItem mItem;
// Type of this browse node.
// Since Media APIs do not define the player separately we define that
@@ -127,45 +129,43 @@
private final List<BrowseNode> mChildren = new ArrayList<BrowseNode>();
private int mExpectedChildrenCount;
- BrowseNode(MediaItem item) {
+ BrowseNode(AvrcpItem item) {
mItem = item;
- Bundle extras = mItem.getDescription().getExtras();
- if (extras != null) {
- mBluetoothId = extras.getLong(AvrcpControllerService.MEDIA_ITEM_UID_KEY);
- }
}
BrowseNode(AvrcpPlayer player) {
mIsPlayer = true;
// Transform the player into a item.
- MediaDescription.Builder mdb = new MediaDescription.Builder();
- String playerKey = PLAYER_PREFIX + player.getId();
- mBluetoothId = player.getId();
-
- mdb.setMediaId(UUID.randomUUID().toString());
- mdb.setTitle(player.getName());
- int mediaItemFlags = player.supportsFeature(AvrcpPlayer.FEATURE_BROWSING)
- ? MediaBrowser.MediaItem.FLAG_BROWSABLE : 0;
- mItem = new MediaBrowser.MediaItem(mdb.build(), mediaItemFlags);
+ AvrcpItem.Builder aid = new AvrcpItem.Builder();
+ aid.setDevice(player.getDevice());
+ aid.setUid(player.getId());
+ aid.setUuid(UUID.randomUUID().toString());
+ aid.setDisplayableName(player.getName());
+ aid.setTitle(player.getName());
+ aid.setBrowsable(player.supportsFeature(AvrcpPlayer.FEATURE_BROWSING));
+ mItem = aid.build();
}
BrowseNode(BluetoothDevice device) {
- boolean mIsPlayer = true;
- mDevice = device;
- MediaDescription.Builder mdb = new MediaDescription.Builder();
+ mIsPlayer = true;
String playerKey = PLAYER_PREFIX + device.getAddress().toString();
- mdb.setMediaId(playerKey);
- mdb.setTitle(device.getName());
- int mediaItemFlags = MediaBrowser.MediaItem.FLAG_BROWSABLE;
- mItem = new MediaBrowser.MediaItem(mdb.build(), mediaItemFlags);
+
+ AvrcpItem.Builder aid = new AvrcpItem.Builder();
+ aid.setDevice(device);
+ aid.setUuid(playerKey);
+ aid.setDisplayableName(device.getName());
+ aid.setTitle(device.getName());
+ aid.setBrowsable(true);
+ mItem = aid.build();
}
private BrowseNode(String name) {
- MediaDescription.Builder mdb = new MediaDescription.Builder();
- mdb.setMediaId(name);
- mdb.setTitle(name);
- mItem = new MediaBrowser.MediaItem(mdb.build(), MediaBrowser.MediaItem.FLAG_BROWSABLE);
+ AvrcpItem.Builder aid = new AvrcpItem.Builder();
+ aid.setUuid(name);
+ aid.setDisplayableName(name);
+ aid.setTitle(name);
+ mItem = aid.build();
}
synchronized void setExpectedChildren(int count) {
@@ -179,8 +179,8 @@
synchronized <E> int addChildren(List<E> newChildren) {
for (E child : newChildren) {
BrowseNode currentNode = null;
- if (child instanceof MediaItem) {
- currentNode = new BrowseNode((MediaItem) child);
+ if (child instanceof AvrcpItem) {
+ currentNode = new BrowseNode((AvrcpItem) child);
} else if (child instanceof AvrcpPlayer) {
currentNode = new BrowseNode((AvrcpPlayer) child);
}
@@ -195,9 +195,6 @@
if (this.mBrowseScope == AvrcpControllerService.BROWSE_SCOPE_NOW_PLAYING) {
node.mBrowseScope = this.mBrowseScope;
}
- if (node.mDevice == null) {
- node.mDevice = this.mDevice;
- }
mChildren.add(node);
mBrowseMap.put(node.getID(), node);
return true;
@@ -208,6 +205,7 @@
synchronized void removeChild(BrowseNode node) {
mChildren.remove(node);
mBrowseMap.remove(node.getID());
+ indicateCoverArtUnused(node.getID(), node.getCoverArtHandle());
}
synchronized int getChildrenCount() {
@@ -229,6 +227,18 @@
return mParent;
}
+ synchronized BluetoothDevice getDevice() {
+ 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());
@@ -258,6 +268,7 @@
if (!cached) {
for (BrowseNode child : mChildren) {
mBrowseMap.remove(child.getID());
+ indicateCoverArtUnused(child.getID(), child.getCoverArtHandle());
}
mChildren.clear();
}
@@ -265,7 +276,7 @@
// Fetch the Unique UID for this item, this is unique across all elements in the tree.
synchronized String getID() {
- return mItem.getDescription().getMediaId();
+ return mItem.getUuid();
}
// Get the BT Player ID associated with this node.
@@ -285,11 +296,11 @@
}
synchronized long getBluetoothID() {
- return mBluetoothId;
+ return mItem.getUid();
}
synchronized MediaItem getMediaItem() {
- return mItem;
+ return mItem.toMediaItem();
}
synchronized boolean isPlayer() {
@@ -312,7 +323,7 @@
@Override
public synchronized String toString() {
if (VDBG) {
- String serialized = "[ Name: " + mItem.getDescription().getTitle()
+ String serialized = "[ Name: " + mItem.getTitle()
+ " Scope:" + mBrowseScope + " expected Children: "
+ mExpectedChildrenCount + "] ";
for (BrowseNode node : mChildren) {
@@ -401,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/TrackInfo.java b/src/com/android/bluetooth/avrcpcontroller/TrackInfo.java
deleted file mode 100644
index fd1b784..0000000
--- a/src/com/android/bluetooth/avrcpcontroller/TrackInfo.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright (C) 2016 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.media.MediaMetadata;
-
-final class TrackInfo {
- /*
- *Element Id Values for GetMetaData from JNI
- */
- private static final int MEDIA_ATTRIBUTE_TITLE = 0x01;
- private static final int MEDIA_ATTRIBUTE_ARTIST_NAME = 0x02;
- private static final int MEDIA_ATTRIBUTE_ALBUM_NAME = 0x03;
- private static final int MEDIA_ATTRIBUTE_TRACK_NUMBER = 0x04;
- private static final int MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER = 0x05;
- private static final int MEDIA_ATTRIBUTE_GENRE = 0x06;
- private static final int MEDIA_ATTRIBUTE_PLAYING_TIME = 0x07;
-
- static MediaMetadata getMetadata(int[] attrIds, String[] attrMap) {
- MediaMetadata.Builder metaDataBuilder = new MediaMetadata.Builder();
- int attributeCount = Math.max(attrIds.length, attrMap.length);
- for (int i = 0; i < attributeCount; i++) {
- switch (attrIds[i]) {
- case MEDIA_ATTRIBUTE_TITLE:
- metaDataBuilder.putString(MediaMetadata.METADATA_KEY_TITLE, attrMap[i]);
- break;
- case MEDIA_ATTRIBUTE_ARTIST_NAME:
- metaDataBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, attrMap[i]);
- break;
- case MEDIA_ATTRIBUTE_ALBUM_NAME:
- metaDataBuilder.putString(MediaMetadata.METADATA_KEY_ALBUM, attrMap[i]);
- break;
- case MEDIA_ATTRIBUTE_TRACK_NUMBER:
- try {
- metaDataBuilder.putLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER,
- Long.valueOf(attrMap[i]));
- } catch (java.lang.NumberFormatException e) {
- // If Track Number doesn't parse, leave it unset
- }
- break;
- case MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER:
- try {
- metaDataBuilder.putLong(MediaMetadata.METADATA_KEY_NUM_TRACKS,
- Long.valueOf(attrMap[i]));
- } catch (java.lang.NumberFormatException e) {
- // If Total Track Number doesn't parse, leave it unset
- }
- break;
- case MEDIA_ATTRIBUTE_GENRE:
- metaDataBuilder.putString(MediaMetadata.METADATA_KEY_GENRE, attrMap[i]);
- break;
- case MEDIA_ATTRIBUTE_PLAYING_TIME:
- try {
- metaDataBuilder.putLong(MediaMetadata.METADATA_KEY_DURATION,
- Long.valueOf(attrMap[i]));
- } catch (java.lang.NumberFormatException e) {
- // If Playing Time doesn't parse, leave it unset
- }
- break;
- }
- }
- return metaDataBuilder.build();
- }
-}
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;
+ }
+}
diff --git a/src/com/android/bluetooth/btservice/BondStateMachine.java b/src/com/android/bluetooth/btservice/BondStateMachine.java
index ab49884..ca18140 100644
--- a/src/com/android/bluetooth/btservice/BondStateMachine.java
+++ b/src/com/android/bluetooth/btservice/BondStateMachine.java
@@ -100,7 +100,7 @@
return bsm;
}
- public void doQuit() {
+ public synchronized void doQuit() {
quitNow();
}
@@ -122,7 +122,7 @@
}
@Override
- public boolean processMessage(Message msg) {
+ public synchronized boolean processMessage(Message msg) {
BluetoothDevice dev = (BluetoothDevice) msg.obj;
@@ -178,7 +178,7 @@
}
@Override
- public boolean processMessage(Message msg) {
+ public synchronized boolean processMessage(Message msg) {
BluetoothDevice dev = (BluetoothDevice) msg.obj;
DeviceProperties devProp = mRemoteDevices.getDeviceProperties(dev);
boolean result = false;
diff --git a/src/com/android/bluetooth/hfp/HeadsetPhoneState.java b/src/com/android/bluetooth/hfp/HeadsetPhoneState.java
index 360e1c6..d1dbe32 100644
--- a/src/com/android/bluetooth/hfp/HeadsetPhoneState.java
+++ b/src/com/android/bluetooth/hfp/HeadsetPhoneState.java
@@ -17,10 +17,7 @@
package com.android.bluetooth.hfp;
import android.bluetooth.BluetoothDevice;
-import android.content.BroadcastReceiver;
import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
import android.os.Handler;
import android.telephony.PhoneStateListener;
import android.telephony.ServiceState;
@@ -57,9 +54,6 @@
// HFP 1.6 CIND service value
private int mCindService = HeadsetHalConstants.NETWORK_STATE_NOT_AVAILABLE;
- // Check this before sending out service state to the device -- if the SIM isn't fully
- // loaded, don't expose that the network is available.
- private boolean mIsSimStateLoaded;
// Number of active (foreground) calls
private int mNumActive;
// Current Call Setup State
@@ -207,6 +201,10 @@
mNumHeld = numHeldCall;
}
+ ServiceState getServiceState() {
+ return mServiceState;
+ }
+
int getCindSignal() {
return mCindSignal;
}
@@ -237,17 +235,15 @@
}
private synchronized void sendDeviceStateChanged() {
- int service =
- mIsSimStateLoaded ? mCindService : HeadsetHalConstants.NETWORK_STATE_NOT_AVAILABLE;
// When out of service, send signal strength as 0. Some devices don't
// use the service indicator, but only the signal indicator
- int signal = service == HeadsetHalConstants.NETWORK_STATE_AVAILABLE ? mCindSignal : 0;
+ int signal = mCindService == HeadsetHalConstants.NETWORK_STATE_AVAILABLE ? mCindSignal : 0;
- Log.d(TAG, "sendDeviceStateChanged. mService=" + mCindService + " mIsSimStateLoaded="
- + mIsSimStateLoaded + " mSignal=" + signal + " mRoam=" + mCindRoam
+ Log.d(TAG, "sendDeviceStateChanged. mService=" + mCindService
+ + " mSignal=" + mCindSignal + " mRoam=" + mCindRoam
+ " mBatteryCharge=" + mCindBatteryCharge);
mHeadsetService.onDeviceStateChanged(
- new HeadsetDeviceState(service, mCindRoam, signal, mCindBatteryCharge));
+ new HeadsetDeviceState(mCindService, mCindRoam, signal, mCindBatteryCharge));
}
private class HeadsetPhoneStateOnSubscriptionChangedListener
@@ -261,6 +257,7 @@
synchronized (mDeviceEventMap) {
int simState = mTelephonyManager.getSimState();
if (simState != TelephonyManager.SIM_STATE_READY) {
+ mServiceState = null;
mCindSignal = 0;
mCindService = HeadsetHalConstants.NETWORK_STATE_NOT_AVAILABLE;
sendDeviceStateChanged();
@@ -291,32 +288,7 @@
}
mCindService = cindService;
mCindRoam = newRoam;
-
- // If this is due to a SIM insertion, we want to defer sending device state changed
- // until all the SIM config is loaded.
- if (cindService == HeadsetHalConstants.NETWORK_STATE_NOT_AVAILABLE) {
- mIsSimStateLoaded = false;
- sendDeviceStateChanged();
- return;
- }
- IntentFilter simStateChangedFilter =
- new IntentFilter(Intent.ACTION_SIM_STATE_CHANGED);
- mHeadsetService.registerReceiver(new BroadcastReceiver() {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (Intent.ACTION_SIM_STATE_CHANGED.equals(intent.getAction())) {
- // This is a sticky broadcast, so if it's already been loaded,
- // this'll execute immediately.
- if (Intent.SIM_STATE_LOADED.equals(
- intent.getStringExtra(Intent.EXTRA_SIM_STATE))) {
- mIsSimStateLoaded = true;
- sendDeviceStateChanged();
- mHeadsetService.unregisterReceiver(this);
- }
- }
- }
- }, simStateChangedFilter);
-
+ sendDeviceStateChanged();
}
@Override
diff --git a/src/com/android/bluetooth/hfp/HeadsetStateMachine.java b/src/com/android/bluetooth/hfp/HeadsetStateMachine.java
index 9c7bebd..8656383 100644
--- a/src/com/android/bluetooth/hfp/HeadsetStateMachine.java
+++ b/src/com/android/bluetooth/hfp/HeadsetStateMachine.java
@@ -31,6 +31,7 @@
import android.os.UserHandle;
import android.telephony.PhoneNumberUtils;
import android.telephony.PhoneStateListener;
+import android.telephony.ServiceState;
import android.text.TextUtils;
import android.util.Log;
@@ -1703,7 +1704,16 @@
}
private void processAtCops(BluetoothDevice device) {
- String operatorName = mSystemInterface.getNetworkOperator();
+ // Get operator name suggested by Telephony
+ String operatorName = null;
+ ServiceState serviceState = mSystemInterface.getHeadsetPhoneState().getServiceState();
+ if (serviceState != null) {
+ operatorName = serviceState.getOperatorAlpha();
+ }
+ if (mSystemInterface.isInCall() || operatorName == null || operatorName.equals("")) {
+ // Get operator name suggested by Telecom
+ operatorName = mSystemInterface.getNetworkOperator();
+ }
if (operatorName == null) {
operatorName = "";
}
diff --git a/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java b/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
index 3b241e3..a3cc2f1 100644
--- a/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
+++ b/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
@@ -1311,15 +1311,10 @@
mService.sendBroadcast(intent, ProfileService.BLUETOOTH_PERM);
break;
case StackEvent.EVENT_TYPE_VR_STATE_CHANGED:
- if (mVoiceRecognitionActive != event.valueInt) {
- mVoiceRecognitionActive = event.valueInt;
-
- intent = new Intent(BluetoothHeadsetClient.ACTION_AG_EVENT);
- intent.putExtra(BluetoothHeadsetClient.EXTRA_VOICE_RECOGNITION,
- mVoiceRecognitionActive);
- intent.putExtra(BluetoothDevice.EXTRA_DEVICE, event.device);
- mService.sendBroadcast(intent, ProfileService.BLUETOOTH_PERM);
- }
+ int oldState = mVoiceRecognitionActive;
+ mVoiceRecognitionActive = event.valueInt;
+ broadcastVoiceRecognitionStateChanged(event.device, oldState,
+ mVoiceRecognitionActive);
break;
case StackEvent.EVENT_TYPE_CALL:
case StackEvent.EVENT_TYPE_CALLSETUP:
@@ -1365,14 +1360,20 @@
break;
case VOICE_RECOGNITION_START:
if (event.valueInt == AT_OK) {
+ oldState = mVoiceRecognitionActive;
mVoiceRecognitionActive =
HeadsetClientHalConstants.VR_STATE_STARTED;
+ broadcastVoiceRecognitionStateChanged(event.device,
+ oldState, mVoiceRecognitionActive);
}
break;
case VOICE_RECOGNITION_STOP:
if (event.valueInt == AT_OK) {
+ oldState = mVoiceRecognitionActive;
mVoiceRecognitionActive =
HeadsetClientHalConstants.VR_STATE_STOPPED;
+ broadcastVoiceRecognitionStateChanged(event.device,
+ oldState, mVoiceRecognitionActive);
}
break;
default:
@@ -1421,6 +1422,17 @@
return HANDLED;
}
+ private void broadcastVoiceRecognitionStateChanged(BluetoothDevice device, int oldState,
+ int newState) {
+ if (oldState == newState) {
+ return;
+ }
+ Intent intent = new Intent(BluetoothHeadsetClient.ACTION_AG_EVENT);
+ intent.putExtra(BluetoothHeadsetClient.EXTRA_VOICE_RECOGNITION, newState);
+ intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device);
+ mService.sendBroadcast(intent, ProfileService.BLUETOOTH_PERM);
+ }
+
// in Connected state
private void processConnectionEvent(int state, BluetoothDevice device) {
switch (state) {
diff --git a/src/com/android/bluetooth/pbap/BluetoothPbapObexServer.java b/src/com/android/bluetooth/pbap/BluetoothPbapObexServer.java
index 306f31d..be161bb 100755
--- a/src/com/android/bluetooth/pbap/BluetoothPbapObexServer.java
+++ b/src/com/android/bluetooth/pbap/BluetoothPbapObexServer.java
@@ -97,6 +97,7 @@
private static final String[] LEGAL_PATH = {
"/telecom",
"/telecom/pb",
+ "/telecom/fav",
"/telecom/ich",
"/telecom/och",
"/telecom/mch",
@@ -106,6 +107,7 @@
@SuppressWarnings("unused") private static final String[] LEGAL_PATH_WITH_SIM = {
"/telecom",
"/telecom/pb",
+ "/telecom/fav",
"/telecom/ich",
"/telecom/och",
"/telecom/mch",
@@ -138,6 +140,9 @@
// phone book
private static final String PB = "pb";
+ // favorites
+ private static final String FAV = "fav";
+
private static final String TELECOM_PATH = "/telecom";
private static final String ICH_PATH = "/telecom/ich";
@@ -150,6 +155,8 @@
private static final String PB_PATH = "/telecom/pb";
+ private static final String FAV_PATH = "/telecom/fav";
+
// type for list vcard objects
private static final String TYPE_LISTING = "x-bt/vcard-listing";
@@ -212,6 +219,8 @@
public static final int MISSED_CALL_HISTORY = 4;
public static final int COMBINED_CALL_HISTORY = 5;
+
+ public static final int FAVORITES = 6;
}
public BluetoothPbapObexServer(Handler callback, Context context,
@@ -441,6 +450,8 @@
if (mCurrentPath.equals(PB_PATH)) {
appParamValue.needTag = ContentType.PHONEBOOK;
+ } else if (mCurrentPath.equals(FAV_PATH)) {
+ appParamValue.needTag = ContentType.FAVORITES;
} else if (mCurrentPath.equals(ICH_PATH)) {
appParamValue.needTag = ContentType.INCOMING_CALL_HISTORY;
} else if (mCurrentPath.equals(OCH_PATH)) {
@@ -478,6 +489,11 @@
if (D) {
Log.v(TAG, "download phonebook request");
}
+ } else if (isNameMatchTarget(name, FAV)) {
+ appParamValue.needTag = ContentType.FAVORITES;
+ if (D) {
+ Log.v(TAG, "download favorites request");
+ }
} else if (isNameMatchTarget(name, ICH)) {
appParamValue.needTag = ContentType.INCOMING_CALL_HISTORY;
appParamValue.callHistoryVersionCounter =
@@ -751,7 +767,8 @@
result.append("<vCard-listing version=\"1.0\">");
// Phonebook listing request
- if (appParamValue.needTag == ContentType.PHONEBOOK) {
+ if ((appParamValue.needTag == ContentType.PHONEBOOK)
+ || (appParamValue.needTag == ContentType.FAVORITES)) {
String type = "";
if (appParamValue.searchAttr.equals("0")) {
type = "name";
@@ -948,7 +965,7 @@
checkPbapFeatureSupport(mFolderVersionCounterbitMask);
}
boolean needSendPhonebookVersionCounters = false;
- if (isNameMatchTarget(name, PB)) {
+ if (isNameMatchTarget(name, PB) || isNameMatchTarget(name, FAV)) {
needSendPhonebookVersionCounters =
checkPbapFeatureSupport(mFolderVersionCounterbitMask);
}
@@ -1194,11 +1211,12 @@
if (appParamValue.needTag == 0) {
Log.w(TAG, "wrong path!");
return ResponseCodes.OBEX_HTTP_NOT_ACCEPTABLE;
- } else if (appParamValue.needTag == ContentType.PHONEBOOK) {
+ } else if ((appParamValue.needTag == ContentType.PHONEBOOK)
+ || (appParamValue.needTag == ContentType.FAVORITES)) {
if (intIndex < 0 || intIndex >= size) {
Log.w(TAG, "The requested vcard is not acceptable! name= " + name);
return ResponseCodes.OBEX_HTTP_NOT_FOUND;
- } else if (intIndex == 0) {
+ } else if ((intIndex == 0) && (appParamValue.needTag == ContentType.PHONEBOOK)) {
// For PB_PATH, 0.vcf is the phone number of this phone.
String ownerVcard = mVcardManager.getOwnerPhoneNumberVcard(vcard21,
appParamValue.ignorefilter ? null : appParamValue.propertySelector);
@@ -1254,30 +1272,49 @@
int requestSize =
pbSize >= appParamValue.maxListCount ? appParamValue.maxListCount : pbSize;
- int startPoint = appParamValue.listStartOffset;
- if (startPoint < 0 || startPoint >= pbSize) {
+ /**
+ * startIndex (resp., lastIndex) corresponds to the index of the first (resp., last)
+ * vcard entry in the phonebook object.
+ * PBAP v1.2.3: only pb starts indexing at 0.vcf (owner card), the other phonebook
+ * objects (e.g., fav) start at 1.vcf. Additionally, the owner card is included in
+ * pb's pbSize. This means pbSize corresponds to the index of the last vcf in the fav
+ * phonebook object, but does not for the pb phonebook object.
+ */
+ int startIndex = 1;
+ int lastIndex = pbSize;
+ if (appParamValue.needTag == BluetoothPbapObexServer.ContentType.PHONEBOOK) {
+ startIndex = 0;
+ lastIndex = pbSize - 1;
+ }
+ // [startPoint, endPoint] denote the range of vcf indices to send, inclusive.
+ int startPoint = startIndex + appParamValue.listStartOffset;
+ int endPoint = startPoint + requestSize - 1;
+ if (appParamValue.listStartOffset < 0 || startPoint > lastIndex) {
Log.w(TAG, "listStartOffset is not correct! " + startPoint);
return ResponseCodes.OBEX_HTTP_OK;
}
+ if (endPoint > lastIndex) {
+ endPoint = lastIndex;
+ }
// Limit the number of call log to CALLLOG_NUM_LIMIT
- if (appParamValue.needTag != BluetoothPbapObexServer.ContentType.PHONEBOOK) {
+ if ((appParamValue.needTag != BluetoothPbapObexServer.ContentType.PHONEBOOK)
+ && (appParamValue.needTag != BluetoothPbapObexServer.ContentType.FAVORITES)) {
if (requestSize > CALLLOG_NUM_LIMIT) {
requestSize = CALLLOG_NUM_LIMIT;
}
}
- int endPoint = startPoint + requestSize - 1;
- if (endPoint > pbSize - 1) {
- endPoint = pbSize - 1;
- }
if (D) {
Log.d(TAG, "pullPhonebook(): requestSize=" + requestSize + " startPoint=" + startPoint
+ " endPoint=" + endPoint);
}
boolean vcard21 = appParamValue.vcard21;
- if (appParamValue.needTag == BluetoothPbapObexServer.ContentType.PHONEBOOK) {
+ boolean favorites =
+ (appParamValue.needTag == BluetoothPbapObexServer.ContentType.FAVORITES);
+ if ((appParamValue.needTag == BluetoothPbapObexServer.ContentType.PHONEBOOK)
+ || favorites) {
if (startPoint == 0) {
String ownerVcard = mVcardManager.getOwnerPhoneNumberVcard(vcard21,
appParamValue.ignorefilter ? null : appParamValue.propertySelector);
@@ -1287,13 +1324,13 @@
return mVcardManager.composeAndSendPhonebookVcards(op, 1, endPoint, vcard21,
ownerVcard, needSendBody, pbSize, appParamValue.ignorefilter,
appParamValue.propertySelector, appParamValue.vCardSelector,
- appParamValue.vCardSelectorOperator, mVcardSelector);
+ appParamValue.vCardSelectorOperator, mVcardSelector, favorites);
}
} else {
return mVcardManager.composeAndSendPhonebookVcards(op, startPoint, endPoint,
vcard21, null, needSendBody, pbSize, appParamValue.ignorefilter,
appParamValue.propertySelector, appParamValue.vCardSelector,
- appParamValue.vCardSelectorOperator, mVcardSelector);
+ appParamValue.vCardSelectorOperator, mVcardSelector, favorites);
}
} else {
return mVcardManager.composeAndSendSelectedCallLogVcards(appParamValue.needTag, op,
diff --git a/src/com/android/bluetooth/pbap/BluetoothPbapService.java b/src/com/android/bluetooth/pbap/BluetoothPbapService.java
index 59eef4b..630c4b6 100644
--- a/src/com/android/bluetooth/pbap/BluetoothPbapService.java
+++ b/src/com/android/bluetooth/pbap/BluetoothPbapService.java
@@ -140,7 +140,8 @@
private ObexServerSockets mServerSockets = null;
private static final int SDP_PBAP_SERVER_VERSION = 0x0102;
- private static final int SDP_PBAP_SUPPORTED_REPOSITORIES = 0x0001;
+ // PBAP v1.2.3, Sec. 7.1.2: local phonebook and favorites
+ private static final int SDP_PBAP_SUPPORTED_REPOSITORIES = 0x0009;
private static final int SDP_PBAP_SUPPORTED_FEATURES = 0x021F;
/* PBAP will use Bluetooth notification ID from 1000000 (included) to 2000000 (excluded).
diff --git a/src/com/android/bluetooth/pbap/BluetoothPbapVcardManager.java b/src/com/android/bluetooth/pbap/BluetoothPbapVcardManager.java
index 5ba2b4b..8801c16 100755
--- a/src/com/android/bluetooth/pbap/BluetoothPbapVcardManager.java
+++ b/src/com/android/bluetooth/pbap/BluetoothPbapVcardManager.java
@@ -153,7 +153,8 @@
int size;
switch (type) {
case BluetoothPbapObexServer.ContentType.PHONEBOOK:
- size = getContactsSize();
+ case BluetoothPbapObexServer.ContentType.FAVORITES:
+ size = getContactsSize(type);
break;
default:
size = getCallHistorySize(type);
@@ -165,16 +166,30 @@
return size;
}
- public final int getContactsSize() {
+ /**
+ * Returns the number of contacts (i.e., vcf) in a phonebook object.
+ * @param type specifies which phonebook object, e.g., pb, fav
+ * @return
+ */
+ public final int getContactsSize(final int type) {
final Uri myUri = DevicePolicyUtils.getEnterprisePhoneUri(mContext);
Cursor contactCursor = null;
+ String selectionClause = null;
+ if (type == BluetoothPbapObexServer.ContentType.FAVORITES) {
+ selectionClause = Phone.STARRED + " = 1";
+ }
try {
- contactCursor = mResolver.query(myUri, new String[]{Phone.CONTACT_ID}, null, null,
- Phone.CONTACT_ID);
+ contactCursor = mResolver.query(myUri,
+ new String[]{Phone.CONTACT_ID}, selectionClause,
+ null, Phone.CONTACT_ID);
if (contactCursor == null) {
return 0;
}
- return getDistinctContactIdSize(contactCursor) + 1; // always has the 0.vcf
+ int contactsSize = getDistinctContactIdSize(contactCursor);
+ if (type == BluetoothPbapObexServer.ContentType.PHONEBOOK) {
+ contactsSize += 1; // pb has the 0.vcf owner's card
+ }
+ return contactsSize;
} catch (CursorWindowAllocationException e) {
Log.e(TAG, "CursorWindowAllocationException while getting Contacts size");
} finally {
@@ -551,7 +566,7 @@
final int composeAndSendPhonebookVcards(Operation op, final int startPoint, final int endPoint,
final boolean vcardType21, String ownerVCard, int needSendBody, int pbSize,
boolean ignorefilter, byte[] filter, byte[] vcardselector, String vcardselectorop,
- boolean vcardselect) {
+ boolean vcardselect, boolean favorites) {
if (startPoint < 1 || startPoint > endPoint) {
Log.e(TAG, "internal error: startPoint or endPoint is not correct.");
return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
@@ -562,9 +577,15 @@
Cursor contactIdCursor = new MatrixCursor(new String[]{
Phone.CONTACT_ID
});
+
+ String selectionClause = null;
+ if (favorites) {
+ selectionClause = Phone.STARRED + " = 1";
+ }
+
try {
- contactCursor = mResolver.query(myUri, PHONES_CONTACTS_PROJECTION, null, null,
- Phone.CONTACT_ID);
+ contactCursor = mResolver.query(myUri, PHONES_CONTACTS_PROJECTION, selectionClause,
+ null, Phone.CONTACT_ID);
if (contactCursor != null) {
contactIdCursor =
ContactCursorFilter.filterByRange(contactCursor, startPoint, endPoint);
diff --git a/tests/unit/res/raw/image_200_200.jpg b/tests/unit/res/raw/image_200_200.jpg
new file mode 100644
index 0000000..520fcfd
--- /dev/null
+++ b/tests/unit/res/raw/image_200_200.jpg
Binary files differ
diff --git a/tests/unit/res/raw/image_600_600.jpg b/tests/unit/res/raw/image_600_600.jpg
new file mode 100644
index 0000000..7706375
--- /dev/null
+++ b/tests/unit/res/raw/image_600_600.jpg
Binary files differ
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachineTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachineTest.java
index c8d421b..f869fe1 100644
--- a/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachineTest.java
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachineTest.java
@@ -63,8 +63,8 @@
private static final int CONNECT_TIMEOUT_TEST_MILLIS = 1000;
private static final int KEY_DOWN = 0;
private static final int KEY_UP = 1;
+ private AvrcpControllerStateMachine mAvrcpStateMachine;
private BluetoothAdapter mAdapter;
- private AvrcpControllerStateMachine mAvrcpControllerStateMachine;
private Context mTargetContext;
private BluetoothDevice mTestDevice;
private ArgumentCaptor<Intent> mIntentArgument = ArgumentCaptor.forClass(Intent.class);
@@ -83,11 +83,12 @@
private AudioManager mAudioManager;
@Mock
private AvrcpControllerService mAvrcpControllerService;
+ @Mock
+ private A2dpSinkService mA2dpSinkService;
@Mock
private Resources mMockResources;
- AvrcpControllerStateMachine mAvrcpStateMachine;
@Before
public void setUp() throws Exception {
@@ -107,9 +108,12 @@
TestUtils.clearAdapterService(mAvrcpAdapterService);
TestUtils.setAdapterService(mA2dpAdapterService);
TestUtils.startService(mA2dpServiceRule, A2dpSinkService.class);
+ when(mA2dpSinkService.setActiveDeviceNative(any())).thenReturn(true);
+
when(mMockResources.getBoolean(R.bool.a2dp_sink_automatically_request_audio_focus))
.thenReturn(true);
doReturn(mMockResources).when(mAvrcpControllerService).getResources();
+ A2dpSinkService.setA2dpSinkService(mA2dpSinkService);
doReturn(15).when(mAudioManager).getStreamMaxVolume(anyInt());
doReturn(8).when(mAudioManager).getStreamVolume(anyInt());
doReturn(true).when(mAudioManager).isVolumeFixed();
@@ -247,7 +251,8 @@
mAvrcpStateMachine.dump(sb);
Assert.assertEquals(sb.toString(),
" mDevice: " + mTestDevice.toString()
- + "(null) name=AvrcpControllerStateMachine state=(null)\n");
+ + "(null) name=AvrcpControllerStateMachine state=(null)\n"
+ + " isActive: false\n");
}
/**
@@ -480,7 +485,7 @@
//Provide back a player object
byte[] playerFeatures =
new byte[]{0, 0, 0, 0, 0, (byte) 0xb7, 0x01, 0x0c, 0x0a, 0, 0, 0, 0, 0, 0, 0};
- AvrcpPlayer playerOne = new AvrcpPlayer(1, playerName, playerFeatures, 1, 1);
+ AvrcpPlayer playerOne = new AvrcpPlayer(mTestDevice, 1, playerName, playerFeatures, 1, 1);
List<AvrcpPlayer> testPlayers = new ArrayList<>();
testPlayers.add(playerOne);
mAvrcpStateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_GET_PLAYER_ITEMS,
@@ -536,7 +541,7 @@
//Provide back a player object
byte[] playerFeatures =
new byte[]{0, 0, 0, 0, 0, (byte) 0xb7, 0x01, 0x0c, 0x0a, 0, 0, 0, 0, 0, 0, 0};
- AvrcpPlayer playerOne = new AvrcpPlayer(1, playerName, playerFeatures, 1, 1);
+ AvrcpPlayer playerOne = new AvrcpPlayer(mTestDevice, 1, playerName, playerFeatures, 1, 1);
List<AvrcpPlayer> testPlayers = new ArrayList<>();
testPlayers.add(playerOne);
mAvrcpStateMachine.sendMessage(AvrcpControllerStateMachine.MESSAGE_PROCESS_GET_PLAYER_ITEMS,
@@ -625,7 +630,7 @@
eq(mTestAddress), eq(AvrcpControllerService.PASS_THRU_CMD_ID_PAUSE), eq(KEY_DOWN));
TestUtils.waitForLooperToFinishScheduledTask(
A2dpSinkService.getA2dpSinkService().getMainLooper());
- Assert.assertEquals(AudioManager.AUDIOFOCUS_NONE, A2dpSinkService.getFocusState());
+ Assert.assertEquals(AudioManager.AUDIOFOCUS_NONE, mA2dpSinkService.getFocusState());
}
/**
@@ -641,8 +646,43 @@
PlaybackStateCompat.STATE_PLAYING);
TestUtils.waitForLooperToFinishScheduledTask(mAvrcpStateMachine.getHandler().getLooper());
TestUtils.waitForLooperToFinishScheduledTask(
- A2dpSinkService.getA2dpSinkService().getMainLooper());
- Assert.assertEquals(AudioManager.AUDIOFOCUS_GAIN, A2dpSinkService.getFocusState());
+ mA2dpSinkService.getMainLooper());
+ verify(mA2dpSinkService).requestAudioFocus(mTestDevice, true);
+ }
+
+ /**
+ * Test that the correct device becomes active
+ *
+ * The first connected device is automatically active, additional ones are not.
+ * After an explicit play command a device becomes active.
+ */
+ @Test
+ public void testActiveDeviceManagement() {
+ // Setup structures and verify initial conditions
+ final String rootName = "__ROOT__";
+ final String playerName = "Player 1";
+ byte[] secondTestAddress = new byte[]{00, 01, 02, 03, 04, 06};
+ BluetoothDevice secondTestDevice = mAdapter.getRemoteDevice(secondTestAddress);
+ AvrcpControllerStateMachine secondAvrcpStateMachine =
+ new AvrcpControllerStateMachine(secondTestDevice, mAvrcpControllerService);
+ secondAvrcpStateMachine.start();
+ Assert.assertFalse(mAvrcpStateMachine.isActive());
+
+ // Connect device 1 and 2 and verify first one is set as active
+ setUpConnectedState(true, true);
+ secondAvrcpStateMachine.connect(StackEvent.connectionStateChanged(true, true));
+ Assert.assertTrue(mAvrcpStateMachine.isActive());
+ Assert.assertFalse(secondAvrcpStateMachine.isActive());
+
+ // Request the second device to play an item and verify active device switched
+ BrowseTree.BrowseNode results = mAvrcpStateMachine.findNode(rootName);
+ Assert.assertEquals(rootName + mTestDevice.toString(), results.getID());
+ BrowseTree.BrowseNode playerNodes = mAvrcpStateMachine.findNode(results.getID());
+ secondAvrcpStateMachine.playItem(playerNodes);
+ TestUtils.waitForLooperToFinishScheduledTask(secondAvrcpStateMachine.getHandler()
+ .getLooper());
+ Assert.assertFalse(mAvrcpStateMachine.isActive());
+ Assert.assertTrue(secondAvrcpStateMachine.isActive());
}
/**
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtStorageTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtStorageTest.java
new file mode 100644
index 0000000..3d255a6
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpCoverArtStorageTest.java
@@ -0,0 +1,311 @@
+/*
+ * 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;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.io.InputStream;
+
+/**
+ * A test suite for the AvrcpCoverArtStorage class.
+ */
+@RunWith(AndroidJUnit4.class)
+public final class AvrcpCoverArtStorageTest {
+ private Context mTargetContext;
+ private Resources mTestResources;
+ private BluetoothDevice mDevice1;
+ private BluetoothDevice mDevice2;
+ private Bitmap mImage1;
+ private Bitmap mImage2;
+ private final String mHandle1 = "1";
+ private final String mHandle2 = "2";
+ private AvrcpCoverArtStorage mAvrcpCoverArtStorage;
+
+ @Before
+ public void setUp() {
+ mTargetContext = InstrumentationRegistry.getTargetContext();
+ try {
+ mTestResources = mTargetContext.getPackageManager()
+ .getResourcesForApplication("com.android.bluetooth.tests");
+ } catch (PackageManager.NameNotFoundException e) {
+ Assert.fail("Setup Failure Unable to get resources" + e.toString());
+ }
+ mDevice1 = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("AA:BB:CC:DD:EE:FF");
+ mDevice2 = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("BB:CC:DD:EE:FF:AA");
+ InputStream is = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_200_200);
+ mImage1 = BitmapFactory.decodeStream(is);
+ InputStream is2 = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_600_600);
+ mImage2 = BitmapFactory.decodeStream(is2);
+
+ mAvrcpCoverArtStorage = new AvrcpCoverArtStorage(mTargetContext);
+ }
+
+ @After
+ public void tearDown() {
+ if (mAvrcpCoverArtStorage != null) {
+ mAvrcpCoverArtStorage.removeImagesForDevice(mDevice1);
+ mAvrcpCoverArtStorage.removeImagesForDevice(mDevice2);
+ mAvrcpCoverArtStorage = null;
+ }
+ mImage1 = null;
+ mImage2 = null;
+ mDevice1 = null;
+ mDevice2 = null;
+ mTestResources = null;
+ mTargetContext = null;
+ }
+
+ private void assertImageSame(Bitmap expected, BluetoothDevice device, String handle) {
+ File file = mAvrcpCoverArtStorage.getImageFile(device, handle);
+ Bitmap fromStorage = BitmapFactory.decodeFile(file.getPath());
+ Assert.assertTrue(expected.sameAs(fromStorage));
+ }
+
+ @Test
+ public void addNewImage_imageExists() {
+ Uri expectedUri = AvrcpCoverArtProvider.getImageUri(mDevice1, mHandle1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+
+ Uri uri = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+
+ Assert.assertEquals(expectedUri, uri);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ }
+
+ @Test
+ public void addExistingImage_imageUpdated() {
+ Uri expectedUri = AvrcpCoverArtProvider.getImageUri(mDevice1, mHandle1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+
+ Uri uri = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(expectedUri, uri);
+ assertImageSame(mImage1, mDevice1, mHandle1);
+
+ uri = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage2);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(expectedUri, uri);
+ assertImageSame(mImage2, mDevice1, mHandle1);
+ }
+
+ @Test
+ public void addTwoImageSameDevice_bothExist() {
+ Uri expectedUri1 = AvrcpCoverArtProvider.getImageUri(mDevice1, mHandle1);
+ Uri expectedUri2 = AvrcpCoverArtProvider.getImageUri(mDevice1, mHandle2);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle2));
+
+ Uri uri1 = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ Uri uri2 = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle2, mImage2);
+
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(expectedUri1, uri1);
+
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle2));
+ Assert.assertEquals(expectedUri2, uri2);
+ }
+
+ @Test
+ public void addTwoImageDifferentDevices_bothExist() {
+ Uri expectedUri1 = AvrcpCoverArtProvider.getImageUri(mDevice1, mHandle1);
+ Uri expectedUri2 = AvrcpCoverArtProvider.getImageUri(mDevice2, mHandle1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice2, mHandle1));
+
+ Uri uri1 = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ Uri uri2 = mAvrcpCoverArtStorage.addImage(mDevice2, mHandle1, mImage1);
+
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(expectedUri1, uri1);
+
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(expectedUri2, uri2);
+ }
+
+ @Test
+ public void addNullImage_imageNotAdded() {
+ Uri uri = mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, null);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(null, uri);
+ }
+
+ @Test
+ public void addImageNullDevice_imageNotAdded() {
+ Uri uri = mAvrcpCoverArtStorage.addImage(null, mHandle1, mImage1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(null, uri);
+ }
+
+ @Test
+ public void addImageNullHandle_imageNotAdded() {
+ Uri uri = mAvrcpCoverArtStorage.addImage(mDevice1, null, mImage1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(null, uri);
+ }
+
+ @Test
+ public void addImageEmptyHandle_imageNotAdded() {
+ Uri uri = mAvrcpCoverArtStorage.addImage(mDevice1, "", mImage1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertEquals(null, uri);
+ }
+
+ @Test
+ public void getImage_canGetImageFromStorage() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ assertImageSame(mImage1, mDevice1, mHandle1);
+ }
+
+ @Test
+ public void getImageSameHandleDifferentDevices_canGetImagesFromStorage() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice2, mHandle1, mImage2);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice2, mHandle1));
+ assertImageSame(mImage1, mDevice1, mHandle1);
+ assertImageSame(mImage2, mDevice2, mHandle1);
+ }
+
+ @Test
+ public void getImageThatDoesntExist_returnsNull() {
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ File file = mAvrcpCoverArtStorage.getImageFile(mDevice1, mHandle1);
+ Assert.assertEquals(null, file);
+ }
+
+ @Test
+ public void getImageNullDevice_returnsNull() {
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ File file = mAvrcpCoverArtStorage.getImageFile(null, mHandle1);
+ Assert.assertEquals(null, file);
+ }
+
+ @Test
+ public void getImageNullHandle_returnsNull() {
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ File file = mAvrcpCoverArtStorage.getImageFile(mDevice1, null);
+ Assert.assertEquals(null, file);
+ }
+
+ @Test
+ public void getImageEmptyHandle_returnsNull() {
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ File file = mAvrcpCoverArtStorage.getImageFile(mDevice1, "");
+ Assert.assertEquals(null, file);
+ }
+
+ @Test
+ public void removeExistingImage_imageDoesntExist() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle2, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice2, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.removeImage(mDevice1, mHandle1);
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle2));
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice2, mHandle1));
+ }
+
+ @Test
+ public void removeNonExistentImage_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.removeImage(mDevice1, mHandle2);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ }
+
+ @Test
+ public void removeImageNullDevice_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.removeImage(null, mHandle1);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ }
+
+ @Test
+ public void removeImageNullHandle_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.removeImage(mDevice1, null);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ }
+
+ @Test
+ public void removeImageEmptyHandle_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.removeImage(mDevice1, "");
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ }
+
+ @Test
+ public void removeImageNullInputs_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.removeImage(null, null);
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ }
+
+ @Test
+ public void removeAllImagesForDevice_onlyOneDeviceImagesGone() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle2, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice2, mHandle1, mImage1);
+
+ mAvrcpCoverArtStorage.removeImagesForDevice(mDevice1);
+
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertFalse(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle2));
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice2, mHandle1));
+ }
+
+ @Test
+ public void removeAllImagesForDeviceDne_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle2, mImage1);
+
+ mAvrcpCoverArtStorage.removeImagesForDevice(mDevice2);
+
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle2));
+ }
+
+ @Test
+ public void removeAllImagesForNullDevice_nothingHappens() {
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle1, mImage1);
+ mAvrcpCoverArtStorage.addImage(mDevice1, mHandle2, mImage1);
+
+ mAvrcpCoverArtStorage.removeImagesForDevice(null);
+
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle1));
+ Assert.assertTrue(mAvrcpCoverArtStorage.doesImageExist(mDevice1, mHandle2));
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpItemTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpItemTest.java
new file mode 100644
index 0000000..b6aacf6
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpItemTest.java
@@ -0,0 +1,434 @@
+/*
+ * 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;
+
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.content.Context;
+import android.net.Uri;
+import android.support.v4.media.MediaBrowserCompat.MediaItem;
+import android.support.v4.media.MediaDescriptionCompat;
+import android.support.v4.media.MediaMetadataCompat;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A test suite for the AvrcpItem class.
+ */
+@RunWith(AndroidJUnit4.class)
+public final class AvrcpItemTest {
+
+ private BluetoothDevice mDevice;
+ private static final String UUID = "AVRCP-ITEM-TEST-UUID";
+
+ // Attribute ID Values from AVRCP Specification
+ private static final int MEDIA_ATTRIBUTE_TITLE = 0x01;
+ private static final int MEDIA_ATTRIBUTE_ARTIST_NAME = 0x02;
+ private static final int MEDIA_ATTRIBUTE_ALBUM_NAME = 0x03;
+ private static final int MEDIA_ATTRIBUTE_TRACK_NUMBER = 0x04;
+ private static final int MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER = 0x05;
+ private static final int MEDIA_ATTRIBUTE_GENRE = 0x06;
+ private static final int MEDIA_ATTRIBUTE_PLAYING_TIME = 0x07;
+ private static final int MEDIA_ATTRIBUTE_COVER_ART_HANDLE = 0x08;
+
+ @Before
+ public void setUp() {
+ Context context = InstrumentationRegistry.getTargetContext();
+ mDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice("AA:BB:CC:DD:EE:FF");
+ }
+
+ @After
+ public void tearDown() {
+ mDevice = null;
+ }
+
+ @Test
+ public void buildAvrcpItem() {
+ String title = "Aaaaargh";
+ String artist = "Bluetooth";
+ String album = "The Best Protocol";
+ long trackNumber = 1;
+ long totalTracks = 12;
+ String genre = "Viking Metal";
+ long playingTime = 301;
+ String artHandle = "abc123";
+ Uri uri = Uri.parse("content://somewhere");
+ Uri uri2 = Uri.parse("content://somewhereelse");
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setItemType(AvrcpItem.TYPE_MEDIA);
+ builder.setType(AvrcpItem.MEDIA_AUDIO);
+ builder.setDevice(mDevice);
+ builder.setPlayable(true);
+ builder.setUid(0);
+ builder.setUuid(UUID);
+ builder.setTitle(title);
+ builder.setArtistName(artist);
+ builder.setAlbumName(album);
+ builder.setTrackNumber(trackNumber);
+ builder.setTotalNumberOfTracks(totalTracks);
+ builder.setGenre(genre);
+ builder.setPlayingTime(playingTime);
+ builder.setCoverArtHandle(artHandle);
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+
+ Assert.assertEquals(mDevice, item.getDevice());
+ Assert.assertEquals(true, item.isPlayable());
+ Assert.assertEquals(false, item.isBrowsable());
+ Assert.assertEquals(0, item.getUid());
+ Assert.assertEquals(UUID, item.getUuid());
+ Assert.assertEquals(null, item.getDisplayableName());
+ Assert.assertEquals(title, item.getTitle());
+ Assert.assertEquals(artist, item.getArtistName());
+ Assert.assertEquals(album, item.getAlbumName());
+ Assert.assertEquals(trackNumber, item.getTrackNumber());
+ Assert.assertEquals(totalTracks, item.getTotalNumberOfTracks());
+ Assert.assertEquals(artHandle, item.getCoverArtHandle());
+ Assert.assertEquals(uri, item.getCoverArtLocation());
+ }
+
+ @Test
+ public void buildAvrcpItemFromAvrcpAttributes() {
+ String title = "Aaaaargh";
+ String artist = "Bluetooth";
+ String album = "The Best Protocol";
+ String trackNumber = "1";
+ String totalTracks = "12";
+ String genre = "Viking Metal";
+ String playingTime = "301";
+ String artHandle = "abc123";
+
+ int[] attrIds = new int[]{
+ MEDIA_ATTRIBUTE_TITLE,
+ MEDIA_ATTRIBUTE_ARTIST_NAME,
+ MEDIA_ATTRIBUTE_ALBUM_NAME,
+ MEDIA_ATTRIBUTE_TRACK_NUMBER,
+ MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER,
+ MEDIA_ATTRIBUTE_GENRE,
+ MEDIA_ATTRIBUTE_PLAYING_TIME,
+ MEDIA_ATTRIBUTE_COVER_ART_HANDLE
+ };
+
+ String[] attrMap = new String[]{
+ title,
+ artist,
+ album,
+ trackNumber,
+ totalTracks,
+ genre,
+ playingTime,
+ artHandle
+ };
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.fromAvrcpAttributeArray(attrIds, attrMap);
+ AvrcpItem item = builder.build();
+
+ Assert.assertEquals(null, item.getDevice());
+ Assert.assertEquals(false, item.isPlayable());
+ Assert.assertEquals(false, item.isBrowsable());
+ Assert.assertEquals(0, item.getUid());
+ Assert.assertEquals(null, item.getUuid());
+ Assert.assertEquals(null, item.getDisplayableName());
+ Assert.assertEquals(title, item.getTitle());
+ Assert.assertEquals(artist, item.getArtistName());
+ Assert.assertEquals(album, item.getAlbumName());
+ Assert.assertEquals(1, item.getTrackNumber());
+ Assert.assertEquals(12, item.getTotalNumberOfTracks());
+ Assert.assertEquals(artHandle, item.getCoverArtHandle());
+ Assert.assertEquals(null, item.getCoverArtLocation());
+ }
+
+ @Test
+ public void buildAvrcpItemFromAvrcpAttributesWithBadIds_badIdsIgnored() {
+ String title = "Aaaaargh";
+ String artist = "Bluetooth";
+ String album = "The Best Protocol";
+ String trackNumber = "1";
+ String totalTracks = "12";
+ String genre = "Viking Metal";
+ String playingTime = "301";
+ String artHandle = "abc123";
+
+ int[] attrIds = new int[]{
+ MEDIA_ATTRIBUTE_TITLE,
+ MEDIA_ATTRIBUTE_ARTIST_NAME,
+ MEDIA_ATTRIBUTE_ALBUM_NAME,
+ MEDIA_ATTRIBUTE_TRACK_NUMBER,
+ MEDIA_ATTRIBUTE_TOTAL_TRACK_NUMBER,
+ MEDIA_ATTRIBUTE_GENRE,
+ MEDIA_ATTRIBUTE_PLAYING_TIME,
+ MEDIA_ATTRIBUTE_COVER_ART_HANDLE,
+ 75,
+ 76,
+ 77,
+ 78
+ };
+
+ String[] attrMap = new String[]{
+ title,
+ artist,
+ album,
+ trackNumber,
+ totalTracks,
+ genre,
+ playingTime,
+ artHandle,
+ "ignore me",
+ "ignore me",
+ "ignore me",
+ "ignore me"
+ };
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.fromAvrcpAttributeArray(attrIds, attrMap);
+ AvrcpItem item = builder.build();
+
+ Assert.assertEquals(null, item.getDevice());
+ Assert.assertEquals(false, item.isPlayable());
+ Assert.assertEquals(false, item.isBrowsable());
+ Assert.assertEquals(0, item.getUid());
+ Assert.assertEquals(null, item.getUuid());
+ Assert.assertEquals(null, item.getDisplayableName());
+ Assert.assertEquals(title, item.getTitle());
+ Assert.assertEquals(artist, item.getArtistName());
+ Assert.assertEquals(album, item.getAlbumName());
+ Assert.assertEquals(1, item.getTrackNumber());
+ Assert.assertEquals(12, item.getTotalNumberOfTracks());
+ Assert.assertEquals(artHandle, item.getCoverArtHandle());
+ Assert.assertEquals(null, item.getCoverArtLocation());
+ }
+
+ @Test
+ public void updateCoverArtLocation() {
+ Uri uri = Uri.parse("content://somewhere");
+ Uri uri2 = Uri.parse("content://somewhereelse");
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+ Assert.assertEquals(uri, item.getCoverArtLocation());
+
+ item.setCoverArtLocation(uri2);
+ Assert.assertEquals(uri2, item.getCoverArtLocation());
+ }
+
+ @Test
+ public void avrcpMediaItem_toMediaMetadata() {
+ String title = "Aaaaargh";
+ String artist = "Bluetooth";
+ String album = "The Best Protocol";
+ long trackNumber = 1;
+ long totalTracks = 12;
+ String genre = "Viking Metal";
+ long playingTime = 301;
+ String artHandle = "abc123";
+ Uri uri = Uri.parse("content://somewhere");
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setItemType(AvrcpItem.TYPE_MEDIA);
+ builder.setType(AvrcpItem.MEDIA_AUDIO);
+ builder.setDevice(mDevice);
+ builder.setPlayable(true);
+ builder.setUid(0);
+ builder.setUuid(UUID);
+ builder.setDisplayableName(title);
+ builder.setTitle(title);
+ builder.setArtistName(artist);
+ builder.setAlbumName(album);
+ builder.setTrackNumber(trackNumber);
+ builder.setTotalNumberOfTracks(totalTracks);
+ builder.setGenre(genre);
+ builder.setPlayingTime(playingTime);
+ builder.setCoverArtHandle(artHandle);
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+ MediaMetadataCompat metadata = item.toMediaMetadata();
+
+ Assert.assertEquals(UUID, metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID));
+ Assert.assertEquals(title,
+ metadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE));
+ Assert.assertEquals(title, metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE));
+ Assert.assertEquals(artist, metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST));
+ Assert.assertEquals(album, metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM));
+ Assert.assertEquals(trackNumber,
+ metadata.getLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER));
+ Assert.assertEquals(totalTracks,
+ metadata.getLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS));
+ Assert.assertEquals(genre, metadata.getString(MediaMetadataCompat.METADATA_KEY_GENRE));
+ Assert.assertEquals(playingTime,
+ metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION));
+ Assert.assertEquals(uri,
+ Uri.parse(metadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI)));
+ Assert.assertEquals(uri,
+ Uri.parse(metadata.getString(MediaMetadataCompat.METADATA_KEY_ART_URI)));
+ Assert.assertEquals(uri,
+ Uri.parse(metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI)));
+ Assert.assertEquals(null,
+ metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON));
+ Assert.assertEquals(null, metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ART));
+ Assert.assertEquals(null, metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART));
+ Assert.assertFalse(metadata.containsKey(MediaMetadataCompat.METADATA_KEY_BT_FOLDER_TYPE));
+ }
+
+ @Test
+ public void avrcpFolderItem_toMediaMetadata() {
+ String title = "Bluetooth Playlist";
+ String artist = "Many";
+ long totalTracks = 12;
+ String genre = "Viking Metal";
+ long playingTime = 301;
+ String artHandle = "abc123";
+ Uri uri = Uri.parse("content://somewhere");
+ int type = AvrcpItem.FOLDER_TITLES;
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setItemType(AvrcpItem.TYPE_FOLDER);
+ builder.setType(type);
+ builder.setDevice(mDevice);
+ builder.setUuid(UUID);
+ builder.setDisplayableName(title);
+ builder.setTitle(title);
+ builder.setArtistName(artist);
+ builder.setTotalNumberOfTracks(totalTracks);
+ builder.setGenre(genre);
+ builder.setPlayingTime(playingTime);
+ builder.setCoverArtHandle(artHandle);
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+ MediaMetadataCompat metadata = item.toMediaMetadata();
+
+ Assert.assertEquals(UUID, metadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID));
+ Assert.assertEquals(title,
+ metadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE));
+ Assert.assertEquals(title, metadata.getString(MediaMetadataCompat.METADATA_KEY_TITLE));
+ Assert.assertEquals(artist, metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST));
+ Assert.assertEquals(null, metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM));
+ Assert.assertEquals(totalTracks,
+ metadata.getLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS));
+ Assert.assertEquals(genre, metadata.getString(MediaMetadataCompat.METADATA_KEY_GENRE));
+ Assert.assertEquals(playingTime,
+ metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION));
+ Assert.assertEquals(uri,
+ Uri.parse(metadata.getString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI)));
+ Assert.assertEquals(uri,
+ Uri.parse(metadata.getString(MediaMetadataCompat.METADATA_KEY_ART_URI)));
+ Assert.assertEquals(uri,
+ Uri.parse(metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI)));
+ Assert.assertEquals(null,
+ metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON));
+ Assert.assertEquals(null, metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ART));
+ Assert.assertEquals(null, metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART));
+ Assert.assertEquals(type,
+ metadata.getLong(MediaMetadataCompat.METADATA_KEY_BT_FOLDER_TYPE));
+ }
+
+ @Test
+ public void avrcpItemNoDisplayName_toMediaItem() {
+ String title = "Aaaaargh";
+ Uri uri = Uri.parse("content://somewhere");
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setPlayable(true);
+ builder.setUuid(UUID);
+ builder.setTitle(title);
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+ MediaItem mediaItem = item.toMediaItem();
+ MediaDescriptionCompat desc = mediaItem.getDescription();
+
+ Assert.assertTrue(mediaItem.isPlayable());
+ Assert.assertFalse(mediaItem.isBrowsable());
+ Assert.assertEquals(UUID, mediaItem.getMediaId());
+
+ Assert.assertEquals(UUID, desc.getMediaId());
+ Assert.assertEquals(null, desc.getMediaUri());
+ Assert.assertEquals(title, desc.getTitle().toString());
+ Assert.assertEquals(null, desc.getSubtitle());
+ Assert.assertEquals(uri, desc.getIconUri());
+ Assert.assertEquals(null, desc.getIconBitmap());
+ }
+
+ @Test
+ public void avrcpItemWithDisplayName_toMediaItem() {
+ String title = "Aaaaargh";
+ String displayName = "A Different Type of Aaaaargh";
+ Uri uri = Uri.parse("content://somewhere");
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setPlayable(true);
+ builder.setUuid(UUID);
+ builder.setDisplayableName(displayName);
+ builder.setTitle(title);
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+ MediaItem mediaItem = item.toMediaItem();
+ MediaDescriptionCompat desc = mediaItem.getDescription();
+
+ Assert.assertTrue(mediaItem.isPlayable());
+ Assert.assertFalse(mediaItem.isBrowsable());
+ Assert.assertEquals(UUID, mediaItem.getMediaId());
+
+ Assert.assertEquals(UUID, desc.getMediaId());
+ Assert.assertEquals(null, desc.getMediaUri());
+ Assert.assertEquals(displayName, desc.getTitle().toString());
+ Assert.assertEquals(null, desc.getSubtitle());
+ Assert.assertEquals(uri, desc.getIconUri());
+ Assert.assertEquals(null, desc.getIconBitmap());
+ }
+
+ @Test
+ public void avrcpItemBrowsable_toMediaItem() {
+ String title = "Aaaaargh";
+ Uri uri = Uri.parse("content://somewhere");
+
+ AvrcpItem.Builder builder = new AvrcpItem.Builder();
+ builder.setBrowsable(true);
+ builder.setUuid(UUID);
+ builder.setTitle(title);
+ builder.setCoverArtLocation(uri);
+
+ AvrcpItem item = builder.build();
+ MediaItem mediaItem = item.toMediaItem();
+ MediaDescriptionCompat desc = mediaItem.getDescription();
+
+ Assert.assertFalse(mediaItem.isPlayable());
+ Assert.assertTrue(mediaItem.isBrowsable());
+ Assert.assertEquals(UUID, mediaItem.getMediaId());
+
+ Assert.assertEquals(UUID, desc.getMediaId());
+ Assert.assertEquals(null, desc.getMediaUri());
+ Assert.assertEquals(title, desc.getTitle().toString());
+ Assert.assertEquals(null, desc.getSubtitle());
+ Assert.assertEquals(uri, desc.getIconUri());
+ Assert.assertEquals(null, desc.getIconBitmap());
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipAttachmentFormatTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipAttachmentFormatTest.java
new file mode 100644
index 0000000..6872780
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipAttachmentFormatTest.java
@@ -0,0 +1,292 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * A test suite for the BipAttachmentFormat class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipAttachmentFormatTest {
+
+ private Date makeDate(int month, int day, int year, int hours, int min, int sec, TimeZone tz) {
+ Calendar.Builder builder = new Calendar.Builder();
+
+ /* Note that Calendar months are zero-based in Java framework */
+ builder.setDate(year, month - 1, day);
+ builder.setTimeOfDay(hours, min, sec, 0);
+ if (tz != null) builder.setTimeZone(tz);
+ return builder.build().getTime();
+ }
+
+ private Date makeDate(int month, int day, int year, int hours, int min, int sec) {
+ return makeDate(month, day, year, hours, min, sec, null);
+ }
+
+ private void testParse(String contentType, String charset, String name, String size,
+ String created, String modified, Date expectedCreated, boolean isCreatedUtc,
+ Date expectedModified, boolean isModifiedUtc) {
+ int expectedSize = (size != null ? Integer.parseInt(size) : -1);
+ BipAttachmentFormat attachment = new BipAttachmentFormat(contentType, charset, name,
+ size, created, modified);
+ Assert.assertEquals(contentType, attachment.getContentType());
+ Assert.assertEquals(charset, attachment.getCharset());
+ Assert.assertEquals(name, attachment.getName());
+ Assert.assertEquals(expectedSize, attachment.getSize());
+
+ if (expectedCreated != null) {
+ Assert.assertEquals(expectedCreated, attachment.getCreatedDate().getTime());
+ Assert.assertEquals(isCreatedUtc, attachment.getCreatedDate().isUtc());
+ } else {
+ Assert.assertEquals(null, attachment.getCreatedDate());
+ }
+
+ if (expectedModified != null) {
+ Assert.assertEquals(expectedModified, attachment.getModifiedDate().getTime());
+ Assert.assertEquals(isModifiedUtc, attachment.getModifiedDate().isUtc());
+ } else {
+ Assert.assertEquals(null, attachment.getModifiedDate());
+ }
+ }
+
+ private void testCreate(String contentType, String charset, String name, int size,
+ Date created, Date modified) {
+ BipAttachmentFormat attachment = new BipAttachmentFormat(contentType, charset, name,
+ size, created, modified);
+ Assert.assertEquals(contentType, attachment.getContentType());
+ Assert.assertEquals(charset, attachment.getCharset());
+ Assert.assertEquals(name, attachment.getName());
+ Assert.assertEquals(size, attachment.getSize());
+
+ if (created != null) {
+ Assert.assertEquals(created, attachment.getCreatedDate().getTime());
+ Assert.assertTrue(attachment.getCreatedDate().isUtc());
+ } else {
+ Assert.assertEquals(null, attachment.getCreatedDate());
+ }
+
+ if (modified != null) {
+ Assert.assertEquals(modified, attachment.getModifiedDate().getTime());
+ Assert.assertTrue(attachment.getModifiedDate().isUtc());
+ } else {
+ Assert.assertEquals(null, attachment.getModifiedDate());
+ }
+ }
+
+ @Test
+ public void testParseAttachment() {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ Date date = makeDate(1, 1, 1990, 12, 34, 56);
+ Date dateUtc = makeDate(1, 1, 1990, 12, 34, 56, utc);
+
+ // Well defined fields
+ testParse("text/plain", "ISO-8859-1", "thisisatextfile.txt", "2048", "19900101T123456",
+ "19900101T123456", date, false, date, false);
+
+ // Well defined fields with UTC date
+ testParse("text/plain", "ISO-8859-1", "thisisatextfile.txt", "2048", "19900101T123456Z",
+ "19900101T123456Z", dateUtc, true, dateUtc, true);
+
+ // Change up the content type and file name
+ testParse("audio/basic", "ISO-8859-1", "thisisawavfile.wav", "1024", "19900101T123456",
+ "19900101T123456", date, false, date, false);
+
+ // Use a null modified date
+ testParse("text/plain", "ISO-8859-1", "thisisatextfile.txt", "2048", "19900101T123456",
+ null, date, false, null, false);
+
+ // Use a null created date
+ testParse("text/plain", "ISO-8859-1", "thisisatextfile.txt", "2048", null,
+ "19900101T123456", null, false, date, false);
+
+ // Use all null dates
+ testParse("text/plain", "ISO-8859-1", "thisisatextfile.txt", "123", null, null, null, false,
+ null, false);
+
+ // Use a null size
+ testParse("text/plain", "ISO-8859-1", "thisisatextfile.txt", null, null, null, null, false,
+ null, false);
+
+ // Use a null charset
+ testParse("text/plain", null, "thisisatextfile.txt", "2048", null, null, null, false, null,
+ false);
+
+ // Use only required fields
+ testParse("text/plain", null, "thisisatextfile.txt", null, null, null, null, false, null,
+ false);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNullContentType() {
+ testParse(null, "ISO-8859-1", "thisisatextfile.txt", null, null, null, null, false, null,
+ false);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNullName() {
+ testParse("text/plain", "ISO-8859-1", null, null, null, null, null, false, null,
+ false);
+ }
+
+ @Test
+ public void testCreateAttachment() {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ Date date = makeDate(1, 1, 1990, 12, 34, 56);
+
+ // Create with normal, well defined fields
+ testCreate("text/plain", "ISO-8859-1", "thisisatextfile.txt", 2048, date, date);
+
+ // Create with a null charset
+ testCreate("text/plain", null, "thisisatextfile.txt", 2048, date, date);
+
+ // Create with "no size"
+ testCreate("text/plain", "ISO-8859-1", "thisisatextfile.txt", -1, date, date);
+
+ // Use only required fields
+ testCreate("text/plain", null, "thisisatextfile.txt", -1, null, null);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testCreateNullContentType_throwsException() {
+ testCreate(null, "ISO-8859-1", "thisisatextfile.txt", 2048, null, null);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testCreateNullName_throwsException() {
+ testCreate("text/plain", "ISO-8859-1", null, 2048, null, null);
+ }
+
+ @Test
+ public void testParsedAttachmentToString() {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ Date date = makeDate(1, 1, 1990, 12, 34, 56);
+ Date dateUtc = makeDate(1, 1, 1990, 12, 34, 56, utc);
+ BipAttachmentFormat attachment = null;
+
+ String expected = "<attachment content-type=\"text/plain\" charset=\"ISO-8859-1\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\""
+ + " created=\"19900101T123456\" modified=\"19900101T123456\" />";
+
+ String expectedUtc = "<attachment content-type=\"text/plain\" charset=\"ISO-8859-1\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\""
+ + " created=\"19900101T123456Z\" modified=\"19900101T123456Z\" />";
+
+ String expectedNoDates = "<attachment content-type=\"text/plain\" charset=\"ISO-8859-1\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\" />";
+
+ String expectedNoSizeNoDates = "<attachment content-type=\"text/plain\""
+ + " charset=\"ISO-8859-1\" name=\"thisisatextfile.txt\" />";
+
+ String expectedNoCharsetNoDates = "<attachment content-type=\"text/plain\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\" />";
+
+ String expectedRequiredOnly = "<attachment content-type=\"text/plain\""
+ + " name=\"thisisatextfile.txt\" />";
+
+ // Create by parsing, all fields
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ "2048", "19900101T123456", "19900101T123456");
+ Assert.assertEquals(expected, attachment.toString());
+
+ // Create by parsing, all fields with utc dates
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ "2048", "19900101T123456Z", "19900101T123456Z");
+ Assert.assertEquals(expectedUtc, attachment.toString());
+
+ // Create by parsing, no timestamps
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ "2048", null, null);
+ Assert.assertEquals(expectedNoDates, attachment.toString());
+
+ // Create by parsing, no size, no dates
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ null, null, null);
+ Assert.assertEquals(expectedNoSizeNoDates, attachment.toString());
+
+ // Create by parsing, no charset, no dates
+ attachment = new BipAttachmentFormat("text/plain", null, "thisisatextfile.txt", "2048",
+ null, null);
+ Assert.assertEquals(expectedNoCharsetNoDates, attachment.toString());
+
+ // Create by parsing, content type only
+ attachment = new BipAttachmentFormat("text/plain", null, "thisisatextfile.txt", null, null,
+ null);
+ Assert.assertEquals(expectedRequiredOnly, attachment.toString());
+ }
+
+ @Test
+ public void testCreatedAttachmentToString() {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ Date date = makeDate(1, 1, 1990, 12, 34, 56, utc);
+ BipAttachmentFormat attachment = null;
+
+ String expected = "<attachment content-type=\"text/plain\" charset=\"ISO-8859-1\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\""
+ + " created=\"19900101T123456Z\" modified=\"19900101T123456Z\" />";
+
+ String expectedNoDates = "<attachment content-type=\"text/plain\" charset=\"ISO-8859-1\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\" />";
+
+ String expectedNoSizeNoDates = "<attachment content-type=\"text/plain\""
+ + " charset=\"ISO-8859-1\" name=\"thisisatextfile.txt\" />";
+
+ String expectedNoCharsetNoDates = "<attachment content-type=\"text/plain\""
+ + " name=\"thisisatextfile.txt\" size=\"2048\" />";
+
+ String expectedRequiredOnly = "<attachment content-type=\"text/plain\""
+ + " name=\"thisisatextfile.txt\" />";
+
+ // Create with objects, all fields. Now we Use UTC since all Date objects eventually become
+ // UTC anyway and this will be timezone agnostic
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ 2048, date, date);
+ Assert.assertEquals(expected, attachment.toString());
+
+ // Create with objects, no dates
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ 2048, null, null);
+ Assert.assertEquals(expectedNoDates, attachment.toString());
+
+ // Create with objects, no size and no dates
+ attachment = new BipAttachmentFormat("text/plain", "ISO-8859-1", "thisisatextfile.txt",
+ -1, null, null);
+ Assert.assertEquals(expectedNoSizeNoDates, attachment.toString());
+
+ // Create with objects, no charset, no dates
+ attachment = new BipAttachmentFormat("text/plain", null, "thisisatextfile.txt", 2048, null,
+ null);
+ Assert.assertEquals(expectedNoCharsetNoDates, attachment.toString());
+
+ // Create with objects, content type only
+ attachment = new BipAttachmentFormat("text/plain", null, "thisisatextfile.txt", -1, null,
+ null);
+ Assert.assertEquals(expectedRequiredOnly, attachment.toString());
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipDatetimeTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipDatetimeTest.java
new file mode 100644
index 0000000..571360d
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipDatetimeTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * A test suite for the BipDateTime class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipDatetimeTest {
+
+ private Date makeDate(int month, int day, int year, int hours, int min, int sec, TimeZone tz) {
+ Calendar.Builder builder = new Calendar.Builder();
+
+ /* Note that Calendar months are zero-based in Java framework */
+ builder.setDate(year, month - 1, day);
+ builder.setTimeOfDay(hours, min, sec, 0);
+ if (tz != null) builder.setTimeZone(tz);
+ return builder.build().getTime();
+ }
+
+ private Date makeDate(int month, int day, int year, int hours, int min, int sec) {
+ return makeDate(month, day, year, hours, min, sec, null);
+ }
+
+ private String makeTzAdjustedString(int month, int day, int year, int hours, int min,
+ int sec) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(makeDate(month, day, year, hours, min, sec));
+ 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));
+ }
+
+ private void testParse(String date, Date expectedDate, boolean isUtc, String expectedStr) {
+ BipDateTime bipDateTime = new BipDateTime(date);
+ Assert.assertEquals(expectedDate, bipDateTime.getTime());
+ Assert.assertEquals(isUtc, bipDateTime.isUtc());
+ Assert.assertEquals(expectedStr, bipDateTime.toString());
+ }
+
+ private void testCreate(Date date, String dateStr) {
+ BipDateTime bipDate = new BipDateTime(date);
+ Assert.assertEquals(date, bipDate.getTime());
+ Assert.assertTrue(bipDate.isUtc());
+ Assert.assertEquals(dateStr, bipDate.toString());
+ }
+
+ @Test
+ public void testCreateFromValidString() {
+ testParse("20000101T000000", makeDate(1, 1, 2000, 0, 0, 0), false,
+ makeTzAdjustedString(1, 1, 2000, 0, 0, 0));
+ testParse("20000101T060115", makeDate(1, 1, 2000, 6, 1, 15), false,
+ makeTzAdjustedString(1, 1, 2000, 6, 1, 15));
+ testParse("20000101T060000", makeDate(1, 1, 2000, 6, 0, 0), false,
+ makeTzAdjustedString(1, 1, 2000, 6, 0, 0));
+ testParse("20000101T071500", makeDate(1, 1, 2000, 7, 15, 0), false,
+ makeTzAdjustedString(1, 1, 2000, 7, 15, 0));
+ testParse("20000101T151700", makeDate(1, 1, 2000, 15, 17, 0), false,
+ makeTzAdjustedString(1, 1, 2000, 15, 17, 0));
+ testParse("20000101T235959", makeDate(1, 1, 2000, 23, 59, 59), false,
+ makeTzAdjustedString(1, 1, 2000, 23, 59, 59));
+ testParse("20501127T235959", makeDate(11, 27, 2050, 23, 59, 59), false,
+ makeTzAdjustedString(11, 27, 2050, 23, 59, 59));
+ }
+
+ @Test
+ public void testParseFromValidStringWithUtc() {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ testParse("20000101T000000Z", makeDate(1, 1, 2000, 0, 0, 0, utc), true,
+ "20000101T000000Z");
+ testParse("20000101T060115Z", makeDate(1, 1, 2000, 6, 1, 15, utc), true,
+ "20000101T060115Z");
+ testParse("20000101T060000Z", makeDate(1, 1, 2000, 6, 0, 0, utc), true,
+ "20000101T060000Z");
+ testParse("20000101T071500Z", makeDate(1, 1, 2000, 7, 15, 0, utc), true,
+ "20000101T071500Z");
+ testParse("20000101T151700Z", makeDate(1, 1, 2000, 15, 17, 0, utc), true,
+ "20000101T151700Z");
+ testParse("20000101T235959Z", makeDate(1, 1, 2000, 23, 59, 59, utc), true,
+ "20000101T235959Z");
+ testParse("20501127T235959Z", makeDate(11, 27, 2050, 23, 59, 59, utc), true,
+ "20501127T235959Z");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNullString_throwsException() {
+ testParse(null, null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseEmptyString_throwsException() {
+ testParse("", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseTooFewCharacters_throwsException() {
+ testParse("200011T61515", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadYear_throwsException() {
+ testParse("00000101T000000", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadMonth_throwsException() {
+ testParse("20000001T000000", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseDayTooSmall_throwsException() {
+ testParse("20000100T000000", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseDayTooLarge_throwsException() {
+ testParse("20000132T000000", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadHours_throwsException() {
+ testParse("20000132T250000", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadMinutes_throwsException() {
+ testParse("20000132T006100", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadSeconds_throwsException() {
+ testParse("20000132T000061", null, false, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadCharacters_throwsException() {
+ testParse("2ABC0101T000000", null, false, null);
+ }
+
+ @Test
+ public void testCreateFromDateTime() {
+ TimeZone utc = TimeZone.getTimeZone("UTC");
+ utc.setRawOffset(0);
+ // Note: All Java Date objects are stored as UTC timestamps so we expect all of these to be
+ // UTC strings when formatted for BIP objects
+ testCreate(makeDate(1, 1, 2000, 6, 1, 15, utc), "20000101T060115Z");
+ testCreate(makeDate(1, 1, 2000, 6, 0, 0, utc), "20000101T060000Z");
+ testCreate(makeDate(1, 1, 2000, 23, 59, 59, utc), "20000101T235959Z");
+ testCreate(makeDate(11, 27, 2050, 23, 59, 59, utc), "20501127T235959Z");
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipEncodingTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipEncodingTest.java
new file mode 100644
index 0000000..5ae69fb
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipEncodingTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A test suite for the BipEncoding class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipEncodingTest {
+
+ private void testParse(String input, int encodingType, String encodingStr, String propId,
+ boolean isAndroidSupported) {
+ BipEncoding encoding = new BipEncoding(input);
+ Assert.assertEquals(encodingType, encoding.getType());
+ Assert.assertEquals(encodingStr, encoding.toString());
+ Assert.assertEquals(propId, encoding.getProprietaryEncodingId());
+ Assert.assertEquals(isAndroidSupported, encoding.isAndroidSupported());
+ }
+
+ private void testParseMany(String[] inputs, int encodingType, String encodingStr, String propId,
+ boolean isAndroidSupported) {
+ for (String input : inputs) {
+ testParse(input, encodingType, encodingStr, propId, isAndroidSupported);
+ }
+ }
+
+ @Test
+ public void testParseJpeg() {
+ String[] inputs = new String[]{"JPEG", "jpeg", "Jpeg", "JpEg"};
+ testParseMany(inputs, BipEncoding.JPEG, "JPEG", null, true);
+ }
+
+ @Test
+ public void testParseGif() {
+ String[] inputs = new String[]{"GIF", "gif", "Gif", "gIf"};
+ testParseMany(inputs, BipEncoding.GIF, "GIF", null, true);
+ }
+
+ @Test
+ public void testParseWbmp() {
+ String[] inputs = new String[]{"WBMP", "wbmp", "Wbmp", "WbMp"};
+ testParseMany(inputs, BipEncoding.WBMP, "WBMP", null, false);
+ }
+
+ @Test
+ public void testParsePng() {
+ String[] inputs = new String[]{"PNG", "png", "Png", "PnG"};
+ testParseMany(inputs, BipEncoding.PNG, "PNG", null, true);
+ }
+
+ @Test
+ public void testParseJpeg2000() {
+ String[] inputs = new String[]{"JPEG2000", "jpeg2000", "Jpeg2000", "JpEg2000"};
+ testParseMany(inputs, BipEncoding.JPEG2000, "JPEG2000", null, false);
+ }
+
+ @Test
+ public void testParseBmp() {
+ String[] inputs = new String[]{"BMP", "bmp", "Bmp", "BmP"};
+ testParseMany(inputs, BipEncoding.BMP, "BMP", null, true);
+ }
+
+ @Test
+ public void testParseUsrProprietary() {
+ String[] inputs = new String[]{"USR-test", "usr-test", "Usr-Test", "UsR-TeSt"};
+ testParseMany(inputs, BipEncoding.USR_XXX, "USR-TEST", "TEST", false);
+
+ // Example used in the spec so not a bad choice here
+ inputs = new String[]{"USR-NOKIA-FORMAT1", "usr-nokia-format1"};
+ testParseMany(inputs, BipEncoding.USR_XXX, "USR-NOKIA-FORMAT1", "NOKIA-FORMAT1", false);
+ }
+
+ @Test
+ public void testCreateBasicEncoding() {
+ int[] inputs = new int[]{BipEncoding.JPEG, BipEncoding.PNG, BipEncoding.BMP,
+ BipEncoding.GIF, BipEncoding.JPEG2000, BipEncoding.WBMP};
+ for (int encodingType : inputs) {
+ BipEncoding encoding = new BipEncoding(encodingType, null);
+ Assert.assertEquals(encodingType, encoding.getType());
+ Assert.assertEquals(null, encoding.getProprietaryEncodingId());
+ }
+ }
+
+ @Test
+ public void testCreateProprietaryEncoding() {
+ BipEncoding encoding = new BipEncoding(BipEncoding.USR_XXX, "test-encoding");
+ Assert.assertEquals(BipEncoding.USR_XXX, encoding.getType());
+ Assert.assertEquals("TEST-ENCODING", encoding.getProprietaryEncodingId());
+ Assert.assertEquals("USR-TEST-ENCODING", encoding.toString());
+ Assert.assertFalse(encoding.isAndroidSupported());
+ }
+
+ @Test
+ public void testCreateProprietaryEncoding_emptyId() {
+ BipEncoding encoding = new BipEncoding(BipEncoding.USR_XXX, "");
+ Assert.assertEquals(BipEncoding.USR_XXX, encoding.getType());
+ Assert.assertEquals("", encoding.getProprietaryEncodingId());
+ Assert.assertEquals("USR-", encoding.toString());
+ Assert.assertFalse(encoding.isAndroidSupported());
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseInvalidName_throwsException() {
+ testParse("JIF", BipEncoding.UNKNOWN, "UNKNOWN", null, false);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseInvalidUsrEncoding_throwsException() {
+ testParse("USR", BipEncoding.UNKNOWN, "UNKNOWN", null, false);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNullString() {
+ testParse(null, BipEncoding.UNKNOWN, "UNKNOWN", null, false);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseEmptyString() {
+ testParse("", BipEncoding.UNKNOWN, "UNKNOWN", null, false);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateInvalidEncoding() {
+ BipEncoding encoding = new BipEncoding(-5, null);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreateProprietary_nullId() {
+ BipEncoding encoding = new BipEncoding(BipEncoding.USR_XXX, null);
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageDescriptorTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageDescriptorTest.java
new file mode 100644
index 0000000..d14ebb4
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageDescriptorTest.java
@@ -0,0 +1,219 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A test suite for the BipImageDescriptor class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipImageDescriptorTest {
+
+ private static final String sXmlDocDecl =
+ "<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n";
+
+ @Test
+ public void testBuildImageDescriptor_encodingConstants() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"JPEG\" pixel=\"1280*960\" size=\"500000\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(BipEncoding.JPEG);
+ builder.setFixedDimensions(1280, 960);
+ builder.setFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_encodingObject() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"JPEG\" pixel=\"1280*960\" size=\"500000\" />\n"
+ + "</image-descriptor>\n";
+
+ BipEncoding encoding = new BipEncoding(BipEncoding.JPEG, null);
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(encoding);
+ builder.setFixedDimensions(1280, 960);
+ builder.setFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_proprietaryEncoding() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"USR-NOKIA-1\" pixel=\"1280*960\" size=\"500000\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setPropietaryEncoding("NOKIA-1");
+ builder.setFixedDimensions(1280, 960);
+ builder.setFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_transformationConstantStretch() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"USR-NOKIA-1\" pixel=\"1280*960\" "
+ + "transformation=\"stretch\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setPropietaryEncoding("NOKIA-1");
+ builder.setFixedDimensions(1280, 960);
+ builder.setTransformation(BipTransformation.STRETCH);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_transformationConstantCrop() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"USR-NOKIA-1\" pixel=\"1280*960\" "
+ + "transformation=\"crop\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setPropietaryEncoding("NOKIA-1");
+ builder.setFixedDimensions(1280, 960);
+ builder.setTransformation(BipTransformation.CROP);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_transformationConstantFill() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"USR-NOKIA-1\" pixel=\"1280*960\" "
+ + "transformation=\"fill\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setPropietaryEncoding("NOKIA-1");
+ builder.setFixedDimensions(1280, 960);
+ builder.setTransformation(BipTransformation.FILL);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_transformationConstantCropThenFill() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"USR-NOKIA-1\" pixel=\"1280*960\" "
+ + "transformation=\"fill\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setPropietaryEncoding("NOKIA-1");
+ builder.setFixedDimensions(1280, 960);
+ builder.setTransformation(BipTransformation.CROP);
+ builder.setTransformation(BipTransformation.FILL);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_noSize() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"JPEG\" pixel=\"1280*960\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(BipEncoding.JPEG);
+ builder.setFixedDimensions(1280, 960);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_useMaxSize() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"JPEG\" pixel=\"1280*960\" maxsize=\"500000\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(BipEncoding.JPEG);
+ builder.setFixedDimensions(1280, 960);
+ builder.setMaxFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_allButSize() {
+ String expected = sXmlDocDecl + "<image-descriptor version=\"1.0\">\n"
+ + " <image encoding=\"JPEG\" pixel=\"1280*960\" maxsize=\"500000\" "
+ + "transformation=\"fill\" />\n"
+ + "</image-descriptor>\n";
+
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(BipEncoding.JPEG);
+ builder.setFixedDimensions(1280, 960);
+ builder.setMaxFileSize(500000);
+ builder.setTransformation(BipTransformation.FILL);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(expected, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_noEncoding() {
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setFixedDimensions(1280, 960);
+ builder.setFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(null, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_noPixel() {
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setEncoding(BipEncoding.JPEG);
+ builder.setFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(null, descriptor.toString());
+ }
+
+ @Test
+ public void testBuildImageDescriptor_noEncodingNoPixel() {
+ BipImageDescriptor.Builder builder = new BipImageDescriptor.Builder();
+ builder.setFileSize(500000);
+
+ BipImageDescriptor descriptor = builder.build();
+ Assert.assertEquals(null, descriptor.toString());
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageFormatTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageFormatTest.java
new file mode 100644
index 0000000..175ceb9
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageFormatTest.java
@@ -0,0 +1,276 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A test suite for the BipImageFormat class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipImageFormatTest {
+ @Test
+ public void testParseNative_requiredOnly() {
+ String expected = "<native encoding=\"JPEG\" pixel=\"1280*1024\" />";
+ BipImageFormat format = BipImageFormat.parseNative("JPEG", "1280*1024", null);
+ Assert.assertEquals(BipImageFormat.FORMAT_NATIVE, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(new BipTransformation(), format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testParseNative_withSize() {
+ String expected = "<native encoding=\"JPEG\" pixel=\"1280*1024\" size=\"1048576\" />";
+ BipImageFormat format = BipImageFormat.parseNative("JPEG", "1280*1024", "1048576");
+ Assert.assertEquals(BipImageFormat.FORMAT_NATIVE, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(new BipTransformation(), format.getTransformation());
+ Assert.assertEquals(1048576, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testParseVariant_requiredOnly() {
+ String expected = "<variant encoding=\"JPEG\" pixel=\"1280*1024\" />";
+ BipImageFormat format = BipImageFormat.parseVariant("JPEG", "1280*1024", null, null);
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(new BipTransformation(), format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testParseVariant_withMaxSize() {
+ String expected = "<variant encoding=\"JPEG\" pixel=\"1280*1024\" maxsize=\"1048576\" />";
+ BipImageFormat format = BipImageFormat.parseVariant("JPEG", "1280*1024", "1048576", null);
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(new BipTransformation(), format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(1048576, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testParseVariant_withTransformation() {
+ String expected = "<variant encoding=\"JPEG\" pixel=\"1280*1024\""
+ + " transformation=\"stretch fill crop\" />";
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.STRETCH);
+ trans.addTransformation(BipTransformation.FILL);
+ trans.addTransformation(BipTransformation.CROP);
+
+ BipImageFormat format = null;
+
+ format = BipImageFormat.parseVariant("JPEG", "1280*1024", null, "stretch fill crop");
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(expected, format.toString());
+
+ format = BipImageFormat.parseVariant("JPEG", "1280*1024", null, "stretch crop fill");
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(expected, format.toString());
+
+ format = BipImageFormat.parseVariant("JPEG", "1280*1024", null, "crop stretch fill");
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testParseVariant_allFields() {
+ String expected = "<variant encoding=\"JPEG\" pixel=\"1280*1024\""
+ + " transformation=\"stretch fill crop\" maxsize=\"1048576\" />";
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.STRETCH);
+ trans.addTransformation(BipTransformation.FILL);
+ trans.addTransformation(BipTransformation.CROP);
+
+ BipImageFormat format = null;
+
+ format = BipImageFormat.parseVariant("JPEG", "1280*1024", "1048576", "stretch fill crop");
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(1048576, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+
+ format = BipImageFormat.parseVariant("JPEG", "1280*1024", "1048576", "stretch crop fill");
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(1048576, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+
+ format = BipImageFormat.parseVariant("JPEG", "1280*1024", "1048576", "crop stretch fill");
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(1048576, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testCreateNative_requiredOnly() {
+ String expected = "<native encoding=\"JPEG\" pixel=\"1280*1024\" />";
+ BipImageFormat format = BipImageFormat.createNative(
+ new BipEncoding(BipEncoding.JPEG, null), BipPixel.createFixed(1280, 1024), -1);
+ Assert.assertEquals(BipImageFormat.FORMAT_NATIVE, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(new BipTransformation(), format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testCreateNative_withSize() {
+ String expected = "<native encoding=\"JPEG\" pixel=\"1280*1024\" size=\"1048576\" />";
+ BipImageFormat format = BipImageFormat.createNative(
+ new BipEncoding(BipEncoding.JPEG, null), BipPixel.createFixed(1280, 1024), 1048576);
+ Assert.assertEquals(BipImageFormat.FORMAT_NATIVE, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(1280, 1024), format.getPixel());
+ Assert.assertEquals(new BipTransformation(), format.getTransformation());
+ Assert.assertEquals(1048576, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testCreateVariant_requiredOnly() {
+ String expected = "<variant encoding=\"JPEG\" pixel=\"32*32\" />";
+ BipImageFormat format = BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.JPEG, null), BipPixel.createFixed(32, 32), -1, null);
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(32, 32), format.getPixel());
+ Assert.assertEquals(null, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testCreateVariant_withTransformations() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.STRETCH,
+ BipTransformation.CROP, BipTransformation.FILL});
+ String expected = "<variant encoding=\"JPEG\" pixel=\"32*32\" "
+ + "transformation=\"stretch fill crop\" />";
+ BipImageFormat format = BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.JPEG, null), BipPixel.createFixed(32, 32), -1, trans);
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(32, 32), format.getPixel());
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(-1, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testCreateVariant_withMaxsize() {
+ String expected = "<variant encoding=\"JPEG\" pixel=\"32*32\" maxsize=\"123\" />";
+ BipImageFormat format = BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.JPEG, null), BipPixel.createFixed(32, 32), 123, null);
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(32, 32), format.getPixel());
+ Assert.assertEquals(null, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(123, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test
+ public void testCreateVariant_allFields() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.STRETCH,
+ BipTransformation.CROP, BipTransformation.FILL});
+ String expected = "<variant encoding=\"JPEG\" pixel=\"32*32\" "
+ + "transformation=\"stretch fill crop\" maxsize=\"123\" />";
+ BipImageFormat format = BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.JPEG, null), BipPixel.createFixed(32, 32), 123, trans);
+ Assert.assertEquals(BipImageFormat.FORMAT_VARIANT, format.getType());
+ Assert.assertEquals(new BipEncoding(BipEncoding.JPEG, null), format.getEncoding());
+ Assert.assertEquals(BipPixel.createFixed(32, 32), format.getPixel());
+ Assert.assertEquals(trans, format.getTransformation());
+ Assert.assertEquals(-1, format.getSize());
+ Assert.assertEquals(123, format.getMaxSize());
+ Assert.assertEquals(expected, format.toString());
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNative_noEncoding() {
+ BipImageFormat format = BipImageFormat.parseNative(null, "1024*960", "1048576");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNative_emptyEncoding() {
+ BipImageFormat format = BipImageFormat.parseNative("", "1024*960", "1048576");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNative_badEncoding() {
+ BipImageFormat format = BipImageFormat.parseNative("JIF", "1024*960", "1048576");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNative_noPixel() {
+ BipImageFormat format = BipImageFormat.parseNative("JPEG", null, "1048576");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNative_emptyPixel() {
+ BipImageFormat format = BipImageFormat.parseNative("JPEG", "", "1048576");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNative_badPixel() {
+ BipImageFormat format = BipImageFormat.parseNative("JPEG", "abc*123", "1048576");
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testCreateFormat_noEncoding() {
+ BipImageFormat format = BipImageFormat.createNative(null, BipPixel.createFixed(1280, 1024),
+ -1);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testCreateFormat_noPixel() {
+ BipImageFormat format = BipImageFormat.createNative(new BipEncoding(BipEncoding.JPEG, null),
+ null, -1);
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImagePropertiesTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImagePropertiesTest.java
new file mode 100644
index 0000000..5497df3
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImagePropertiesTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+
+/**
+ * A test suite for the BipImageProperties class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipImagePropertiesTest {
+ private static String sImageHandle = "123456789";
+ private static final String sXmlDocDecl =
+ "<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n";
+ private static String sXmlString = sXmlDocDecl
+ + "<image-properties version=\"1.0\" handle=\"123456789\">\n"
+ + " <native encoding=\"JPEG\" pixel=\"1280*1024\" size=\"1048576\" />\n"
+ + " <variant encoding=\"JPEG\" pixel=\"640*480\" />\n"
+ + " <variant encoding=\"GIF\" pixel=\"80*60-640*480\" "
+ + "transformation=\"stretch fill crop\" />\n"
+ + " <variant encoding=\"JPEG\" pixel=\"150**-600*120\" />\n"
+ + " <attachment content-type=\"text/plain\" name=\"ABCD1234.txt\" size=\"5120\" />\n"
+ + " <attachment content-type=\"audio/basic\" name=\"ABCD1234.wav\" size=\"102400\" "
+ + "/>\n"
+ + "</image-properties>\n";
+
+ private InputStream toUtf8Stream(String s) {
+ try {
+ return new ByteArrayInputStream(s.getBytes("UTF-8"));
+ } catch (UnsupportedEncodingException e) {
+ return null;
+ }
+ }
+
+ @Test
+ public void testParseProperties() {
+ InputStream stream = toUtf8Stream(sXmlString);
+ BipImageProperties properties = new BipImageProperties(stream);
+ Assert.assertEquals(sImageHandle, properties.getImageHandle());
+ Assert.assertEquals(sXmlString, properties.toString());
+ }
+
+ @Test
+ public void testCreateProperties() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.STRETCH);
+ trans.addTransformation(BipTransformation.CROP);
+ trans.addTransformation(BipTransformation.FILL);
+
+ BipImageProperties.Builder builder = new BipImageProperties.Builder();
+ builder.setImageHandle(sImageHandle);
+ builder.addNativeFormat(BipImageFormat.createNative(new BipEncoding(BipEncoding.JPEG, null),
+ BipPixel.createFixed(1280, 1024), 1048576));
+
+ builder.addVariantFormat(
+ BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.JPEG, null),
+ BipPixel.createFixed(640, 480), -1, null));
+ builder.addVariantFormat(
+ BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.GIF, null),
+ BipPixel.createResizableModified(80, 60, 640, 480), -1, trans));
+ builder.addVariantFormat(
+ BipImageFormat.createVariant(
+ new BipEncoding(BipEncoding.JPEG, null),
+ BipPixel.createResizableFixed(150, 600, 120), -1, null));
+
+ builder.addAttachment(
+ new BipAttachmentFormat("text/plain", null, "ABCD1234.txt", 5120, null, null));
+ builder.addAttachment(
+ new BipAttachmentFormat("audio/basic", null, "ABCD1234.wav", 102400, null, null));
+
+ BipImageProperties properties = builder.build();
+ Assert.assertEquals(sImageHandle, properties.getImageHandle());
+ Assert.assertEquals(sXmlString, properties.toString());
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageTest.java
new file mode 100644
index 0000000..fd619a0
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipImageTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.InputStream;
+
+/**
+ * A test suite for the BipImage class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipImageTest {
+ private static String sImageHandle = "123456789";
+ private Context mTargetContext;
+ private Resources mTestResources;
+
+ @Before
+ public void setUp() {
+ mTargetContext = InstrumentationRegistry.getTargetContext();
+ try {
+ mTestResources = mTargetContext.getPackageManager()
+ .getResourcesForApplication("com.android.bluetooth.tests");
+ } catch (PackageManager.NameNotFoundException e) {
+ Assert.fail("Setup Failure Unable to get resources" + e.toString());
+ }
+ }
+
+ @Test
+ public void testParseImage_200by200() {
+ InputStream imageInputStream = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_200_200);
+ BipImage image = new BipImage(sImageHandle, imageInputStream);
+
+ InputStream expectedInputStream = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_200_200);
+ Bitmap bitmap = BitmapFactory.decodeStream(expectedInputStream);
+
+ Assert.assertEquals(sImageHandle, image.getImageHandle());
+ Assert.assertTrue(bitmap.sameAs(image.getImage()));
+ }
+
+ @Test
+ public void testParseImage_600by600() {
+ InputStream imageInputStream = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_600_600);
+ BipImage image = new BipImage(sImageHandle, imageInputStream);
+
+ InputStream expectedInputStream = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_600_600);
+ Bitmap bitmap = BitmapFactory.decodeStream(expectedInputStream);
+
+ Assert.assertEquals(sImageHandle, image.getImageHandle());
+ Assert.assertTrue(bitmap.sameAs(image.getImage()));
+ }
+
+ @Test
+ public void testMakeFromImage_200by200() {
+ InputStream imageInputStream = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_200_200);
+ Bitmap bitmap = BitmapFactory.decodeStream(imageInputStream);
+ BipImage image = new BipImage(sImageHandle, bitmap);
+ Assert.assertEquals(sImageHandle, image.getImageHandle());
+ Assert.assertTrue(bitmap.sameAs(image.getImage()));
+ }
+
+ @Test
+ public void testMakeFromImage_600by600() {
+ InputStream imageInputStream = mTestResources.openRawResource(
+ com.android.bluetooth.tests.R.raw.image_600_600);
+ Bitmap bitmap = BitmapFactory.decodeStream(imageInputStream);
+ BipImage image = new BipImage(sImageHandle, bitmap);
+ Assert.assertEquals(sImageHandle, image.getImageHandle());
+ Assert.assertTrue(bitmap.sameAs(image.getImage()));
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipPixelTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipPixelTest.java
new file mode 100644
index 0000000..11c0f33
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipPixelTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A test suite for the BipPixel class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipPixelTest {
+
+ private void testParse(String input, int pixelType, int minWidth, int minHeight, int maxWidth,
+ int maxHeight, String pixelStr) {
+ BipPixel pixel = new BipPixel(input);
+ Assert.assertEquals(pixelType, pixel.getType());
+ Assert.assertEquals(minWidth, pixel.getMinWidth());
+ Assert.assertEquals(minHeight, pixel.getMinHeight());
+ Assert.assertEquals(maxWidth, pixel.getMaxWidth());
+ Assert.assertEquals(maxHeight, pixel.getMaxHeight());
+ Assert.assertEquals(pixelStr, pixel.toString());
+ }
+
+ private void testFixed(int width, int height, String pixelStr) {
+ BipPixel pixel = BipPixel.createFixed(width, height);
+ Assert.assertEquals(BipPixel.TYPE_FIXED, pixel.getType());
+ Assert.assertEquals(width, pixel.getMinWidth());
+ Assert.assertEquals(height, pixel.getMinHeight());
+ Assert.assertEquals(width, pixel.getMaxWidth());
+ Assert.assertEquals(height, pixel.getMaxHeight());
+ Assert.assertEquals(pixelStr, pixel.toString());
+ }
+
+ private void testResizableModified(int minWidth, int minHeight, int maxWidth, int maxHeight,
+ String pixelStr) {
+ BipPixel pixel = BipPixel.createResizableModified(minWidth, minHeight, maxWidth, maxHeight);
+ Assert.assertEquals(BipPixel.TYPE_RESIZE_MODIFIED_ASPECT_RATIO, pixel.getType());
+ Assert.assertEquals(minWidth, pixel.getMinWidth());
+ Assert.assertEquals(minHeight, pixel.getMinHeight());
+ Assert.assertEquals(maxWidth, pixel.getMaxWidth());
+ Assert.assertEquals(maxHeight, pixel.getMaxHeight());
+ Assert.assertEquals(pixelStr, pixel.toString());
+ }
+
+ private void testResizableFixed(int minWidth, int maxWidth, int maxHeight,
+ String pixelStr) {
+ int minHeight = (minWidth * maxHeight) / maxWidth; // spec defined
+ BipPixel pixel = BipPixel.createResizableFixed(minWidth, maxWidth, maxHeight);
+ Assert.assertEquals(BipPixel.TYPE_RESIZE_FIXED_ASPECT_RATIO, pixel.getType());
+ Assert.assertEquals(minWidth, pixel.getMinWidth());
+ Assert.assertEquals(minHeight, pixel.getMinHeight());
+ Assert.assertEquals(maxWidth, pixel.getMaxWidth());
+ Assert.assertEquals(maxHeight, pixel.getMaxHeight());
+ Assert.assertEquals(pixelStr, pixel.toString());
+ }
+
+ @Test
+ public void testParseFixed() {
+ testParse("0*0", BipPixel.TYPE_FIXED, 0, 0, 0, 0, "0*0");
+ testParse("200*200", BipPixel.TYPE_FIXED, 200, 200, 200, 200, "200*200");
+ testParse("12*67", BipPixel.TYPE_FIXED, 12, 67, 12, 67, "12*67");
+ testParse("1000*1000", BipPixel.TYPE_FIXED, 1000, 1000, 1000, 1000, "1000*1000");
+ }
+
+ @Test
+ public void testParseResizableModified() {
+ testParse("0*0-200*200", BipPixel.TYPE_RESIZE_MODIFIED_ASPECT_RATIO, 0, 0, 200, 200,
+ "0*0-200*200");
+ testParse("200*200-600*600", BipPixel.TYPE_RESIZE_MODIFIED_ASPECT_RATIO, 200, 200, 600, 600,
+ "200*200-600*600");
+ testParse("12*67-34*89", BipPixel.TYPE_RESIZE_MODIFIED_ASPECT_RATIO, 12, 67, 34, 89,
+ "12*67-34*89");
+ testParse("123*456-1000*1000", BipPixel.TYPE_RESIZE_MODIFIED_ASPECT_RATIO, 123, 456, 1000,
+ 1000, "123*456-1000*1000");
+ }
+
+ @Test
+ public void testParseResizableFixed() {
+ testParse("0**-200*200", BipPixel.TYPE_RESIZE_FIXED_ASPECT_RATIO, 0, 0, 200, 200,
+ "0**-200*200");
+ testParse("200**-600*600", BipPixel.TYPE_RESIZE_FIXED_ASPECT_RATIO, 200, 200, 600, 600,
+ "200**-600*600");
+ testParse("123**-1000*1000", BipPixel.TYPE_RESIZE_FIXED_ASPECT_RATIO, 123, 123, 1000, 1000,
+ "123**-1000*1000");
+ testParse("12**-35*89", BipPixel.TYPE_RESIZE_FIXED_ASPECT_RATIO, 12, (12 * 89) / 35, 35, 89,
+ "12**-35*89");
+ }
+
+ @Test
+ public void testParseDimensionAtMax() {
+ testParse("65535*65535", BipPixel.TYPE_FIXED, 65535, 65535, 65535, 65535, "65535*65535");
+ }
+
+ @Test
+ public void testCreateFixed() {
+ testFixed(0, 0, "0*0");
+ testFixed(200, 200, "200*200");
+ testFixed(17, 43, "17*43");
+ testFixed(20000, 23456, "20000*23456");
+ }
+
+ @Test
+ public void testCreateResizableModified() {
+ testResizableModified(0, 0, 200, 200, "0*0-200*200");
+ testResizableModified(200, 200, 600, 600, "200*200-600*600");
+ testResizableModified(12, 67, 34, 89, "12*67-34*89");
+ testResizableModified(123, 456, 1000, 1000, "123*456-1000*1000");
+ }
+
+ @Test
+ public void testCreateResizableFixed() {
+ testResizableFixed(0, 200, 200, "0**-200*200");
+ testResizableFixed(200, 600, 600, "200**-600*600");
+ testResizableFixed(123, 1000, 1000, "123**-1000*1000");
+ testResizableFixed(12, 35, 89, "12**-35*89");
+ }
+
+ @Test
+ public void testCreateDimensionsAtMax() {
+ testFixed(65535, 65535, "65535*65535");
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseNull_throwsException() {
+ testParse(null, BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseEmpty_throwsException() {
+ testParse("", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseWhitespace_throwsException() {
+ testParse("\n\t ", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseBadCharacters_throwsException() {
+ testParse("this*has-characters*init", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseTooManyAsterisks_throwsException() {
+ testParse("123*****456", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseWithSymbols_throwsException() {
+ testParse("!@#*342", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseEscapeCharacters_throwsException() {
+ testParse("\\\\*\\\\-\\\\*\\\\", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseWidthTooLargeFixed_throwsException() {
+ testParse("123456*123", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseHeightTooLargeFixed_throwsException() {
+ testParse("123*123456", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMinWidthTooLargeResize_throwsException() {
+ testParse("123456*1-12*1234", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMaxWidthTooLargeResize_throwsException() {
+ testParse("1*1-123456*123", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMinHeightTooLargeResize_throwsException() {
+ testParse("1*123456-12*1234", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMaxHeightTooLargeResize_throwsException() {
+ testParse("1*1-12*123456", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMinWidthTooLargeResizeFixed_throwsException() {
+ testParse("123456**-123*1234", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMaxWidthTooLargeResizeFixed_throwsException() {
+ testParse("123**-123456*1234", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+
+ @Test(expected = ParseException.class)
+ public void testParseMaxHeightTooLargeResizeFixed_throwsException() {
+ testParse("123**-1234*123456", BipPixel.TYPE_UNKNOWN, -1, -1, -1, -1, null);
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipTransformationTest.java b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipTransformationTest.java
new file mode 100644
index 0000000..793b2df
--- /dev/null
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/bip/BipTransformationTest.java
@@ -0,0 +1,342 @@
+/*
+ * 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;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A test suite for the BipTransformation class
+ */
+@RunWith(AndroidJUnit4.class)
+public class BipTransformationTest {
+
+ @Test
+ public void testCreateEmpty() {
+ BipTransformation trans = new BipTransformation();
+ Assert.assertFalse(trans.supportsAny());
+ Assert.assertEquals(null, trans.toString());
+ }
+
+ @Test
+ public void testAddTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("crop", trans.toString());
+
+ trans.addTransformation(BipTransformation.STRETCH);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("stretch crop", trans.toString());
+ }
+
+ @Test
+ public void testAddExistingTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("crop", trans.toString());
+
+ trans.addTransformation(BipTransformation.CROP);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("crop", trans.toString());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testAddOnlyInvalidTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.UNKNOWN);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testAddNewInvalidTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ trans.addTransformation(BipTransformation.UNKNOWN);
+ }
+
+ @Test
+ public void testRemoveOnlyTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("crop", trans.toString());
+
+ trans.removeTransformation(BipTransformation.CROP);
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.supportsAny());
+ Assert.assertEquals(null, trans.toString());
+ }
+
+ @Test
+ public void testRemoveOneTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ trans.addTransformation(BipTransformation.STRETCH);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("stretch crop", trans.toString());
+
+ trans.removeTransformation(BipTransformation.CROP);
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("stretch", trans.toString());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testRemoveInvalidTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ trans.addTransformation(BipTransformation.STRETCH);
+ trans.removeTransformation(BipTransformation.UNKNOWN);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("stretch crop", trans.toString());
+ }
+
+ @Test
+ public void testRemoveUnsupportedTransformation() {
+ BipTransformation trans = new BipTransformation();
+ trans.addTransformation(BipTransformation.CROP);
+ trans.addTransformation(BipTransformation.STRETCH);
+ trans.removeTransformation(BipTransformation.FILL);
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("stretch crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_Stretch() {
+ BipTransformation trans = new BipTransformation("stretch");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch", trans.toString());
+ }
+
+ @Test
+ public void testParse_Crop() {
+ BipTransformation trans = new BipTransformation("crop");
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_Fill() {
+ BipTransformation trans = new BipTransformation("Fill");
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("fill", trans.toString());
+ }
+
+ @Test
+ public void testParse_StretchFill() {
+ BipTransformation trans = new BipTransformation("stretch fill");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill", trans.toString());
+ }
+
+ @Test
+ public void testParse_StretchCrop() {
+ BipTransformation trans = new BipTransformation("stretch crop");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertEquals("stretch crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_FillCrop() {
+ BipTransformation trans = new BipTransformation("fill crop");
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertEquals("fill crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_StretchFillCrop() {
+ BipTransformation trans = new BipTransformation("stretch fill crop");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_CropFill() {
+ BipTransformation trans = new BipTransformation("crop fill");
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertEquals("fill crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_CropFillStretch() {
+ BipTransformation trans = new BipTransformation("crop fill stretch");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_CropFillStretchWithDuplicates() {
+ BipTransformation trans = new BipTransformation("stretch crop fill fill crop stretch");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill crop", trans.toString());
+ }
+
+ @Test
+ public void testCreate_stretch() {
+ BipTransformation trans = new BipTransformation(BipTransformation.STRETCH);
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch", trans.toString());
+ }
+
+ @Test
+ public void testCreate_fill() {
+ BipTransformation trans = new BipTransformation(BipTransformation.FILL);
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("fill", trans.toString());
+ }
+
+ @Test
+ public void testCreate_crop() {
+ BipTransformation trans = new BipTransformation(BipTransformation.CROP);
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("crop", trans.toString());
+ }
+
+ @Test
+ public void testCreate_cropArray() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.CROP});
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("crop", trans.toString());
+ }
+
+ @Test
+ public void testCreate_stretchFill() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.STRETCH,
+ BipTransformation.FILL});
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill", trans.toString());
+ }
+
+ @Test
+ public void testCreate_stretchFillCrop() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.STRETCH,
+ BipTransformation.FILL, BipTransformation.CROP});
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill crop", trans.toString());
+ }
+
+ @Test
+ public void testCreate_stretchFillCropOrderChanged() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.CROP,
+ BipTransformation.FILL, BipTransformation.STRETCH});
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill crop", trans.toString());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreate_badTransformationOnly() {
+ BipTransformation trans = new BipTransformation(-7);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testCreate_badTransformation() {
+ BipTransformation trans = new BipTransformation(new int[]{BipTransformation.CROP, -7});
+ }
+
+ @Test
+ public void testParse_badTransformationOnly() {
+ BipTransformation trans = new BipTransformation("bad");
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals(null, trans.toString());
+ }
+
+ @Test
+ public void testParse_badTransformationMixedIn() {
+ BipTransformation trans = new BipTransformation("crop fill bad stretch");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch fill crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_badTransformationStart() {
+ BipTransformation trans = new BipTransformation("bad crop fill");
+ Assert.assertFalse(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertTrue(trans.isSupported(BipTransformation.FILL));
+ Assert.assertTrue(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("fill crop", trans.toString());
+ }
+
+ @Test
+ public void testParse_badTransformationEnd() {
+ BipTransformation trans = new BipTransformation("stretch bad");
+ Assert.assertTrue(trans.isSupported(BipTransformation.STRETCH));
+ Assert.assertFalse(trans.isSupported(BipTransformation.FILL));
+ Assert.assertFalse(trans.isSupported(BipTransformation.CROP));
+ Assert.assertEquals("stretch", trans.toString());
+ }
+}
diff --git a/tests/unit/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachineTest.java b/tests/unit/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachineTest.java
index 1ed8fe8..7388434 100644
--- a/tests/unit/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachineTest.java
+++ b/tests/unit/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachineTest.java
@@ -1,6 +1,8 @@
package com.android.bluetooth.hfpclient;
import static com.android.bluetooth.hfpclient.HeadsetClientStateMachine.AT_OK;
+import static com.android.bluetooth.hfpclient.HeadsetClientStateMachine.VOICE_RECOGNITION_START;
+import static com.android.bluetooth.hfpclient.HeadsetClientStateMachine.VOICE_RECOGNITION_STOP;
import static org.mockito.Mockito.*;
@@ -562,4 +564,58 @@
final String vendorResponseArgument = "2";
runUnsupportedVendorEvent(vendorId, vendorResponseCode, vendorResponseArgument);
}
+
+ /**
+ * Test voice recognition state change broadcast.
+ */
+ @MediumTest
+ @Test
+ public void testVoiceRecognitionStateChange() {
+ // Setup connection state machine to be in connected state
+ when(mHeadsetClientService.getConnectionPolicy(any(BluetoothDevice.class))).thenReturn(
+ BluetoothProfile.CONNECTION_POLICY_ALLOWED);
+ when(mNativeInterface.startVoiceRecognition(any(byte[].class))).thenReturn(true);
+ when(mNativeInterface.stopVoiceRecognition(any(byte[].class))).thenReturn(true);
+
+ int expectedBroadcastIndex = 1;
+ expectedBroadcastIndex = setUpHfpClientConnection(expectedBroadcastIndex);
+ expectedBroadcastIndex = setUpServiceLevelConnection(expectedBroadcastIndex);
+
+ // Simulate a voice recognition start
+ mHeadsetClientStateMachine.sendMessage(VOICE_RECOGNITION_START);
+
+ // Signal that the complete list of actions was received.
+ StackEvent event = new StackEvent(StackEvent.EVENT_TYPE_CMD_RESULT);
+ event.device = mTestDevice;
+ event.valueInt = AT_OK;
+ mHeadsetClientStateMachine.sendMessage(StackEvent.STACK_EVENT, event);
+
+ expectedBroadcastIndex = verifyVoiceRecognitionBroadcast(expectedBroadcastIndex,
+ HeadsetClientHalConstants.VR_STATE_STARTED);
+
+ // Simulate a voice recognition stop
+ mHeadsetClientStateMachine.sendMessage(VOICE_RECOGNITION_STOP);
+
+ // Signal that the complete list of actions was received.
+ event = new StackEvent(StackEvent.EVENT_TYPE_CMD_RESULT);
+ event.device = mTestDevice;
+ event.valueInt = AT_OK;
+ mHeadsetClientStateMachine.sendMessage(StackEvent.STACK_EVENT, event);
+
+ verifyVoiceRecognitionBroadcast(expectedBroadcastIndex,
+ HeadsetClientHalConstants.VR_STATE_STOPPED);
+ }
+
+ private int verifyVoiceRecognitionBroadcast(int expectedBroadcastIndex, int expectedState) {
+ // Validate broadcast intent
+ ArgumentCaptor<Intent> intentArgument = ArgumentCaptor.forClass(Intent.class);
+ verify(mHeadsetClientService, timeout(STANDARD_WAIT_MILLIS).times(expectedBroadcastIndex))
+ .sendBroadcast(intentArgument.capture(), anyString());
+ Assert.assertEquals(BluetoothHeadsetClient.ACTION_AG_EVENT,
+ intentArgument.getValue().getAction());
+ int state = intentArgument.getValue().getIntExtra(
+ BluetoothHeadsetClient.EXTRA_VOICE_RECOGNITION, -1);
+ Assert.assertEquals(expectedState, state);
+ return expectedBroadcastIndex + 1;
+ }
}