From: Jan-Michael <jan.brummer@tabos.org>
To: linux-bluetooth@vger.kernel.org
Cc: Jan-Michael Brummer <jan.brummer@tabos.org>
Subject: [PATCH BlueZ 1/1] Add cover art support
Date: Mon, 3 Aug 2026 21:55:01 +0200 [thread overview]
Message-ID: <20260803195620.255866-2-jan.brummer@tabos.org> (raw)
In-Reply-To: <20260803195620.255866-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 +-
profiles/audio/avrcp-bip.c | 562 +++++++++++++++++++++++++++++++++++++
profiles/audio/avrcp-bip.h | 31 ++
profiles/audio/avrcp.c | 84 +++++-
profiles/audio/media.c | 77 +++++
src/bluetooth.service.in | 7 +-
6 files changed, 757 insertions(+), 8 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/profiles/audio/avrcp-bip.c b/profiles/audio/avrcp-bip.c
new file mode 100644
index 000000000..4079a6ea9
--- /dev/null
+++ b/profiles/audio/avrcp-bip.c
@@ -0,0 +1,562 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * AVRCP 1.6 Cover Art Responder
+ *
+ * Copyright (C) 2026 Jan-Michael Brummer <jan.brummer@tabos.org>
+ *
+ */
+
+#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 */
+};
+
+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);
+
+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);
+
+ 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;
+
+ 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)) {
+ 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");
+}
+
+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..ff970ac86
--- /dev/null
+++ b/profiles/audio/avrcp-bip.h
@@ -0,0 +1,31 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ *
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * AVRCP 1.6 Cover Art Responder
+ *
+ * Copyright (C) 2026 Jan-Michael Brummer <jan.brummer@tabos.org>
+ *
+ */
+
+#ifndef __AVRCP_BIP_H
+#define __AVRCP_BIP_H
+
+#include <stdint.h>
+#include <stddef.h>
+#include <stdbool.h>
+
+uint16_t avrcp_bip_server_start(void);
+
+void avrcp_bip_server_stop(void);
+
+bool avrcp_bip_server_active(void);
+
+uint16_t avrcp_bip_server_get_psm(void);
+
+const char *avrcp_bip_set_cover_art(const uint8_t *data, size_t len);
+
+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..8f2fd823e 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,10 @@ static uint8_t avrcp_handle_get_element_attributes(struct avrcp *session,
id > AVRCP_MEDIA_ATTRIBUTE_LAST)
continue;
+ 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 +4937,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 +4963,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..7bd1937d6 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,8 @@ 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 */
struct queue *cbs;
};
@@ -2069,6 +2074,7 @@ static void local_player_destroy(struct local_player *mp)
g_free(mp->path);
g_free(mp->status);
g_free(mp->name);
+ g_free(mp->art_url);
g_free(mp);
}
@@ -2458,6 +2464,72 @@ static gboolean parse_int32_metadata(struct local_player *mp, const char *key,
return TRUE;
}
+#ifdef HAVE_AVRCP
+#define COVER_ART_MAX_SIZE (1024 * 1024)
+
+static gboolean parse_art_url_metadata(struct local_player *mp,
+ DBusMessageIter *iter)
+{
+ const char *url, *handle;
+ char *filename, *contents = NULL;
+ gsize len = 0;
+ GError *gerr = NULL;
+
+ 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;
+
+ 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;
+ }
+
+ filename = g_filename_from_uri(url, NULL, NULL);
+ if (filename == NULL) {
+ DBG("cover art %s is not a local file, ignoring", url);
+ return TRUE;
+ }
+
+ if (!g_file_get_contents(filename, &contents, &len, &gerr)) {
+ DBG("cover art %s: %s", filename, gerr->message);
+ g_error_free(gerr);
+ g_free(filename);
+ return TRUE;
+ }
+
+ g_free(filename);
+
+ if (len == 0 || len > COVER_ART_MAX_SIZE) {
+ DBG("cover art has invalid size (%zu bytes), ignoring", len);
+ g_free(contents);
+ return TRUE;
+ }
+
+ handle = avrcp_bip_set_cover_art((const uint8_t *) contents, len);
+ g_free(contents);
+
+ /* Non-JPEG images are rejected by the responder */
+ if (handle == NULL)
+ return TRUE;
+
+ g_free(mp->art_url);
+ mp->art_url = g_strdup(url);
+ strncpy(mp->art_handle, handle, sizeof(mp->art_handle) - 1);
+ mp->art_handle[sizeof(mp->art_handle) - 1] = '\0';
+
+ g_hash_table_insert(mp->track, g_strdup("ImgHandle"),
+ g_strdup(handle));
+
+ return TRUE;
+}
+#endif
+
static gboolean parse_player_metadata(struct local_player *mp,
DBusMessageIter *iter)
{
@@ -2517,6 +2589,11 @@ 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;
+#endif
} else
DBG("%s not supported, ignoring", key);
diff --git a/src/bluetooth.service.in b/src/bluetooth.service.in
index 8dcbde236..79372f23e 100644
--- a/src/bluetooth.service.in
+++ b/src/bluetooth.service.in
@@ -10,11 +10,14 @@ ExecStart=@PKGLIBEXECDIR@/bluetoothd
NotifyAccess=main
#WatchdogSec=10
#Restart=on-failure
-CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN CAP_NET_BIND_SERVICE
+CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH
LimitNPROC=1
# Filesystem lockdown
-ProtectHome=true
+# Cover art referenced by MediaPlayer1 metadata (mpris:artUrl) usually
+# lives in the user's home directory (e.g. ~/.cache of the player), so
+# bluetoothd needs read access to serve it via the AVRCP BIP responder.
+ProtectHome=read-only
ProtectSystem=strict
PrivateTmp=true
ProtectKernelTunables=true
--
2.55.0
next prev parent reply other threads:[~2026-08-03 19:56 UTC|newest]
Thread overview: 5+ 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 ` Jan-Michael [this message]
2026-08-03 20:56 ` [PATCH BlueZ 1/1] " 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
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=20260803195620.255866-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;
as well as URLs for NNTP newsgroup(s).