Linux bluetooth development
 help / color / mirror / Atom feed
From: Jan-Michael <jan.brummer@tabos.org>
To: linux-bluetooth@vger.kernel.org
Cc: jan.brummer@tabos.org
Subject: [PATCH BlueZ] Add cover art support
Date: Mon, 31 Aug 2026 17:00:51 +0200	[thread overview]
Message-ID: <20260831150051.80631-2-jan.brummer@tabos.org> (raw)
In-Reply-To: <20260831150051.80631-1-jan.brummer@tabos.org>

From: Jan-Michael Brummer <jan.brummer@tabos.org>

Add bluetooth cover art support based on the existing code.
Tested with VW head unit and Fairphone 5.
---
 Makefile.plugins           |   4 +-
 doc/org.bluez.Media.rst    |  21 ++
 profiles/audio/avrcp-bip.c | 608 +++++++++++++++++++++++++++++++++++++
 profiles/audio/avrcp-bip.h |  46 +++
 profiles/audio/avrcp.c     |  92 +++++-
 profiles/audio/media.c     | 194 ++++++++++++
 src/bluetooth.conf         |   1 +
 tools/mpris-proxy.c        |  81 +++++
 8 files changed, 1041 insertions(+), 6 deletions(-)
 create mode 100644 profiles/audio/avrcp-bip.c
 create mode 100644 profiles/audio/avrcp-bip.h

diff --git a/Makefile.plugins b/Makefile.plugins
index ac667beda..101e6bdc6 100644
--- a/Makefile.plugins
+++ b/Makefile.plugins
@@ -37,7 +37,9 @@ builtin_modules += avrcp
 builtin_sources += profiles/audio/control.h profiles/audio/control.c \
 			profiles/audio/avctp.h profiles/audio/avctp.c \
 			profiles/audio/avrcp.h profiles/audio/avrcp.c \
-			profiles/audio/avrcp-player.c
+			profiles/audio/avrcp-player.c \
+			profiles/audio/avrcp-bip.h profiles/audio/avrcp-bip.c \
+			$(gobex_sources)
 endif
 
 if NETWORK
diff --git a/doc/org.bluez.Media.rst b/doc/org.bluez.Media.rst
index 1352a822d..0c793db58 100644
--- a/doc/org.bluez.Media.rst
+++ b/doc/org.bluez.Media.rst
@@ -91,6 +91,27 @@ MPRIS 2.2 spec:
 
 http://specifications.freedesktop.org/mpris-spec/latest/
 
+The object may additionally implement **org.bluez.MediaPlayerCoverArt1** to
+serve album art to remote AVRCP controllers:
+
+.. code-block::
+
+	array{byte} GetCoverArt(string url)
+
+Called with the value the player last exported as **mpris:artUrl** whenever
+that value changes. It shall return the image as JPEG data, at most 1 MiB in
+size; other encodings are ignored by the AVRCP Cover Art responder.
+
+The image is passed as bytes rather than read from **mpris:artUrl** directly
+because bluetoothd is sandboxed and cannot access the caches players commonly
+store their artwork in. Players that do not implement this interface simply do
+not provide cover art.
+
+Possible Errors:
+
+:org.bluez.Error.NotSupported:
+:org.bluez.Error.Failed:
+
 Note: If the sender disconnects its objects are automatically unregistered.
 
 Possible Errors:
