Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 373eefcf authored by Sal Savage's avatar Sal Savage
Browse files

Add the AVRCP Target 1.6 cover art feature

This patch adds a service that maintains a set of images and a set of
BIP OBEX clients. The service provides an interface to store images and
returns handles to retrieve them at. The handles are added to AVRCP
metadata objects in the Utils helper file when converting Media objects
to objects consumable by the native stack. Handles are sent with the
metadata objects through the native stack to the remote device.
BIP client connections allow remote devices to trade those handles for
the actual images. Handles are only sent for devices with an active
BIP client.

Th feature is defaulted to off, and can be enabled through a resource
overlay, 'avrcp_target_enable_cover_art." The feature additionally
requires that you choose profile version 1.6 in settings.

Tag: #feature
Bug: 153076316
Test: atest BluetoothInstrumentationTests
Test: build, flash, interop test with controller devices that support
the feature, PTS test the feature against tests in design doc.

Change-Id: I63b35c3503cfff2c2913196b08806e499ca9412f
parent 63b91a56
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -101,6 +101,9 @@
    <integer name="a2dp_source_codec_priority_aptx_hd">4001</integer>
    <integer name="a2dp_source_codec_priority_ldac">5001</integer>

    <!-- For enabling the AVRCP Target Cover Artowrk feature-->
    <bool name="avrcp_target_enable_cover_art">false</bool>

    <!-- Package that is responsible for user interaction on pairing request,
         success or cancel.
         Receives:
+366 −0
Original line number Diff line number Diff line
/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.bluetooth.avrcp;

import android.util.Log;

import com.android.bluetooth.avrcpcontroller.BipImageDescriptor;
import com.android.bluetooth.avrcpcontroller.BipImageProperties;
import com.android.bluetooth.avrcpcontroller.ParseException;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Arrays;

import javax.obex.HeaderSet;
import javax.obex.Operation;
import javax.obex.ResponseCodes;
import javax.obex.ServerRequestHandler;

/**
 * A class responsible for handling requests from a specific client connection
 */
public class AvrcpBipObexServer extends ServerRequestHandler {
    private static final String TAG = "AvrcpBipObexServer";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    private final AvrcpCoverArtService mAvrcpCoverArtService;

    // 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 String TYPE_GET_LINKED_THUMBNAIL = "x-bt/img-thm";
    private static final String TYPE_GET_IMAGE_PROPERTIES = "x-bt/img-properties";
    private static final String TYPE_GET_IMAGE = "x-bt/img-img";

    private static final byte HEADER_ID_IMG_HANDLE = 0x30;
    private static final byte HEADER_ID_IMG_DESCRIPTOR = 0x71;

    private final Callback mCallback;

    /**
     * A set of callbacks to notify the creator of important AVRCP BIP events.
     */
    public interface Callback {
        /**
         * Receive a notification when this server session connects to a device
         */
        void onConnected();

        /**
         * Receive a notification when this server session disconnects with a device
         */
        void onDisconnected();
         /**
          * Receive a notification when this server session closes a connection with a device
          */
        void onClose();
    }

    public AvrcpBipObexServer(AvrcpCoverArtService service, Callback callback) {
        super();
        mAvrcpCoverArtService = service;
        mCallback = callback;
    }

    @Override
    public int onConnect(final HeaderSet request, HeaderSet reply) {
        debug("onConnect");
        try {
            byte[] uuid = (byte[]) request.getHeader(HeaderSet.TARGET);
            debug("onConnect - uuid=" + Arrays.toString(uuid));
            if (!Arrays.equals(uuid, BLUETOOTH_UUID_AVRCP_COVER_ART)) {
                warn("onConnect - uuid didn't match. Not Acceptable");
                return ResponseCodes.OBEX_HTTP_NOT_ACCEPTABLE;
            }
            // ...
        } catch (IOException e) {
            warn("onConnect - Something bad happened");
            return ResponseCodes.OBEX_HTTP_INTERNAL_ERROR;
        }

        reply.setHeader(HeaderSet.WHO, BLUETOOTH_UUID_AVRCP_COVER_ART);
        debug("onConnect - Successful");
        if (mCallback != null) {
            mCallback.onConnected();
        }
        return ResponseCodes.OBEX_HTTP_OK;
    }

