* [PATCH BlueZ 0/1] Add cover art support
@ 2026-08-03 19:55 Jan-Michael
2026-08-03 19:55 ` [PATCH BlueZ 1/1] " Jan-Michael
0 siblings, 1 reply; 5+ messages in thread
From: Jan-Michael @ 2026-08-03 19:55 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Jan-Michael
BlueZ wasn't able to display cover art in car's headunits due to missing
cover art support. Add missing pieces to allow sending cover arts:
- Add AVRCP BIP Cover Art Responder
- Advertise Cover Art Support
- Update bluetooth service to have at least read only access to home directories (where cover images are located)
Tested on a Fairphone 5 and OnePlus 6T with a VW head unit.
Jan-Michael Brummer (1):
Add cover art support
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
--
2.55.0
^ permalink raw reply [flat|nested] 5+ messages in thread
* [PATCH BlueZ 1/1] Add cover art support
2026-08-03 19:55 [PATCH BlueZ 0/1] Add cover art support Jan-Michael
@ 2026-08-03 19:55 ` Jan-Michael
2026-08-03 20:56 ` Bastien Nocera
2026-08-03 21:29 ` bluez.test.bot
0 siblings, 2 replies; 5+ messages in thread
From: Jan-Michael @ 2026-08-03 19:55 UTC (permalink / raw)
To: linux-bluetooth; +Cc: Jan-Michael Brummer
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
^ permalink raw reply related [flat|nested] 5+ messages in thread
* Re: [PATCH BlueZ 1/1] Add cover art support
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 21:29 ` bluez.test.bot
1 sibling, 1 reply; 5+ messages in thread
From: Bastien Nocera @ 2026-08-03 20:56 UTC (permalink / raw)
To: Jan-Michael, linux-bluetooth
Hey,
On Mon, 2026-08-03 at 21:55 +0200, Jan-Michael wrote:
> # 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
This is going to be a show-stopper.
Having a root-running daemon rummaging around in user's files is not
something we want *at all*.
Cheers
^ permalink raw reply [flat|nested] 5+ messages in thread
* RE: Add cover art support
2026-08-03 19:55 ` [PATCH BlueZ 1/1] " Jan-Michael
2026-08-03 20:56 ` Bastien Nocera
@ 2026-08-03 21:29 ` bluez.test.bot
1 sibling, 0 replies; 5+ messages in thread
From: bluez.test.bot @ 2026-08-03 21:29 UTC (permalink / raw)
To: linux-bluetooth, jan.brummer
[-- Attachment #1: Type: text/plain, Size: 990 bytes --]
This is automated email and please do not reply to this email!
Dear submitter,
Thank you for submitting the patches to the linux bluetooth mailing list.
This is a CI test results with your patch series:
PW Link:https://patchwork.kernel.org/project/bluetooth/list/?series=1139642
---Test result---
Test Summary:
CheckPatch PASS 0.92 seconds
GitLint PASS 0.21 seconds
BuildEll PASS 20.82 seconds
BluezMake PASS 559.76 seconds
MakeCheck PASS 18.59 seconds
MakeDistcheck PASS 162.14 seconds
CheckValgrind PASS 233.65 seconds
CheckSmatch PASS 318.88 seconds
bluezmakeextell PASS 104.34 seconds
IncrementalBuild PASS 553.71 seconds
ScanBuild PASS 1009.10 seconds
https://github.com/bluez/bluez/pull/2370
---
Regards,
Linux Bluetooth
^ permalink raw reply [flat|nested] 5+ messages in thread
* Re: [PATCH BlueZ 1/1] Add cover art support
[not found] ` <6716CF9D-9856-4371-B55E-FFB8E6914B74@tabos.org>
@ 2026-08-03 22:29 ` Bastien Nocera
0 siblings, 0 replies; 5+ messages in thread
From: Bastien Nocera @ 2026-08-03 22:29 UTC (permalink / raw)
To: Jan-Michael Brummer, linux-bluetooth
On Mon, 2026-08-03 at 23:03 +0200, Jan-Michael Brummer wrote:
> Maybe you have an alternative solution in mind? I'm open to change
> it.
I think you might need to extend MPRIS to allow making image data
available instead of a URL.
This could also help with avoiding, say, gnome-shell having to make
https calls to fetch a remote cover, or third-party playback control
apps having to cross apps boundaries to get to another sandbox app's
cover art.
Maybe this new feature is what we want?
https://gitlab.freedesktop.org/mpris/mpris-spec/-/merge_requests/4
I looked through the MPRIS repo after writing the above.
Cheers
> Am 3. August 2026 22:56:39 MESZ schrieb Bastien Nocera
> <hadess@hadess.net>:
> > Hey,
> >
> > On Mon, 2026-08-03 at 21:55 +0200, Jan-Michael wrote:
> > > # 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
> > >
> >
> > This is going to be a show-stopper.
> >
> > Having a root-running daemon rummaging around in user's files is
> > not
> > something we want *at all*.
> >
> > Cheers
^ permalink raw reply [flat|nested] 5+ messages in thread
end of thread, other threads:[~2026-08-03 22:29 UTC | newest]
Thread overview: 5+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox