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;
+    }
 }