diff --git a/profiles/audio/avrcp-bip.c b/profiles/audio/avrcp-bip.c
new file mode 100644
index 000000000..dfd710b5b
--- /dev/null
+++ b/profiles/audio/avrcp-bip.c
@@ -0,0 +1,608 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ *  BlueZ - Bluetooth protocol stack for Linux
+ *
+ *  AVRCP 1.6 Cover Art Responder (BIP over OBEX/L2CAP, Target role)
+ *
+ *  Copyright (C) 2026  tabos.org
+ *
+ *  Implements the "Cover Art Responder" role of the Basic Imaging
+ *  Profile subset defined in AVRCP 1.6 section 5.14. The responder is
+ *  an OBEX server on a dynamic L2CAP PSM (GOEP 2.0, ERTM) which is
+ *  advertised in the AdditionalProtocolDescriptorList of the AVRCP
+ *  Target SDP record. Controllers (e.g. car head units) connect to it
+ *  and fetch the image referenced by media attribute 0x08 (ImgHandle)
+ *  using GetImageProperties, GetLinkedThumbnail and GetImage.
+ *
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdio.h>
+#include <string.h>
+#include <errno.h>
+
+#include <glib.h>
+
+#include "bluetooth/bluetooth.h"
+
+#include "gobex/gobex.h"
+#include "btio/btio.h"
+#include "src/adapter.h"
+#include "src/device.h"
+#include "src/log.h"
+
+#include "avctp.h"
+#include "avrcp-bip.h"
+
+/* OBEX Target UUID for AVRCP Cover Art (AVRCP 1.6, section 5.14.2.1) */
+static const uint8_t cover_art_target_uuid[] = {
+	0x71, 0x63, 0xDD, 0x54, 0x4A, 0x7E, 0x11, 0xE2,
+	0xB4, 0x7C, 0x00, 0x50, 0xC2, 0x49, 0x00, 0x48
+};
+
+/* BIP user defined headers */
+#define BIP_HDR_IMG_HANDLE	0x30	/* Unicode text */
+#define BIP_HDR_IMG_DESCRIPTOR	0x71	/* Byte sequence */
+
+#define BIP_TYPE_CAPABILITIES	"x-bt/img-capabilities"
+#define BIP_TYPE_PROPERTIES	"x-bt/img-properties"
+#define BIP_TYPE_IMAGE		"x-bt/img-img"
+#define BIP_TYPE_THUMBNAIL	"x-bt/img-thm"
+
+#define COVER_ART_MAX_IMAGES	4
+
+struct cover_image {
+	char		handle[8];	/* 7 digit handle + NUL */
+	GBytes		*data;
+	unsigned int	width;
+	unsigned int	height;
+};
+
+struct bip_session {
+	GObex		*obex;
+	GBytes		*pending;	/* image being transferred */
+	size_t		offset;
+	bool		connected;	/* CONNECT with valid target seen */
+	bdaddr_t	src;		/* local adapter address */
+	bdaddr_t	dst;		/* peer address */
+};
+
+/*
+ * Candidate PSMs for the responder. The listener is bound to
+ * BDADDR_ANY so it keeps working when the controller address changes
+ * after the first power-on (e.g. controllers that boot with a default
+ * address until the driver programs the real BD_ADDR). Dynamic kernel
+ * PSM allocation cannot be used for a BDADDR_ANY socket: the kernel
+ * only guarantees PSM uniqueness per source address, so the allocated
+ * PSM may collide with the dynamic PSMs handed to the external OBEX
+ * profiles (MNS/MAS/PBAP/...) of src/profile.c, which bind to the
+ * adapter address, and those sockets would then shadow this one. Pick
+ * from a high range instead that neither the kernel allocator nor
+ * profile.c reaches in practice.
+ */
+static const uint16_t candidate_psms[] = {
+	0x10F1, 0x10F3, 0x10F5, 0x10F7, 0x10F9
+};
+
+static GIOChannel *server_io;
+static uint16_t server_psm;
+static unsigned int server_ref;
+static uint32_t next_handle = 1;
+static GSList *images;		/* struct cover_image, newest first */
+static GSList *sessions;	/* struct bip_session */
+
+static bool session_has_avrcp(struct bip_session *session);
+
+/*
+ * Minimal JPEG SOFn parser to extract the pixel dimensions for the
+ * image-properties object. Returns false if the data doesn't look
+ * like a JPEG image.
+ */
+static bool jpeg_get_size(const uint8_t *data, size_t len,
+				unsigned int *width, unsigned int *height)
+{
+	size_t i;
+
+	if (len < 4 || data[0] != 0xff || data[1] != 0xd8)
+		return false;
+
+	i = 2;
+	while (i + 9 < len) {
+		uint8_t marker;
+		uint16_t seglen;
+
+		if (data[i] != 0xff) {
+			i++;
+			continue;
+		}
+
+		marker = data[i + 1];
+
+		/* Standalone markers without length field */
+		if (marker == 0xff || (marker >= 0xd0 && marker <= 0xd9)) {
+			i += 2;
+			continue;
+		}
+
+		seglen = (data[i + 2] << 8) | data[i + 3];
+		if (seglen < 2)
+			return false;
+
+		/* SOF0..SOF15 except DHT(C4)/JPG(C8)/DAC(CC) */
+		if (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 &&
+				marker != 0xc8 && marker != 0xcc) {
+			if (i + 9 >= len)
+				return false;
+			*height = (data[i + 5] << 8) | data[i + 6];
+			*width = (data[i + 7] << 8) | data[i + 8];
+			return true;
+		}
+
+		i += 2 + seglen;
+	}
+
+	return false;
+}
+
+static void cover_image_free(void *data)
+{
+	struct cover_image *img = data;
+
+	g_bytes_unref(img->data);
+	g_free(img);
+}
+
+static struct cover_image *find_image(const char *handle)
+{
+	GSList *l;
+
+	for (l = images; l; l = l->next) {
+		struct cover_image *img = l->data;
+
+		if (g_str_equal(img->handle, handle))
+			return img;
+	}
+
+	return NULL;
+}
+
+const char *avrcp_bip_set_cover_art(const uint8_t *data, size_t len)
+{
+	struct cover_image *img;
+	unsigned int width = 0, height = 0;
+
+	if (data == NULL || len == 0)
+		return NULL;
+
+	if (!jpeg_get_size(data, len, &width, &height)) {
+		DBG("cover art is not a valid JPEG image");
+		return NULL;
+	}
+
+	img = g_new0(struct cover_image, 1);
+	snprintf(img->handle, sizeof(img->handle), "%07u",
+					next_handle++ % 10000000);
+	img->data = g_bytes_new(data, len);
+	img->width = width;
+	img->height = height;
+
+	images = g_slist_prepend(images, img);
+
+	/* Keep a short tail so a controller can still fetch the
+	 * previous image right after a track change.
+	 */
+	while (g_slist_length(images) > COVER_ART_MAX_IMAGES) {
+		GSList *last = g_slist_last(images);
+
+		cover_image_free(last->data);
+		images = g_slist_delete_link(images, last);
+	}
+
+	DBG("handle %s (%zu bytes, %ux%u)", img->handle, len, width, height);
+
+	return img->handle;
+}
+
+void avrcp_bip_clear_cover_art(void)
+{
+	g_slist_free_full(images, cover_image_free);
+	images = NULL;
+}
+
+static void session_free(struct bip_session *session)
+{
+	sessions = g_slist_remove(sessions, session);
+
+	if (session->pending)
+		g_bytes_unref(session->pending);
+
+	if (session->obex)
+		g_obex_unref(session->obex);
+
+	g_free(session);
+}
+
+static void disconn_func(GObex *obex, GError *err, gpointer user_data)
+{
+	struct bip_session *session = user_data;
+
+	DBG("BIP session disconnected");
+
+	session_free(session);
+}
+
+static char *packet_get_type(GObexPacket *req)
+{
+	GObexHeader *hdr;
+	const guint8 *type;
+	gsize len;
+
+	hdr = g_obex_packet_get_header(req, G_OBEX_HDR_TYPE);
+	if (hdr == NULL)
+		return NULL;
+
+	if (!g_obex_header_get_bytes(hdr, &type, &len) || len == 0)
+		return NULL;
+
+	/* Type header is a NUL terminated ASCII string */
+	return g_strndup((const char *) type, len);
+}
+
+static char *packet_get_img_handle(GObexPacket *req)
+{
+	GObexHeader *hdr;
+	const char *handle;
+
+	hdr = g_obex_packet_get_header(req, BIP_HDR_IMG_HANDLE);
+	if (hdr == NULL)
+		return NULL;
+
+	if (!g_obex_header_get_unicode(hdr, &handle))
+		return NULL;
+
+	return g_strdup(handle);
+}
+
+static void connect_func(GObex *obex, GObexPacket *req, gpointer user_data)
+{
+	struct bip_session *session = user_data;
+	GObexHeader *hdr;
+	const guint8 *target;
+	gsize len;
+	GError *err = NULL;
+
+	hdr = g_obex_packet_get_header(req, G_OBEX_HDR_TARGET);
+	if (hdr == NULL || !g_obex_header_get_bytes(hdr, &target, &len) ||
+			len != sizeof(cover_art_target_uuid) ||
+			memcmp(target, cover_art_target_uuid, len) != 0) {
+		g_obex_send_rsp(obex, G_OBEX_RSP_NOT_ACCEPTABLE, NULL,
+							G_OBEX_HDR_INVALID);
+		return;
+	}
+
+	session->connected = true;
+
+	DBG("Cover Art OBEX session connected");
+
+	/* gobex fills in version/flags/mpl and the Connection ID */
+	g_obex_send_rsp(obex, G_OBEX_RSP_SUCCESS, &err,
+			G_OBEX_HDR_WHO, cover_art_target_uuid,
+			sizeof(cover_art_target_uuid),
+			G_OBEX_HDR_INVALID);
+
+	if (err != NULL) {
+		error("Cover Art CONNECT rsp: %s", err->message);
+		g_error_free(err);
+	}
+}
+
+static void disconnect_func(GObex *obex, GObexPacket *req, gpointer user_data)
+{
+	g_obex_send_rsp(obex, G_OBEX_RSP_SUCCESS, NULL, G_OBEX_HDR_INVALID);
+}
+
+static gssize pending_data_producer(void *buf, gsize len, gpointer user_data)
+{
+	struct bip_session *session = user_data;
+	gsize size, remaining;
+	const uint8_t *data;
+
+	if (session->pending == NULL)
+		return 0;
+
+	data = g_bytes_get_data(session->pending, &size);
+
+	if (session->offset >= size)
+		remaining = 0;
+	else
+		remaining = size - session->offset;
+
+	if (remaining == 0) {
+		g_bytes_unref(session->pending);
+		session->pending = NULL;
+		session->offset = 0;
+		return 0;
+	}
+
+	len = MIN(len, remaining);
+	memcpy(buf, data + session->offset, len);
+	session->offset += len;
+
+	return len;
+}
+
+static void transfer_complete(GObex *obex, GError *err, gpointer user_data)
+{
+	struct bip_session *session = user_data;
+
+	if (err != NULL)
+		DBG("Cover Art transfer failed: %s", err->message);
+
+	if (session->pending) {
+		g_bytes_unref(session->pending);
+		session->pending = NULL;
+	}
+
+	session->offset = 0;
+}
+
+static void respond_with_bytes(struct bip_session *session, GBytes *bytes,
+							gboolean with_length)
+{
+	GError *err = NULL;
+	gsize size;
+
+	g_bytes_get_data(bytes, &size);
+
+	if (session->pending)
+		g_bytes_unref(session->pending);
+
+	session->pending = g_bytes_ref(bytes);
+	session->offset = 0;
+
+	if (with_length)
+		g_obex_get_rsp(session->obex, pending_data_producer,
+				transfer_complete, session, &err,
+				G_OBEX_HDR_LENGTH, (guint32) size,
+				G_OBEX_HDR_INVALID);
+	else
+		g_obex_get_rsp(session->obex, pending_data_producer,
+				transfer_complete, session, &err,
+				G_OBEX_HDR_INVALID);
+
+	if (err != NULL) {
+		error("Cover Art GET rsp: %s", err->message);
+		g_error_free(err);
+		g_bytes_unref(session->pending);
+		session->pending = NULL;
+	}
+}
+
+static void get_image_properties(struct bip_session *session,
+						struct cover_image *img)
+{
+	GString *xml;
+	GBytes *bytes;
+	gsize size;
+	char *str;
+
+	g_bytes_get_data(img->data, &size);
+
+	xml = g_string_new("");
+	g_string_append_printf(xml,
+		"<image-properties version=\"1.0\" handle=\"%s\">\r\n"
+		"<native encoding=\"JPEG\" pixel=\"%u*%u\" size=\"%zu\"/>\r\n"
+		"<variant encoding=\"JPEG\" pixel=\"200*200\"/>\r\n"
+		"</image-properties>\r\n",
+		img->handle, img->width, img->height, size);
+
+	str = g_string_free(xml, FALSE);
+	bytes = g_bytes_new_take(str, strlen(str));
+
+	respond_with_bytes(session, bytes, FALSE);
+	g_bytes_unref(bytes);
+}
+
+static void get_func(GObex *obex, GObexPacket *req, gpointer user_data)
+{
+	struct bip_session *session = user_data;
+	struct cover_image *img = NULL;
+	char *type, *handle;
+
+	if (!session->connected || !session_has_avrcp(session)) {
+		g_obex_send_rsp(obex, G_OBEX_RSP_FORBIDDEN, NULL,
+							G_OBEX_HDR_INVALID);
+		return;
+	}
+
+	type = packet_get_type(req);
+	if (type == NULL) {
+		g_obex_send_rsp(obex, G_OBEX_RSP_BAD_REQUEST, NULL,
+							G_OBEX_HDR_INVALID);
+		return;
+	}
+
+	handle = packet_get_img_handle(req);
+
+	DBG("type %s handle %s", type, handle ? handle : "(none)");
+
+	if (handle != NULL)
+		img = find_image(handle);
+	else if (images != NULL)
+		img = images->data;	/* newest */
+
+	if (img == NULL) {
+		g_obex_send_rsp(obex, G_OBEX_RSP_NOT_FOUND, NULL,
+							G_OBEX_HDR_INVALID);
+		goto done;
+	}
+
+	if (g_str_equal(type, BIP_TYPE_PROPERTIES)) {
+		get_image_properties(session, img);
+	} else if (g_str_equal(type, BIP_TYPE_THUMBNAIL)) {
+		/*
+		 * BIP mandates a 200x200 baseline JPEG thumbnail. We
+		 * hand out the native image; head units tested (VW MIB)
+		 * scale it themselves. Players should provide a
+		 * reasonably sized JPEG via mpris:artUrl.
+		 */
+		respond_with_bytes(session, img->data, FALSE);
+	} else if (g_str_equal(type, BIP_TYPE_IMAGE)) {
+		respond_with_bytes(session, img->data, TRUE);
+	} else {
+		g_obex_send_rsp(obex, G_OBEX_RSP_NOT_IMPLEMENTED, NULL,
+							G_OBEX_HDR_INVALID);
+	}
+
+done:
+	g_free(type);
+	g_free(handle);
+}
+
+static void bip_connect_cb(GIOChannel *io, GError *gerr, gpointer user_data)
+{
+	struct bip_session *session;
+	GObex *obex;
+
+	if (gerr != NULL) {
+		error("Cover Art accept: %s", gerr->message);
+		return;
+	}
+
+	obex = g_obex_new(io, G_OBEX_TRANSPORT_PACKET, -1, -1);
+	if (obex == NULL) {
+		g_io_channel_shutdown(io, TRUE, NULL);
+		return;
+	}
+
+	session = g_new0(struct bip_session, 1);
+	session->obex = obex;
+
+	bt_io_get(io, NULL, BT_IO_OPT_SOURCE_BDADDR, &session->src,
+			BT_IO_OPT_DEST_BDADDR, &session->dst,
+			BT_IO_OPT_INVALID);
+
+	sessions = g_slist_prepend(sessions, session);
+
+	g_obex_set_disconnect_function(obex, disconn_func, session);
+	g_obex_add_request_function(obex, G_OBEX_OP_CONNECT, connect_func,
+								session);
+	g_obex_add_request_function(obex, G_OBEX_OP_DISCONNECT,
+						disconnect_func, session);
+	g_obex_add_request_function(obex, G_OBEX_OP_GET, get_func, session);
+
+	DBG("Cover Art transport connected");
+}
+
+/*
+ * AVRCP 1.6 section 5.14.2.2: the Cover Art OBEX connection is only
+ * valid while an AVRCP (AVCTP) connection exists between the same
+ * devices. This is enforced when image data is requested rather than
+ * when the transport is set up: head units establish the OBEX channel
+ * in parallel with AVCTP during the initial connection burst, so the
+ * AVCTP session may not be registered yet when the transport comes
+ * in, and rejecting it at that point makes controllers give up on
+ * cover art until the next connection.
+ */
+static bool session_has_avrcp(struct bip_session *session)
+{
+	struct btd_adapter *adapter;
+	struct btd_device *device;
+
+	adapter = adapter_find(&session->src);
+	if (adapter == NULL)
+		return false;
+
+	device = btd_adapter_find_device(adapter, &session->dst,
+								BDADDR_BREDR);
+	if (device == NULL || avctp_get(device) == NULL) {
+		DBG("Peer has no AVRCP session");
+		return false;
+	}
+
+	return true;
+}
+
+static void bip_confirm_cb(GIOChannel *io, gpointer user_data)
+{
+	GError *gerr = NULL;
+
+	if (!bt_io_accept(io, bip_connect_cb, NULL, NULL, &gerr)) {
+		error("Cover Art bt_io_accept: %s", gerr->message);
+		g_error_free(gerr);
+		g_io_channel_shutdown(io, TRUE, NULL);
+	}
+}
+
+uint16_t avrcp_bip_server_start(void)
+{
+	size_t i;
+
+	if (server_io != NULL) {
+		server_ref++;
+		return server_psm;
+	}
+
+	for (i = 0; i < G_N_ELEMENTS(candidate_psms); i++) {
+		GError *gerr = NULL;
+
+		server_io = bt_io_listen(NULL, bip_confirm_cb, NULL, NULL,
+				&gerr,
+				BT_IO_OPT_PSM, candidate_psms[i],
+				BT_IO_OPT_MODE, BT_IO_MODE_ERTM,
+				BT_IO_OPT_SEC_LEVEL, BT_IO_SEC_MEDIUM,
+				BT_IO_OPT_INVALID);
+		if (server_io != NULL) {
+			server_psm = candidate_psms[i];
+			break;
+		}
+
+		DBG("Cover Art responder PSM 0x%04x: %s",
+				candidate_psms[i], gerr->message);
+		g_error_free(gerr);
+	}
+
+	if (server_io == NULL) {
+		error("Cover Art responder: no free PSM");
+		return 0;
+	}
+
+	server_ref = 1;
+
+	DBG("Cover Art responder listening on PSM 0x%04x", server_psm);
+
+	return server_psm;
+}
+
+void avrcp_bip_server_stop(void)
+{
+	if (server_io == NULL)
+		return;
+
+	if (--server_ref > 0)
+		return;
+
+	while (sessions != NULL)
+		session_free(sessions->data);
+
+	avrcp_bip_clear_cover_art();
+
+	g_io_channel_shutdown(server_io, TRUE, NULL);
+	g_io_channel_unref(server_io);
+	server_io = NULL;
+	server_psm = 0;
+}
+
+bool avrcp_bip_server_active(void)
+{
+	return server_io != NULL;
+}
+
+uint16_t avrcp_bip_server_get_psm(void)
+{
+	return server_psm;
+}
diff --git a/profiles/audio/avrcp-bip.h b/profiles/audio/avrcp-bip.h
new file mode 100644
index 000000000..ce7f80a26
--- /dev/null
+++ b/profiles/audio/avrcp-bip.h
@@ -0,0 +1,46 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ *
+ *  BlueZ - Bluetooth protocol stack for Linux
+ *
+ *  AVRCP 1.6 Cover Art Responder (BIP over OBEX/L2CAP, Target role)
+ *
+ *  Copyright (C) 2026  tabos.org
+ *
+ */
+
+#ifndef __AVRCP_BIP_H
+#define __AVRCP_BIP_H
+
+#include <stdint.h>
+#include <stddef.h>
+#include <stdbool.h>
+
+/*
+ * Start the global BIP Cover Art responder. Reference counted, called
+ * once per adapter using it. Returns the L2CAP PSM the OBEX server is
+ * listening on, or 0 on failure.
+ */
+uint16_t avrcp_bip_server_start(void);
+
+/* Drop one reference; the listener is closed with the last user. */
+void avrcp_bip_server_stop(void);
+
+/* Whether the responder is currently listening. */
+bool avrcp_bip_server_active(void);
+
+/* L2CAP PSM of the running responder (0 if inactive). */
+uint16_t avrcp_bip_server_get_psm(void);
+
+/*
+ * Register the cover art of the current track. The data must be a
+ * complete JPEG image. Returns the 7-digit BIP image handle to be
+ * exposed as media attribute 0x08 (valid until replaced), or NULL
+ * on error.
+ */
+const char *avrcp_bip_set_cover_art(const uint8_t *data, size_t len);
+
+/* Remove all registered images (e.g. on playback stop). */
+void avrcp_bip_clear_cover_art(void);
+
+#endif /* __AVRCP_BIP_H */
diff --git a/profiles/audio/avrcp.c b/profiles/audio/avrcp.c
index 2194a9135..68f7b9176 100644
--- a/profiles/audio/avrcp.c
+++ b/profiles/audio/avrcp.c
@@ -52,6 +52,7 @@
 
 #include "avctp.h"
 #include "avrcp.h"