    @Override
    public void onDisconnect(final HeaderSet request, HeaderSet reply) {
        debug("onDisconnect");
        if (mCallback != null) {
            mCallback.onDisconnected();
        }
    }

    @Override
    public int onGet(final Operation op) {
        debug("onGet");
        try {
            HeaderSet request = op.getReceivedHeader();
            if (request == null) return ResponseCodes.OBEX_HTTP_BAD_REQUEST;

            // Route the request to the proper response handler
            String type = (String) request.getHeader(HeaderSet.TYPE);
            if (TYPE_GET_LINKED_THUMBNAIL.equals(type)) {
                return handleGetImageThumbnail(op);
            } else if (TYPE_GET_IMAGE_PROPERTIES.equals(type)) {
                return handleGetImageProperties(op);
            } else if (TYPE_GET_IMAGE.equals(type)) {
                return handleGetImage(op);
            } else {
                warn("Received unknown type '" + type + "'");
                return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
            }
        } catch (IllegalArgumentException e) {
            return ResponseCodes.OBEX_HTTP_PRECON_FAILED;
        } catch (ParseException e) {
            return ResponseCodes.OBEX_HTTP_PRECON_FAILED;
        } catch (Exception e) {
            return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
        }
    }

    @Override
    public int onPut(final Operation op) {
        return ResponseCodes.OBEX_HTTP_NOT_IMPLEMENTED;
    }

    @Override
    public int onAbort(final HeaderSet request, HeaderSet reply) {
        return ResponseCodes.OBEX_HTTP_NOT_IMPLEMENTED;
    }

    @Override
    public int onSetPath(final HeaderSet request, HeaderSet reply, final boolean backup,
            final boolean create) {
        return ResponseCodes.OBEX_HTTP_NOT_IMPLEMENTED;
    }

    @Override
    public void onClose() {
        debug("Connection closed");
        if (mCallback != null) {
            mCallback.onClose();
        }
    }

    /**
     * Determine if a given image handle is valid in format
     *
     * An image handle a 9 character string of numbers 0-9 only. Anything else is invalid. This is
     * defined in section 4.4.4 (Image Handles) of the BIP specification, which is inherited by the
     * AVRCP specification.
     *
     * @return True if the image handle is valid, false otherwise.
     */
    private boolean isImageHandleValid(String handle) {
        if (handle == null || handle.length() != 7) return false;
        for (int i = 0; i < 7; i++) {
            char c = handle.charAt(i);
            if (!Character.isDigit(c)) return false;
        }
        return true;
    }

    private int handleGetImageThumbnail(Operation op)throws IOException  {
        HeaderSet request = op.getReceivedHeader();
        String imageHandle = (String) request.getHeader(HEADER_ID_IMG_HANDLE);

        debug("Received GetImageThumbnail(handle='" + imageHandle + "')");

        if (imageHandle == null) {
            warn("Received GetImageThumbnail without an image handle");
            return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
        }

        if (!isImageHandleValid(imageHandle)) {
            debug("Received GetImageThumbnail with an invalid image handle");
            return ResponseCodes.OBEX_HTTP_PRECON_FAILED;
        }

        CoverArt image = mAvrcpCoverArtService.getImage(imageHandle);
        if (image == null) {
            warn("No image stored at handle '" + imageHandle + "'");
            return ResponseCodes.OBEX_HTTP_NOT_FOUND;
        }

        byte[] thumbnail = image.getThumbnail();
        if (thumbnail == null) {
            warn("Failed to serialize image");
            return ResponseCodes.OBEX_HTTP_NOT_FOUND;
        }

        HeaderSet replyHeaders = new HeaderSet();
        return sendResponse(op, replyHeaders, thumbnail);
    }

    private int handleGetImageProperties(Operation op) throws IOException {
        HeaderSet request = op.getReceivedHeader();
        String imageHandle = (String) request.getHeader(HEADER_ID_IMG_HANDLE);

        debug("Received GetImageProperties(handle='" + imageHandle + "')");

        if (imageHandle == null) {
            warn("Received GetImageProperties without an image handle");
            return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
        }

        if (!isImageHandleValid(imageHandle)) {
            debug("Received GetImageProperties with an invalid image handle");
            return ResponseCodes.OBEX_HTTP_PRECON_FAILED;
        }

        CoverArt image = mAvrcpCoverArtService.getImage(imageHandle);
        if (image == null) {
            warn("No image stored at handle '" + imageHandle + "'");
            return ResponseCodes.OBEX_HTTP_NOT_FOUND;
        }
        BipImageProperties properties = image.getImageProperties();
        if (properties == null) {
            warn("Failed to get properties for known image");
            return ResponseCodes.OBEX_HTTP_NOT_FOUND;
        }

        byte[] propertiesBytes = properties.serialize();
        if (propertiesBytes == null) {
            debug("Failed to serialize properties for image");
            return ResponseCodes.OBEX_HTTP_NOT_FOUND;
        }

        debug("Sending image properties: " + properties);
        HeaderSet replyHeaders = new HeaderSet();
        return sendResponse(op, replyHeaders, propertiesBytes);
    }

    private int handleGetImage(Operation op) throws IOException {
        HeaderSet request = op.getReceivedHeader();
        String imageHandle = (String) request.getHeader(HEADER_ID_IMG_HANDLE);
        byte[] descriptorBytes = (byte[]) request.getHeader(HEADER_ID_IMG_DESCRIPTOR);
        BipImageDescriptor descriptor = null;

        if (descriptorBytes != null) {
            descriptor = new BipImageDescriptor(new ByteArrayInputStream(descriptorBytes));
        }

        debug("Received GetImage(handle='" + imageHandle + "', descriptor='" + descriptor + "')");

        if (imageHandle == null) {
            warn("Received GetImage without an image handle");
            return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
        }

        if (!isImageHandleValid(imageHandle)) {
            debug("Received GetImage with an invalid image handle");
            return ResponseCodes.OBEX_HTTP_PRECON_FAILED;
        }

        CoverArt image = mAvrcpCoverArtService.getImage(imageHandle);
        if (image == null) {
            warn("No image stored at handle '" + imageHandle + "'");
            return ResponseCodes.OBEX_HTTP_NOT_FOUND;
        }

        byte[] imageBytes = null;
        if (descriptor == null) {
            debug("Received GetImage without an image descriptor. Returning native format");
            imageBytes = image.getImage();
        } else {
            imageBytes = image.getImage(descriptor);
        }

        if (imageBytes == null) {
            warn("Failed to serialize image with given format");
            return ResponseCodes.OBEX_HTTP_NOT_ACCEPTABLE; // BIP Section 5.3 unsupported format
        }

        debug("Sending image");
        HeaderSet replyHeaders = new HeaderSet();
        replyHeaders.setHeader(HeaderSet.LENGTH, null); // Section 4.5.8, Required, null is fine
        return sendResponse(op, replyHeaders, imageBytes);
    }

    /**
     * Send a response to the given operation using the given headers and bytes.
     */
    private int sendResponse(Operation op, HeaderSet replyHeaders, byte[] bytes) {
        if (op != null && bytes != null && replyHeaders != null) {
            OutputStream outStream = null;
            int maxChunkSize = 0;
            int bytesToWrite = 0;
            int bytesWritten = 0;
            try {
                op.sendHeaders(replyHeaders); // Do this before getting chunk size
                maxChunkSize = op.getMaxPacketSize();
                outStream = op.openOutputStream();
                while (bytesWritten < bytes.length) {
                    bytesToWrite = Math.min(maxChunkSize, bytes.length - bytesWritten);
                    outStream.write(bytes, bytesWritten, bytesToWrite);
                    bytesWritten += bytesToWrite;
                }
            } catch (IOException e) {
                warn("An exception occurred while writing response, e=" + e);
            } finally {
                // Make sure we close
                if (outStream != null) {
                    try {
                        outStream.close();
                    } catch (IOException e) { }
                }
            }
            // If we didn't write everything then send the error code
            if (bytesWritten != bytes.length) {
                warn("Failed to write entire response");
                return ResponseCodes.OBEX_HTTP_BAD_REQUEST;
            }
            // Otherwise, success!
            return ResponseCodes.OBEX_HTTP_OK;
        }
        // If had no header or no body to send then assume we didn't find anything at all
        return ResponseCodes.OBEX_HTTP_NOT_FOUND;
    }

