Add support for the AVRCP Controller Cover Art feature

Bug: b/132812696
Test: Build, flash, interop test with devices, atest. New unit tests to
come as the topic of a new patch.

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