+#include "avrcp-bip.h"
 #include "control.h"
 #include "media.h"
 #include "player.h"
@@ -216,6 +217,7 @@ struct get_total_number_of_items_rsp {
 struct avrcp_server {
 	struct btd_adapter *adapter;
 	bool browsing;
+	bool cover_art;
 	uint32_t tg_record_id;
 	uint32_t ct_record_id;
 	GSList *players;
@@ -484,7 +486,63 @@ static sdp_record_t *avrcp_ct_record(bool browsing)
 	return record;
 }
 
-static sdp_record_t *avrcp_tg_record(bool browsing)
+static void avrcp_tg_add_protos(sdp_record_t *record, sdp_data_t *version,
+					bool browsing, uint16_t cover_psm)
+{
+	sdp_list_t *apseq_browsing = NULL, *apseq_obex = NULL;
+	uuid_t l2cap, avctp, obex;
+	sdp_list_t *aproto = NULL, *proto[2] = { NULL, NULL };
+	sdp_list_t *oproto[2] = { NULL, NULL };
+	sdp_data_t *psm = NULL, *opsm = NULL;
+	uint16_t ap = AVCTP_BROWSING_PSM;
+
+	if (!browsing && cover_psm == 0)
+		return;
+
+	sdp_uuid16_create(&l2cap, L2CAP_UUID);
+
+	if (browsing) {
+		proto[0] = sdp_list_append(NULL, &l2cap);
+		psm = sdp_data_alloc(SDP_UINT16, &ap);
+		proto[0] = sdp_list_append(proto[0], psm);
+		apseq_browsing = sdp_list_append(NULL, proto[0]);
+
+		sdp_uuid16_create(&avctp, AVCTP_UUID);
+		proto[1] = sdp_list_append(NULL, &avctp);
+		proto[1] = sdp_list_append(proto[1], version);
+		apseq_browsing = sdp_list_append(apseq_browsing, proto[1]);
+
+		aproto = sdp_list_append(aproto, apseq_browsing);
+	}
+
+	/* AVRCP 1.6 section 8: Cover Art OBEX transport entry */
+	if (cover_psm != 0) {
+		oproto[0] = sdp_list_append(NULL, &l2cap);
+		opsm = sdp_data_alloc(SDP_UINT16, &cover_psm);
+		oproto[0] = sdp_list_append(oproto[0], opsm);
+		apseq_obex = sdp_list_append(NULL, oproto[0]);
+
+		sdp_uuid16_create(&obex, OBEX_UUID);
+		oproto[1] = sdp_list_append(NULL, &obex);
+		apseq_obex = sdp_list_append(apseq_obex, oproto[1]);
+
+		aproto = sdp_list_append(aproto, apseq_obex);
+	}
+
+	sdp_set_add_access_protos(record, aproto);
+
+	free(psm);
+	free(opsm);
+	sdp_list_free(proto[0], NULL);
+	sdp_list_free(proto[1], NULL);
+	sdp_list_free(oproto[0], NULL);
+	sdp_list_free(oproto[1], NULL);
+	sdp_list_free(apseq_browsing, NULL);
+	sdp_list_free(apseq_obex, NULL);
+	sdp_list_free(aproto, NULL);
+}
+
+static sdp_record_t *avrcp_tg_record(bool browsing, uint16_t cover_psm)
 {
 	sdp_list_t *svclass_id, *pfseq, *apseq, *root;
 	uuid_t root_uuid, l2cap, avctp, avrtg;
@@ -500,6 +558,9 @@ static sdp_record_t *avrcp_tg_record(bool browsing)
 					AVRCP_FEATURE_CATEGORY_4 |
 					AVRCP_FEATURE_TG_PLAYER_SETTINGS);
 
+	if (cover_psm != 0)
+		feat |= AVRCP_FEATURE_TG_COVERT_ART;
+
 	record = sdp_record_alloc();
 	if (!record)
 		return NULL;
@@ -530,10 +591,10 @@ static sdp_record_t *avrcp_tg_record(bool browsing)
 	sdp_set_access_protos(record, aproto_control);
 
 	/* Additional Protocol Descriptor List */
-	if (browsing) {
+	if (browsing)
 		feat |= AVRCP_FEATURE_BROWSING;
-		avrcp_browsing_record(record, version);
-	}
+
+	avrcp_tg_add_protos(record, version, browsing, cover_psm);
 
 	/* Bluetooth Profile Descriptor List */
 	sdp_uuid16_create(&profile[0].uuid, AV_REMOTE_PROFILE_ID);
@@ -1272,6 +1333,18 @@ static uint8_t avrcp_handle_get_element_attributes(struct avrcp *session,
 					id > AVRCP_MEDIA_ATTRIBUTE_LAST)
 				continue;
 
+			/*
+			 * AVRCP 1.6 Section 5.14: the Default Cover Art
+			 * attribute shall only be included if a valid
+			 * image handle exists for the current track.
+			 * Returning it with an empty value makes some
+			 * head units (e.g. VW MIB) give up on Cover Art
+			 * for the rest of the session.
+			 */
+			if (id == AVRCP_MEDIA_ATTRIBUTE_IMG_HANDLE &&
+					player_get_metadata(player, id) == NULL)
+				continue;
+
 			len++;
 			attr_ids = g_list_prepend(attr_ids,
 							GUINT_TO_POINTER(id));
@@ -4872,6 +4945,11 @@ static void avrcp_target_server_remove(struct btd_profile *p,
 		server->tg_record_id = 0;
 	}
 
+	if (server->cover_art) {
+		avrcp_bip_server_stop();
+		server->cover_art = false;
+	}
+
 	if (server->ct_record_id == 0)
 		avrcp_server_unregister(server);
 }
