Merge changes from topic "bt-avrcp-controller-cover-art"

* changes:
  Avrcp controller cover art feature tests
  Add support for the AVRCP Controller Cover Art feature
  House all AVRCP metadata in custom objects instead of MediaMetadata
  Migrate the rest of AVRCP Controller to use MediaCompat
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index a6d3eaf..6dc6cab 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -345,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_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/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 87291f0..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,10 +166,11 @@
     }
 
     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);
         }
@@ -301,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);
@@ -326,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. */
@@ -336,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(
@@ -349,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,
@@ -364,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);
         }
     }
 
@@ -378,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(
@@ -392,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(
@@ -426,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 =
@@ -442,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) {
 
@@ -467,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,
@@ -518,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;
     }
 
@@ -579,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,
@@ -591,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) {
@@ -604,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) {
@@ -617,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) {
@@ -630,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) {
@@ -697,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<>();
@@ -733,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 bd948b4..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;
@@ -59,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;
@@ -81,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;
@@ -89,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
@@ -109,6 +111,8 @@
     protected final BluetoothDevice mDevice;
     protected final byte[] mDeviceAddress;
     protected final AvrcpControllerService mService;
+    protected int mCoverArtPsm;
+    protected final AvrcpCoverArtManager mCoverArtManager;
     protected final Disconnected mDisconnected;
     protected final Connecting mConnecting;
     protected final Connected mConnected;
@@ -137,6 +141,8 @@
         mDevice = device;
         mDeviceAddress = Utils.getByteAddress(mDevice);
         mService = service;
+        mCoverArtPsm = 0;
+        mCoverArtManager = service.getCoverArtManager();
         logD(device.toString());
 
         mBrowseTree = new BrowseTree(mDevice);
@@ -280,6 +286,22 @@
         mBrowsingConnected = false;
     }
 
+    synchronized void connectCoverArt() {
+        // Called from "connected" state, which assumes either control or browse is connected
+        if (mCoverArtManager != null && mCoverArtPsm != 0) {
+            logD("Attempting to connect to AVRCP BIP, psm: " + mCoverArtPsm);
+            mCoverArtManager.connect(mDevice, /* psm */ mCoverArtPsm);
+        }
+    }
+
+    synchronized void disconnectCoverArt() {
+        // Safe to call even if we're not connected
+        if (mCoverArtManager != null) {
+            logD("Disconnect BIP cover artwork");
+            mCoverArtManager.disconnect(mDevice);
+        }
+    }
+
     private void notifyChanged(BrowseTree.BrowseNode node) {
         BluetoothMediaBrowserService.notifyChanged(node);
     }
@@ -317,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);
@@ -350,6 +375,7 @@
                 BluetoothMediaBrowserService.addressedPlayerChanged(mSessionCallbacks);
                 BluetoothMediaBrowserService.notifyChanged(mAddressedPlayer.getPlaybackState());
                 broadcastConnectionStateChanged(BluetoothProfile.STATE_CONNECTED);
+                connectCoverArt(); // only works if we have a valid PSM
             } else {
                 logD("ReEnteringConnected");
             }
@@ -397,9 +423,11 @@
                     return true;
 
                 case MESSAGE_PROCESS_TRACK_CHANGED:
-                    mAddressedPlayer.updateCurrentTrack((MediaMetadata) msg.obj);
+                    AvrcpItem track = (AvrcpItem) msg.obj;
+                    downloadImageIfNeeded(track);
+                    mAddressedPlayer.updateCurrentTrack(track);
                     if (isActive()) {
-                        BluetoothMediaBrowserService.trackChanged((MediaMetadata) msg.obj);
+                        BluetoothMediaBrowserService.trackChanged(track);
                     }
                     return true;
 
@@ -459,6 +487,31 @@
                     processAvailablePlayerChanged();
                     return true;
 
+                case MESSAGE_PROCESS_RECEIVED_COVER_ART_PSM:
+                    mCoverArtPsm = msg.arg1;
+                    connectCoverArt();
+                    return true;
+
+                case MESSAGE_PROCESS_IMAGE_DOWNLOADED:
+                    AvrcpCoverArtManager.DownloadEvent event =
+                            (AvrcpCoverArtManager.DownloadEvent) msg.obj;
+                    String handle = event.getHandle();
+                    Uri uri = event.getUri();
+                    logD("Received image for " + handle + " at " + uri.toString());
+
+                    // Let the addressed player know we got an image so it can see if the current
+                    // track now has cover artwork
+                    boolean addedArtwork = mAddressedPlayer.notifyImageDownload(handle, uri);
+                    if (addedArtwork) {
+                        BluetoothMediaBrowserService.trackChanged(
+                                mAddressedPlayer.getCurrentTrack());
+                    }
+
+                    // Let the browse tree know of the newly downloaded image so it can attach it to
+                    // all the items that need it
+                    mBrowseTree.notifyImageDownload(handle, uri);
+                    return true;
+
                 case DISCONNECT:
                     transitionTo(mDisconnecting);
                     return true;
@@ -577,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
@@ -695,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,
@@ -776,6 +838,7 @@
     protected class Disconnecting extends State {
         @Override
         public void enter() {
+            disconnectCoverArt();
             onBrowsingDisconnected();
             if (isActive()) {
                 sActiveDevice = null;
@@ -843,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() {
@@ -873,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) {
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/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 494994d..f869fe1 100644
--- a/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachineTest.java
+++ b/tests/unit/src/com/android/bluetooth/avrcpcontroller/AvrcpControllerStateMachineTest.java
@@ -485,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,
@@ -541,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,
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());
+    }
+}