    private void warn(String msg) {
        Log.w(TAG, msg);
    }

    private void debug(String msg) {
        if (DEBUG) {
            Log.d(TAG, msg);
        }
    }
}
+295 −0
Original line number Diff line number Diff line
/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.bluetooth.avrcp;

import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.Context;
import android.util.Log;

import com.android.bluetooth.BluetoothObexTransport;
import com.android.bluetooth.IObexConnectionHandler;
import com.android.bluetooth.ObexServerSockets;
import com.android.bluetooth.audio_util.Image;

import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;

import javax.obex.ServerSession;

/**
 * The AVRCP Cover Art Service
 *
 * This service handles allocation of image handles and storage of images. It also owns the BIP OBEX
 * server that handles requests to get AVRCP cover artwork.
 */
public class AvrcpCoverArtService {
    private static final String TAG = "AvrcpCoverArtService";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    private static final int COVER_ART_STORAGE_MAX_ITEMS = 255;

    private final Context mContext;

    // Cover Art and Image Handle objects
    private final AvrcpCoverArtStorage mStorage;

    // BIP Server Objects
    private volatile boolean mShutdown = true;
    private final SocketAcceptor mAcceptThread;
    private ObexServerSockets mServerSockets = null;
    private final HashMap<BluetoothDevice, ServerSession> mClients =
            new HashMap<BluetoothDevice, ServerSession>();
    private final Object mClientsLock = new Object();
    private final Object mServerLock = new Object();

    // Native interface
    private AvrcpNativeInterface mNativeInterface;

    public AvrcpCoverArtService(Context context) {
        mContext = context;
        mNativeInterface = AvrcpNativeInterface.getInterface();
        mAcceptThread = new SocketAcceptor();
        mStorage = new AvrcpCoverArtStorage(COVER_ART_STORAGE_MAX_ITEMS);
    }

    /**
     * Start the AVRCP Cover Art Service.
     *
     * This will start up a BIP OBEX server and record the l2cap psm in the SDP record, and begin
     * accepting connections.
     */
    public boolean start() {
        debug("Starting service");
        if (!isShutdown()) {
            error("Service already started");
            return true;
        }
        mStorage.clear();
        return startBipServer();
    }

    /**
     * Stop the AVRCP Cover Art Service.
     *
     * Tear down existing connections, remove ourselved from the SDP record.
     */
    public boolean stop() {
        debug("Stopping service");
        stopBipServer();
        synchronized (mClientsLock) {
            for (ServerSession session : mClients.values()) {
                session.close();
            }
            mClients.clear();
        }
        mStorage.clear();
        return true;
    }

    private boolean startBipServer() {
        debug("Starting BIP OBEX server");
        synchronized (mServerLock) {
            mServerSockets = ObexServerSockets.create(mAcceptThread);
            if (mServerSockets == null) {
                error("Failed to get a server socket. Can't setup cover art service");
                return false;
            }
            registerBipServer(mServerSockets.getL2capPsm());
            mShutdown = false;
            debug("Service started, psm=" + mServerSockets.getL2capPsm());
        }
        return true;
    }

    private boolean stopBipServer() {
        debug("Stopping BIP OBEX server");
        synchronized (mServerLock) {
            mShutdown = true;
            unregisterBipServer();
            if (mServerSockets != null) {
                mServerSockets.shutdown(false);
                mServerSockets = null;
            }
        }
        return true;
    }

    private boolean isShutdown() {
        synchronized (mServerLock) {
            return mShutdown;
        }
    }

    private int getL2capPsm() {
        synchronized (mServerLock) {
            return (mServerLock != null ? mServerSockets.getL2capPsm() : 0);
        }
    }

    /**
     * Store an image with the service and gets the image handle it's associated with.
     */
    public String storeImage(Image image) {
        debug("storeImage(image='" + image + "')");
        if (image == null || image.getImage() == null) return null;
        return mStorage.storeImage(new CoverArt(image));
    }