@@ -4893,7 +4971,11 @@ static int avrcp_target_server_probe(struct btd_profile *p,
 		return -EPROTONOSUPPORT;
 
 done:
-	record = avrcp_tg_record(server->browsing);
+	if (!server->cover_art)
+		server->cover_art = avrcp_bip_server_start() != 0;
+
+	record = avrcp_tg_record(server->browsing,
+			server->cover_art ? avrcp_bip_server_get_psm() : 0);
 	if (!record) {
 		error("Unable to allocate new service record");
 		avrcp_target_server_remove(p, adapter);
diff --git a/profiles/audio/media.c b/profiles/audio/media.c
index 5d9ea2cbc..8ae716913 100644
--- a/profiles/audio/media.c
+++ b/profiles/audio/media.c
@@ -66,6 +66,9 @@
 #ifdef HAVE_A2DP
 #include "a2dp.h"
 #endif
+#ifdef HAVE_AVRCP
+#include "avrcp-bip.h"
+#endif
 
 #define MEDIA_INTERFACE "org.bluez.Media1"
 #define MEDIA_ENDPOINT_INTERFACE "org.bluez.MediaEndpoint1"
@@ -159,6 +162,10 @@ struct local_player {
 	bool			previous;
 	bool			control;
 	char			*name;
+	char			*art_url;	/* Registered cover art URL */
+	char			art_handle[8];	/* BIP handle of art_url */
+	char			*art_pending;	/* URL being fetched */
+	DBusPendingCall		*art_call;	/* Pending GetCoverArt call */
 	struct queue		*cbs;
 };
 
@@ -2039,6 +2046,20 @@ static void local_player_emit_player_added(struct local_player *mp)
 	}
 }
 
