* [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 2026-08-31 15:00 ` [PATCH v2 BlueZ 0/1] " Jan-Michael 0 siblings, 2 replies; 9+ 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] 9+ 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 2026-08-31 15:00 ` [PATCH v2 BlueZ 0/1] " Jan-Michael 1 sibling, 2 replies; 9+ 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] 9+ 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; 9+ 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] 9+ messages in thread
[parent not found: <6716CF9D-9856-4371-B55E-FFB8E6914B74@tabos.org>]
* 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; 9+ 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] 9+ 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; 9+ 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] 9+ messages in thread
* [PATCH v2 BlueZ 0/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 ` [PATCH BlueZ 1/1] " Jan-Michael @ 2026-08-31 15:00 ` Jan-Michael 2026-08-31 15:00 ` [PATCH BlueZ] " Jan-Michael 1 sibling, 1 reply; 9+ messages in thread From: Jan-Michael @ 2026-08-31 15:00 UTC (permalink / raw) To: linux-bluetooth; +Cc: jan.brummer 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) v2: Dropped the patch that relaxed the sandbox of the systemd unit. bluetoothd no longer touches the filesystem at all: instead of reading the file mpris:artUrl points to, it asks the player for the image data over D-Bus, and mpris-proxy answers that call for the players it registers (patch 5). ProtectHome= and the capability bounding set stay exactly as they are. Tested on a Fairphone 5 and OnePlus 6T with a VW head unit. Jan-Michael Brummer (1): Add cover art support 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 -- 2.55.0 ^ permalink raw reply [flat|nested] 9+ messages in thread
* [PATCH BlueZ] Add cover art support 2026-08-31 15:00 ` [PATCH v2 BlueZ 0/1] " Jan-Michael @ 2026-08-31 15:00 ` Jan-Michael 2026-08-31 20:59 ` Luiz Augusto von Dentz 2026-08-31 21:38 ` [BlueZ] " bluez.test.bot 0 siblings, 2 replies; 9+ messages in thread From: Jan-Michael @ 2026-08-31 15:00 UTC (permalink / raw) To: linux-bluetooth; +Cc: jan.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 +- 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 ^ permalink raw reply related [flat|nested] 9+ messages in thread
* Re: [PATCH BlueZ] Add cover art support 2026-08-31 15:00 ` [PATCH BlueZ] " Jan-Michael @ 2026-08-31 20:59 ` Luiz Augusto von Dentz 2026-08-31 21:38 ` [BlueZ] " bluez.test.bot 1 sibling, 0 replies; 9+ messages in thread From: Luiz Augusto von Dentz @ 2026-08-31 20:59 UTC (permalink / raw) To: Jan-Michael; +Cc: linux-bluetooth Hi Jan-Michael, On Mon, Aug 31, 2026 at 4:40 PM Jan-Michael <jan.brummer@tabos.org> wrote: > > 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. It would be much better to expose a file descriptor that bluetoothd can read directly instead of transferring the whole content over D-Bus. > +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" Bas idea to link gobex to bluetoothd, that should really be obexd to handle the bip session and transfer, otherwise we have to duplicate the whole obex session management again. > +#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 > +}; I assume it doesn't need to be a fixed psm, otherwise the spec would reseve one, beside if you move this over to obexd then all the logic of OBEX profiles is reused. > +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 > > -- Luiz Augusto von Dentz ^ permalink raw reply [flat|nested] 9+ messages in thread
* RE: [BlueZ] Add cover art support 2026-08-31 15:00 ` [PATCH BlueZ] " Jan-Michael 2026-08-31 20:59 ` Luiz Augusto von Dentz @ 2026-08-31 21:38 ` bluez.test.bot 1 sibling, 0 replies; 9+ messages in thread From: bluez.test.bot @ 2026-08-31 21:38 UTC (permalink / raw) To: linux-bluetooth, jan.brummer [-- Attachment #1: Type: text/plain, Size: 989 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=1154605 ---Test result--- Test Summary: CheckPatch PASS 1.33 seconds GitLint PASS 0.34 seconds BuildEll PASS 20.36 seconds BluezMake PASS 602.63 seconds MakeCheck PASS 18.92 seconds MakeDistcheck PASS 160.40 seconds CheckValgrind PASS 232.78 seconds CheckSmatch PASS 315.83 seconds bluezmakeextell PASS 103.48 seconds IncrementalBuild PASS 610.47 seconds ScanBuild PASS 993.28 seconds https://github.com/bluez/bluez/pull/2464 --- Regards, Linux Bluetooth ^ permalink raw reply [flat|nested] 9+ messages in thread
end of thread, other threads:[~2026-08-31 21:38 UTC | newest]
Thread overview: 9+ 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
2026-08-31 15:00 ` [PATCH v2 BlueZ 0/1] " Jan-Michael
2026-08-31 15:00 ` [PATCH BlueZ] " Jan-Michael
2026-08-31 20:59 ` Luiz Augusto von Dentz
2026-08-31 21:38 ` [BlueZ] " 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