    /**
     * Get the image stored at the given image handle, if it exists
     */
    public CoverArt getImage(String imageHandle) {
        debug("getImage(" + imageHandle + ")");
        return mStorage.getImage(imageHandle);
    }

    /**
     * Add a BIP L2CAP PSM to the AVRCP Target SDP Record
     */
    private void registerBipServer(int psm) {
        debug("Add our PSM (" + psm + ") to the AVRCP Target SDP record");
        mNativeInterface.registerBipServer(psm);
        return;
    }

    /**
     * Remove any BIP L2CAP PSM from the AVRCP Target SDP Record
     */
    private void unregisterBipServer() {
        debug("Remove the PSM remove the AVRCP Target SDP record");
        mNativeInterface.unregisterBipServer();
        return;
    }

    /**
     * Connect a device with the server
     *
     * Since the server cannot explicitly make clients connect, this function is internal only and
     * provides a place for us to do required book keeping when we've decided to accept a client
     */
    private boolean connect(BluetoothDevice device, BluetoothSocket socket) {
        debug("Connect '" + device + "'");
        synchronized (mClientsLock) {

            // Only allow one client at all
            if (mClients.size() >= 1) return false;

            // Only allow one client per device
            if (mClients.containsKey(device)) return false;

            // Create a BIP OBEX Server session for the client and connect
            AvrcpBipObexServer s = new AvrcpBipObexServer(this, new AvrcpBipObexServer.Callback() {
                public void onConnected() {
                    mNativeInterface.setBipClientStatus(device.getAddress(), true);
                }

                public void onDisconnected() {
                    mNativeInterface.setBipClientStatus(device.getAddress(), false);
                }

                public void onClose() {
                    disconnect(device);
                }
            });
            BluetoothObexTransport transport = new BluetoothObexTransport(socket);
            try {
                ServerSession session = new ServerSession(transport, s, null);
                mClients.put(device, session);
                return true;
            } catch (IOException e) {
                error(e.toString());
                return false;
            }
        }
    }

    /**
     * Explicitly disconnect a device from our BIP server if its connected.
     */
    public void disconnect(BluetoothDevice device) {
        debug("disconnect '" + device + "'");
        // Closing the server session closes the underlying transport, which closes the underlying
        // socket as well. No need to maintain and close anything else.
        synchronized (mClientsLock) {
            if (mClients.containsKey(device)) {
                mNativeInterface.setBipClientStatus(device.getAddress(), false);
                ServerSession session = mClients.get(device);
                mClients.remove(device);
                session.close();
            }
        }
    }

    /**
     * A Socket Acceptor to handle incoming connections to our BIP Server.
     *
     * If we are accepting connections and the device is permitted, then this class will create a
     * session with our AvrcpBipObexServer.
     */
    private class SocketAcceptor implements IObexConnectionHandler {
        @Override
        public synchronized boolean onConnect(BluetoothDevice device, BluetoothSocket socket) {
            debug("onConnect() - device=" + device + ", socket=" + socket);
            if (isShutdown()) return false;
            return connect(device, socket);
        }

        @Override
        public synchronized void onAcceptFailed() {
            error("OnAcceptFailed()");
            if (isShutdown()) {
                error("Failed to accept incoming connection due to shutdown");
            } else {
                // restart
                stop();
                start();
            }
        }
    }

    /**
     * Dump useful debug information about this service to a string
     */
    public void dump(StringBuilder sb) {
        int psm = getL2capPsm();
        sb.append("AvrcpCoverArtService:");
        sb.append("\n\tpsm = " + (psm == 0 ? "null" : psm));
        mStorage.dump(sb);
        synchronized (mClientsLock) {
            sb.append("\n\tclients = " + Arrays.toString(mClients.keySet().toArray()));
        }
        sb.append("\n");
    }

    /**
     * Print a message to DEBUG if debug output is enabled
     */
    private void debug(String msg) {
        if (DEBUG) {
            Log.d(TAG, msg);
        }
    }

    /**
     * Print a message to ERROR
     */
    private void error(String msg) {
        Log.e(TAG, msg);
    }
}
+212 −0

File added.

Preview size limit exceeded, changes collapsed.

+47 −3

File changed.

Preview size limit exceeded, changes collapsed.

Loading