+#ifdef HAVE_AVRCP
+static void cover_art_cancel(struct local_player *mp)
+{
+	if (mp->art_call != NULL) {
+		dbus_pending_call_cancel(mp->art_call);
+		dbus_pending_call_unref(mp->art_call);
+		mp->art_call = NULL;
+	}
+
+	g_free(mp->art_pending);
+	mp->art_pending = NULL;
+}
+#endif
+
 static void local_player_destroy(struct local_player *mp)
 {
 	DBusConnection *conn = btd_get_dbus_connection();
@@ -2064,11 +2085,16 @@ static void local_player_destroy(struct local_player *mp)
 	if (mp->settings)
 		g_hash_table_unref(mp->settings);
 
+#ifdef HAVE_AVRCP
+	cover_art_cancel(mp);
+#endif
+
 	g_timer_destroy(mp->timer);
 	g_free(mp->sender);
 	g_free(mp->path);
 	g_free(mp->status);
 	g_free(mp->name);
+	g_free(mp->art_url);
 	g_free(mp);
 }
 
@@ -2458,6 +2484,159 @@ static gboolean parse_int32_metadata(struct local_player *mp, const char *key,
 	return TRUE;
 }
 
+#ifdef HAVE_AVRCP
+#define COVER_ART_MAX_SIZE (1024 * 1024)
+#define COVER_ART_TIMEOUT 5000 /* ms */
+#define MEDIA_PLAYER_COVER_ART_INTERFACE "org.bluez.MediaPlayerCoverArt1"
+
+static void cover_art_reset(struct local_player *mp)
+{
+	cover_art_cancel(mp);
+
+	g_free(mp->art_url);
+	mp->art_url = NULL;
+	mp->art_handle[0] = '\0';
+}
+
+static void cover_art_reply(DBusPendingCall *call, void *user_data)
+{
+	struct local_player *mp = user_data;
+	DBusMessage *reply;
+	DBusMessageIter iter, array;
+	const uint8_t *data = NULL;
+	const char *handle;
+	int len = 0;
+
+	reply = dbus_pending_call_steal_reply(call);
+
+	dbus_pending_call_unref(mp->art_call);
+	mp->art_call = NULL;
+
+	/* The call was cancelled or the connection went away */
+	if (reply == NULL) {
+		g_free(mp->art_pending);
+		mp->art_pending = NULL;
+		return;
+	}
+
+	if (dbus_message_get_type(reply) == DBUS_MESSAGE_TYPE_ERROR) {
+		DBG("GetCoverArt: %s", dbus_message_get_error_name(reply));
+		goto done;
+	}
+
+	if (!dbus_message_iter_init(reply, &iter) ||
+			dbus_message_iter_get_arg_type(&iter) !=
+							DBUS_TYPE_ARRAY ||
+			dbus_message_iter_get_element_type(&iter) !=
+							DBUS_TYPE_BYTE) {
+		DBG("GetCoverArt: unexpected reply signature");
+		goto done;
+	}
+
+	dbus_message_iter_recurse(&iter, &array);
+	dbus_message_iter_get_fixed_array(&array, &data, &len);
+
+	if (len <= 0 || len > COVER_ART_MAX_SIZE) {
+		DBG("cover art has invalid size (%d bytes), ignoring", len);
+		goto done;
+	}
+
+	/* Non-JPEG images are rejected by the responder */
+	handle = avrcp_bip_set_cover_art(data, len);
+	if (handle == NULL)
+		goto done;
+
+	g_free(mp->art_url);
+	mp->art_url = mp->art_pending;
+	mp->art_pending = NULL;
+
+	strncpy(mp->art_handle, handle, sizeof(mp->art_handle) - 1);
+	mp->art_handle[sizeof(mp->art_handle) - 1] = '\0';
+
+	/*
+	 * The track was announced without attribute 0x08 while the image
+	 * was in flight, so tell the peer to read the metadata again now
+	 * that a valid handle exists.
+	 */
+	if (mp->track != NULL) {
+		g_hash_table_insert(mp->track, g_strdup("ImgHandle"),
+							g_strdup(handle));
+		local_player_emit_track_changed(mp);
+	}
+
+done:
+	g_free(mp->art_pending);
+	mp->art_pending = NULL;
+	dbus_message_unref(reply);
+}
+
+static void cover_art_request(struct local_player *mp, const char *url)
+{
+	DBusMessage *msg;
+
+	cover_art_cancel(mp);
+
+	msg = dbus_message_new_method_call(mp->sender, mp->path,
+					MEDIA_PLAYER_COVER_ART_INTERFACE,
+					"GetCoverArt");
+	if (msg == NULL) {
+		error("Couldn't allocate D-Bus message");
+		return;
+	}
+
+	dbus_message_append_args(msg, DBUS_TYPE_STRING, &url,
+							DBUS_TYPE_INVALID);
+
+	if (!g_dbus_send_message_with_reply(btd_get_dbus_connection(), msg,
+					&mp->art_call, COVER_ART_TIMEOUT)) {
+		error("Failed to send GetCoverArt");
+		dbus_message_unref(msg);
+		return;
+	}
+
+	dbus_message_unref(msg);
+
+	mp->art_pending = g_strdup(url);
+
+	dbus_pending_call_set_notify(mp->art_call, cover_art_reply, mp, NULL);
+}
+
+static gboolean parse_art_url_metadata(struct local_player *mp,
+							DBusMessageIter *iter)
+{
+	const char *url;
+
+	if (dbus_message_iter_get_arg_type(iter) != DBUS_TYPE_STRING)
+		return FALSE;
+
+	dbus_message_iter_get_basic(iter, &url);
+
+	if (!avrcp_bip_server_active())
+		return TRUE;
+
+	/*
+	 * Players resend their full metadata on many state changes.
+	 * Reuse the registered handle if the cover has not changed to
+	 * avoid refetching the image and churning image handles, which
+	 * would make controllers re-fetch an unchanged image.
+	 */
+	if (mp->art_url != NULL && g_str_equal(mp->art_url, url) &&
+						mp->art_handle[0] != '\0') {
+		g_hash_table_insert(mp->track, g_strdup("ImgHandle"),
+						g_strdup(mp->art_handle));
+		return TRUE;
+	}
+
+	/* Likewise, do not restart a request that is already in flight */
+	if (mp->art_pending != NULL && g_str_equal(mp->art_pending, url))
+		return TRUE;
+
+	cover_art_request(mp, url);
+
+	return TRUE;
+}
+#endif
+
 static gboolean parse_player_metadata(struct local_player *mp,
 							DBusMessageIter *iter)
 {
@@ -2465,6 +2644,9 @@ static gboolean parse_player_metadata(struct local_player *mp,
 	DBusMessageIter var;
 	int ctype;
 	gboolean title = FALSE;
+#ifdef HAVE_AVRCP
+	gboolean art = FALSE;
+#endif
 
 	ctype = dbus_message_iter_get_arg_type(iter);
 	if (ctype != DBUS_TYPE_ARRAY)
@@ -2517,6 +2699,12 @@ static gboolean parse_player_metadata(struct local_player *mp,
 		} else if (strcasecmp(key, "xesam:trackNumber") == 0) {
 			if (!parse_int32_metadata(mp, "TrackNumber", &var))
 				return FALSE;
+		} else if (strcasecmp(key, "mpris:artUrl") == 0) {
+#ifdef HAVE_AVRCP
+			if (!parse_art_url_metadata(mp, &var))
+				return FALSE;
+			art = TRUE;
+#endif
 		} else
 			DBG("%s not supported, ignoring", key);
 
@@ -2527,6 +2715,12 @@ static gboolean parse_player_metadata(struct local_player *mp,
 		g_hash_table_insert(mp->track, g_strdup("Title"),
 								g_strdup(""));
 
+#ifdef HAVE_AVRCP
+	/* The new track has no cover art, drop whatever was cached */
+	if (art == FALSE)
+		cover_art_reset(mp);
+#endif
+
 	mp->position = 0;
 	g_timer_start(mp->timer);
 
diff --git a/src/bluetooth.conf b/src/bluetooth.conf
index b6c614908..f94198d65 100644
--- a/src/bluetooth.conf
+++ b/src/bluetooth.conf
@@ -14,6 +14,7 @@
     <allow send_interface="org.bluez.Agent1"/>
     <allow send_interface="org.bluez.MediaEndpoint1"/>
     <allow send_interface="org.bluez.MediaPlayer1"/>
+    <allow send_interface="org.bluez.MediaPlayerCoverArt1"/>
     <allow send_interface="org.bluez.Profile1"/>
     <allow send_interface="org.bluez.GattCharacteristic1"/>
     <allow send_interface="org.bluez.GattDescriptor1"/>
diff --git a/tools/mpris-proxy.c b/tools/mpris-proxy.c
index 1d7a421e9..3474c4b4a 100644
--- a/tools/mpris-proxy.c
+++ b/tools/mpris-proxy.c
@@ -33,6 +33,8 @@
 #define BLUEZ_DEVICE_INTERFACE "org.bluez.Device1"
 #define BLUEZ_MEDIA_INTERFACE "org.bluez.Media1"
 #define BLUEZ_MEDIA_PLAYER_INTERFACE "org.bluez.MediaPlayer1"
+#define BLUEZ_MEDIA_PLAYER_COVER_ART_INTERFACE \
+					"org.bluez.MediaPlayerCoverArt1"
 #define BLUEZ_MEDIA_FOLDER_INTERFACE "org.bluez.MediaFolder1"
 #define BLUEZ_MEDIA_ITEM_INTERFACE "org.bluez.MediaItem1"
 #define BLUEZ_MEDIA_TRANSPORT_INTERFACE "org.bluez.MediaTransport1"
@@ -385,6 +387,76 @@ done:
 	dbus_message_unref(reply);
 }
 
+#define COVER_ART_MAX_SIZE (1024 * 1024)
+
+/*
+ * bluetoothd is sandboxed and has no access to the user's home directory,
+ * so it cannot read the file mpris:artUrl points to. Read it here instead,
+ * where we already run with the permissions of the player, and hand the
+ * image over as plain bytes.
+ */
+static DBusHandlerResult cover_art_get(DBusConnection *conn, DBusMessage *msg)
+{
+	DBusMessage *reply;
+	DBusMessageIter iter, array;
+	const char *url;
+	char *filename, *contents = NULL;
+	gsize len = 0;
+	GError *gerr = NULL;
+
+	if (!dbus_message_get_args(msg, NULL, DBUS_TYPE_STRING, &url,
+							DBUS_TYPE_INVALID))
+		return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+
+	filename = g_filename_from_uri(url, NULL, NULL);
+	if (filename == NULL) {
+		reply = g_dbus_create_error(msg, ERROR_INTERFACE
+					".NotSupported", "Not a local file");
+		goto send;
+	}
+
+	if (!g_file_get_contents(filename, &contents, &len, &gerr)) {
+		reply = g_dbus_create_error(msg, ERROR_INTERFACE ".Failed",
+							"%s", gerr->message);
+		g_error_free(gerr);
+		g_free(filename);
+		goto send;
+	}
+
+	g_free(filename);
+
+	if (len == 0 || len > COVER_ART_MAX_SIZE) {
+		reply = g_dbus_create_error(msg, ERROR_INTERFACE ".Failed",
+							"Invalid image size");
+		g_free(contents);
+		goto send;
+	}
+
+	reply = dbus_message_new_method_return(msg);
+	if (reply == NULL) {
+		g_free(contents);
+		return DBUS_HANDLER_RESULT_NEED_MEMORY;
+	}
+
+	dbus_message_iter_init_append(reply, &iter);
+	dbus_message_iter_open_container(&iter, DBUS_TYPE_ARRAY,
+					DBUS_TYPE_BYTE_AS_STRING, &array);
+	dbus_message_iter_append_fixed_array(&array, DBUS_TYPE_BYTE,
+							&contents, len);
+	dbus_message_iter_close_container(&iter, &array);
+
+	g_free(contents);
+
+send:
+	if (reply == NULL)
+		return DBUS_HANDLER_RESULT_NEED_MEMORY;
+
+	dbus_connection_send(conn, reply, NULL);
+	dbus_message_unref(reply);
+
+	return DBUS_HANDLER_RESULT_HANDLED;
+}
+
 static DBusHandlerResult player_message(DBusConnection *conn,
 						DBusMessage *msg, void *data)
 {
@@ -393,6 +465,15 @@ static DBusHandlerResult player_message(DBusConnection *conn,
 	DBusMessageIter args, iter;
 	DBusPendingCall *call;
 
+	/*
+	 * Cover art is served by the proxy itself, the player behind it
+	 * knows nothing about this interface.
+	 */
+	if (dbus_message_is_method_call(msg,
+				BLUEZ_MEDIA_PLAYER_COVER_ART_INTERFACE,
+				"GetCoverArt"))
+		return cover_art_get(conn, msg);
+
 	dbus_message_iter_init(msg, &args);
 
 	copy = dbus_message_new_method_call(owner,
-- 
2.55.0


  reply	other threads:[~2026-08-31 15:01 UTC|newest]

Thread overview: 9+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-03 19:55 [PATCH BlueZ 0/1] Add cover art support Jan-Michael
2026-08-03 19:55 ` [PATCH BlueZ 1/1] " Jan-Michael
2026-08-03 20:56   ` Bastien Nocera
     [not found]     ` <6716CF9D-9856-4371-B55E-FFB8E6914B74@tabos.org>
2026-08-03 22:29       ` Bastien Nocera
2026-08-03 21:29   ` bluez.test.bot
2026-08-31 15:00 ` [PATCH v2 BlueZ 0/1] " Jan-Michael
2026-08-31 15:00   ` Jan-Michael [this message]
2026-08-31 20:59     ` [PATCH BlueZ] " Luiz Augusto von Dentz
2026-08-31 21:38     ` [BlueZ] " bluez.test.bot

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260831150051.80631-2-jan.brummer@tabos.org \
    --to=jan.brummer@tabos.org \
    --cc=linux-bluetooth@vger.kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox