* [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
` (2 more replies)
0 siblings, 3 replies; 13+ 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] 13+ 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 2026-09-07 19:34 ` [PATCH v3 BlueZ 0/1] " Jan-Michael 2 siblings, 2 replies; 13+ 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] 13+ 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; 13+ 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] 13+ 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; 13+ 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] 13+ 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; 13+ 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] 13+ 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 2026-09-07 19:34 ` [PATCH v3 BlueZ 0/1] " Jan-Michael 2 siblings, 1 reply; 13+ 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] 13+ 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; 13+ 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] 13+ 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; 13+ 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] 13+ 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; 13+ 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] 13+ messages in thread
* [PATCH v3 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 ` [PATCH v2 BlueZ 0/1] " Jan-Michael @ 2026-09-07 19:34 ` Jan-Michael 2026-09-07 19:34 ` [PATCH v3 BlueZ] " Jan-Michael 2 siblings, 1 reply; 13+ messages in thread From: Jan-Michael @ 2026-09-07 19:34 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 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. v3: Update based on review comments, e.g. moved to obex 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] 13+ messages in thread
* [PATCH v3 BlueZ] Add cover art support 2026-09-07 19:34 ` [PATCH v3 BlueZ 0/1] " Jan-Michael @ 2026-09-07 19:34 ` Jan-Michael 2026-09-07 22:07 ` [v3,BlueZ] " bluez.test.bot 2026-09-08 18:07 ` bluez.test.bot 0 siblings, 2 replies; 13+ messages in thread From: Jan-Michael @ 2026-09-07 19:34 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.obexd | 3 + lib/bluetooth/uuid.h | 2 + obexd/plugins/bip-avrcp.c | 566 ++++++++++++++++++++++++++++++++++++++ obexd/plugins/bluetooth.c | 13 + obexd/src/obex-priv.h | 1 + obexd/src/obex.c | 32 +++ obexd/src/obex.h | 1 + profiles/audio/avrcp.c | 150 +++++++++- profiles/audio/media.c | 35 +++ src/profile.c | 77 ++++++ src/profile.h | 7 + 11 files changed, 872 insertions(+), 15 deletions(-) create mode 100644 obexd/plugins/bip-avrcp.c diff --git a/Makefile.obexd b/Makefile.obexd index 7ad74e128..2421b6814 100644 --- a/Makefile.obexd +++ b/Makefile.obexd @@ -41,6 +41,9 @@ obexd_builtin_sources += obexd/plugins/opp.c obexd_builtin_modules += ftp obexd_builtin_sources += obexd/plugins/ftp.c obexd/plugins/ftp.h +obexd_builtin_modules += bip_avrcp +obexd_builtin_sources += obexd/plugins/bip-avrcp.c + obexd_builtin_modules += irmc obexd_builtin_sources += obexd/plugins/irmc.c diff --git a/lib/bluetooth/uuid.h b/lib/bluetooth/uuid.h index bd3fdd486..af5cdf027 100644 --- a/lib/bluetooth/uuid.h +++ b/lib/bluetooth/uuid.h @@ -102,6 +102,8 @@ extern "C" { #define OBEX_MAS_UUID "00001132-0000-1000-8000-00805f9b34fb" #define OBEX_MNS_UUID "00001133-0000-1000-8000-00805f9b34fb" #define OBEX_MAP_UUID "00001134-0000-1000-8000-00805f9b34fb" +/* AVRCP 1.6 Cover Art, Imaging Responder role */ +#define OBEX_BIP_AVRCP_UUID "0000111b-0000-1000-8000-00805f9b34fb" /* GATT UUIDs section */ #define GATT_PRIM_SVC_UUID 0x2800 diff --git a/obexd/plugins/bip-avrcp.c b/obexd/plugins/bip-avrcp.c new file mode 100644 index 000000000..df4efc860 --- /dev/null +++ b/obexd/plugins/bip-avrcp.c @@ -0,0 +1,566 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * + * OBEX Server - AVRCP Cover Art responder + * + * Copyright (C) 2026 Jan-Michael Brummer <jan.brummer@tabos.org> + * + * + */ + +#ifdef HAVE_CONFIG_H +#include <config.h> +#endif + +#include <errno.h> +#include <fcntl.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> +#include <sys/stat.h> + +#include <glib.h> +#include <dbus/dbus.h> + +#include "gdbus/gdbus.h" + +#include "obexd/src/obexd.h" +#include "obexd/src/plugin.h" +#include "obexd/src/obex.h" +#include "obexd/src/service.h" +#include "obexd/src/mimetype.h" +#include "obexd/src/log.h" +#include "obexd/src/manager.h" + +/* AVRCP 1.6 section 5.14.2.1 */ +#define COVER_ART_TARGET ((const uint8_t *) \ + "\x71\x63\xDD\x54\x4A\x7E\x11\xE2\xB4\x7C\x00\x50\xC2\x49\x00\x48") +#define COVER_ART_TARGET_SIZE 16 + +#define COVER_ART_MAX_SIZE (1024 * 1024) + +#define MPRIS_PREFIX "org.mpris.MediaPlayer2." +#define MPRIS_PLAYER_INTERFACE "org.mpris.MediaPlayer2.Player" + +struct cover_art_object { + int fd; + char *contents; /* Properties document, NULL for images */ + size_t size; + size_t offset; +}; + +/* + * The image handle is derived from the art URL, the same way bluetoothd + * derives the value it reports in AVRCP attribute 0x08. Keep in sync with + * profiles/audio/media.c. + */ +static void cover_art_handle(const char *url, char *handle, size_t len) +{ + snprintf(handle, len, "%07u", g_str_hash(url) % 10000000); +} + +static char *mpris_get_art_url(DBusConnection *conn, const char *name) +{ + DBusMessage *msg, *reply; + DBusMessageIter iter, variant, dict; + const char *iface = MPRIS_PLAYER_INTERFACE; + const char *prop = "Metadata"; + char *url = NULL; + + msg = dbus_message_new_method_call(name, "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties", + "Get"); + if (msg == NULL) + return NULL; + + dbus_message_append_args(msg, DBUS_TYPE_STRING, &iface, + DBUS_TYPE_STRING, &prop, + DBUS_TYPE_INVALID); + + reply = dbus_connection_send_with_reply_and_block(conn, msg, -1, NULL); + dbus_message_unref(msg); + + if (reply == NULL) + return NULL; + + if (!dbus_message_iter_init(reply, &iter) || + dbus_message_iter_get_arg_type(&iter) != + DBUS_TYPE_VARIANT) + goto done; + + dbus_message_iter_recurse(&iter, &variant); + + if (dbus_message_iter_get_arg_type(&variant) != DBUS_TYPE_ARRAY) + goto done; + + dbus_message_iter_recurse(&variant, &dict); + + while (dbus_message_iter_get_arg_type(&dict) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter entry, value; + const char *key, *str; + + dbus_message_iter_recurse(&dict, &entry); + + if (dbus_message_iter_get_arg_type(&entry) != DBUS_TYPE_STRING) + break; + + dbus_message_iter_get_basic(&entry, &key); + dbus_message_iter_next(&entry); + + if (dbus_message_iter_get_arg_type(&entry) != + DBUS_TYPE_VARIANT) + break; + + dbus_message_iter_recurse(&entry, &value); + + if (g_str_equal(key, "mpris:artUrl") && + dbus_message_iter_get_arg_type(&value) == + DBUS_TYPE_STRING) { + dbus_message_iter_get_basic(&value, &str); + url = g_strdup(str); + break; + } + + dbus_message_iter_next(&dict); + } + +done: + dbus_message_unref(reply); + + return url; +} + +/* + * Controllers fetch lazily and commonly ask for the previous track's + * handle after a track change, when the player no longer advertises that + * art URL. Remember the last few resolved handles so those requests can + * still be answered. + */ +#define COVER_ART_CACHE_SIZE 4 + +struct cover_art_entry { + char handle[8]; + char *filename; +}; + +static GQueue cover_art_cache = G_QUEUE_INIT; + +static void cover_art_cache_add(const char *handle, const char *filename) +{ + struct cover_art_entry *entry; + GList *l; + + for (l = cover_art_cache.head; l != NULL; l = g_list_next(l)) { + entry = l->data; + + if (!g_str_equal(entry->handle, handle)) + continue; + + g_queue_unlink(&cover_art_cache, l); + g_queue_push_head_link(&cover_art_cache, l); + return; + } + + if (g_queue_get_length(&cover_art_cache) >= COVER_ART_CACHE_SIZE) { + entry = g_queue_pop_tail(&cover_art_cache); + g_free(entry->filename); + g_free(entry); + } + + entry = g_new0(struct cover_art_entry, 1); + strncpy(entry->handle, handle, sizeof(entry->handle) - 1); + entry->filename = g_strdup(filename); + + g_queue_push_head(&cover_art_cache, entry); +} + +static char *cover_art_cache_lookup(const char *handle) +{ + GList *l; + + for (l = cover_art_cache.head; l != NULL; l = g_list_next(l)) { + struct cover_art_entry *entry = l->data; + + if (g_str_equal(entry->handle, handle)) + return g_strdup(entry->filename); + } + + return NULL; +} + +static void cover_art_cache_clear(void) +{ + struct cover_art_entry *entry; + + while ((entry = g_queue_pop_head(&cover_art_cache)) != NULL) { + g_free(entry->filename); + g_free(entry); + } +} + +/* + * Find the file a handle refers to by asking every MPRIS player on the bus + * for its current art URL and hashing it. Players are few and the lookup + * only happens when a controller actually fetches an image. + */ +static char *cover_art_lookup(const char *handle) +{ + DBusConnection *conn = obex_get_dbus_connection(); + DBusMessage *msg, *reply; + DBusMessageIter iter, array; + char *filename = NULL; + + if (handle == NULL) + return NULL; + + filename = cover_art_cache_lookup(handle); + if (filename != NULL) + return filename; + + if (conn == NULL) + return NULL; + + msg = dbus_message_new_method_call("org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", "ListNames"); + if (msg == NULL) + return NULL; + + reply = dbus_connection_send_with_reply_and_block(conn, msg, -1, NULL); + dbus_message_unref(msg); + + if (reply == NULL) + return NULL; + + if (!dbus_message_iter_init(reply, &iter) || + dbus_message_iter_get_arg_type(&iter) != + DBUS_TYPE_ARRAY) + goto done; + + dbus_message_iter_recurse(&iter, &array); + + while (dbus_message_iter_get_arg_type(&array) == DBUS_TYPE_STRING) { + const char *name; + char *url; + char h[8]; + + dbus_message_iter_get_basic(&array, &name); + dbus_message_iter_next(&array); + + if (!g_str_has_prefix(name, MPRIS_PREFIX)) + continue; + + url = mpris_get_art_url(conn, name); + if (url == NULL) + continue; + + cover_art_handle(url, h, sizeof(h)); + + if (g_str_equal(h, handle)) { + filename = g_filename_from_uri(url, NULL, NULL); + + if (filename != NULL) + cover_art_cache_add(handle, filename); + } + + g_free(url); + + if (filename != NULL) + break; + } + +done: + dbus_message_unref(reply); + + return filename; +} + +static void *cover_art_open_image(const char *name, int oflag, mode_t mode, + void *context, size_t *size, int *err) +{ + struct cover_art_object *obj; + struct stat st; + char *filename; + int fd; + + if (oflag != O_RDONLY) { + if (err) + *err = -EPERM; + return NULL; + } + + filename = cover_art_lookup(name); + if (filename == NULL) { + DBG("no image for handle %s", name ? name : "(none)"); + if (err) + *err = -ENOENT; + return NULL; + } + + fd = open(filename, O_RDONLY | O_CLOEXEC); + g_free(filename); + + if (fd < 0) { + if (err) + *err = -errno; + return NULL; + } + + if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode) || + st.st_size > COVER_ART_MAX_SIZE) { + close(fd); + if (err) + *err = -EINVAL; + return NULL; + } + + obj = g_new0(struct cover_art_object, 1); + obj->fd = fd; + obj->size = st.st_size; + + if (size) + *size = obj->size; + + if (err) + *err = 0; + + return obj; +} + +/* + * Parse the JPEG start-of-frame marker for the image dimensions, which the + * image-properties document has to carry. + */ +static gboolean jpeg_dimensions(int fd, unsigned int *width, + unsigned int *height) +{ + uint8_t buf[4]; + off_t pos = 2; + + if (lseek(fd, 0, SEEK_SET) < 0) + return FALSE; + + if (read(fd, buf, 2) != 2 || buf[0] != 0xff || buf[1] != 0xd8) + return FALSE; + + while (lseek(fd, pos, SEEK_SET) >= 0 && read(fd, buf, 4) == 4) { + uint16_t len = (buf[2] << 8) | buf[3]; + + if (buf[0] != 0xff) + return FALSE; + + /* SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15 */ + if (buf[1] >= 0xc0 && buf[1] <= 0xcf && + buf[1] != 0xc4 && buf[1] != 0xc8 && + buf[1] != 0xcc) { + uint8_t sof[5]; + + if (read(fd, sof, 5) != 5) + return FALSE; + + *height = (sof[1] << 8) | sof[2]; + *width = (sof[3] << 8) | sof[4]; + + return TRUE; + } + + if (len < 2) + return FALSE; + + pos += 2 + len; + } + + return FALSE; +} + +static void *cover_art_open_properties(const char *name, int oflag, + mode_t mode, void *context, + size_t *size, int *err) +{ + struct cover_art_object *obj; + unsigned int width = 0, height = 0; + GString *props; + + obj = cover_art_open_image(name, oflag, mode, context, NULL, err); + if (obj == NULL) + return NULL; + + if (!jpeg_dimensions(obj->fd, &width, &height)) { + close(obj->fd); + g_free(obj); + if (err) + *err = -EINVAL; + return NULL; + } + + props = g_string_new("<image-properties version=\"1.0\" handle=\""); + g_string_append_printf(props, "%s\">\n", name); + g_string_append_printf(props, + "<native encoding=\"JPEG\" pixel=\"%ux%u\" size=\"%zu\"/>\n", + width, height, obj->size); + g_string_append(props, "</image-properties>\n"); + + close(obj->fd); + obj->fd = -1; + obj->size = props->len; + obj->contents = g_string_free(props, FALSE); + + if (size) + *size = obj->size; + + return obj; +} + +static ssize_t cover_art_read(void *object, void *buf, size_t count) +{ + struct cover_art_object *obj = object; + ssize_t len; + + if (obj->contents != NULL) { + len = MIN(count, obj->size - obj->offset); + memcpy(buf, obj->contents + obj->offset, len); + obj->offset += len; + + return len; + } + + len = read(obj->fd, buf, count); + if (len < 0) + return -errno; + + return len; +} + +static int cover_art_close(void *object) +{ + struct cover_art_object *obj = object; + + if (obj->fd >= 0) + close(obj->fd); + + g_free(obj->contents); + g_free(obj); + + return 0; +} + +static void *cover_art_connect(struct obex_session *os, int *err) +{ + DBG(""); + + manager_register_session(os); + + if (err) + *err = 0; + + return NULL; +} + +static void cover_art_disconnect(struct obex_session *os, void *user_data) +{ + DBG(""); + + manager_unregister_session(os); +} + +static int cover_art_get(struct obex_session *os, void *user_data) +{ + const char *type = obex_get_type(os); + const char *handle = obex_get_img_handle(os); + + DBG("type %s handle %s", type ? type : "(none)", + handle ? handle : "(none)"); + + if (type == NULL || handle == NULL) + return -EBADR; + + return obex_get_stream_start(os, handle); +} + +static const struct obex_service_driver cover_art = { + .name = "AVRCP Cover Art server", + .service = OBEX_BIP, + .target = COVER_ART_TARGET, + .target_size = COVER_ART_TARGET_SIZE, + .connect = cover_art_connect, + .get = cover_art_get, + .disconnect = cover_art_disconnect, +}; + +static const struct obex_mime_type_driver properties = { + .target = COVER_ART_TARGET, + .target_size = COVER_ART_TARGET_SIZE, + .mimetype = "x-bt/img-properties", + .open = cover_art_open_properties, + .close = cover_art_close, + .read = cover_art_read, +}; + +/* + * The linked thumbnail is served in the image's native encoding rather + * than the 200x200 JPEG the specification describes: scaling would mean + * decoding images inside obexd. Controllers seen so far accept it. + */ +static const struct obex_mime_type_driver thumbnail = { + .target = COVER_ART_TARGET, + .target_size = COVER_ART_TARGET_SIZE, + .mimetype = "x-bt/img-thm", + .open = cover_art_open_image, + .close = cover_art_close, + .read = cover_art_read, +}; + +static const struct obex_mime_type_driver image = { + .target = COVER_ART_TARGET, + .target_size = COVER_ART_TARGET_SIZE, + .mimetype = "x-bt/img-img", + .open = cover_art_open_image, + .close = cover_art_close, + .read = cover_art_read, +}; + +static int bip_avrcp_init(void) +{ + int err; + + err = obex_mime_type_driver_register(&properties); + if (err < 0) + return err; + + err = obex_mime_type_driver_register(&thumbnail); + if (err < 0) + goto failed_thumbnail; + + err = obex_mime_type_driver_register(&image); + if (err < 0) + goto failed_image; + + err = obex_service_driver_register(&cover_art); + if (err < 0) + goto failed_service; + + return 0; + +failed_service: + obex_mime_type_driver_unregister(&image); +failed_image: + obex_mime_type_driver_unregister(&thumbnail); +failed_thumbnail: + obex_mime_type_driver_unregister(&properties); + + return err; +} + +static void bip_avrcp_exit(void) +{ + cover_art_cache_clear(); + + obex_service_driver_unregister(&cover_art); + obex_mime_type_driver_unregister(&image); + obex_mime_type_driver_unregister(&thumbnail); + obex_mime_type_driver_unregister(&properties); +} + +OBEX_PLUGIN_DEFINE(bip_avrcp, bip_avrcp_init, bip_avrcp_exit) diff --git a/obexd/plugins/bluetooth.c b/obexd/plugins/bluetooth.c index 3af1b504d..962c88c8f 100644 --- a/obexd/plugins/bluetooth.c +++ b/obexd/plugins/bluetooth.c @@ -259,6 +259,17 @@ static int register_profile(struct bluetooth_profile *profile) &opt); g_dbus_dict_append_entry(&opt, "AutoConnect", DBUS_TYPE_BOOLEAN, &auto_connect); + if (profile->driver->service == OBEX_BIP) { + dbus_uint16_t psm = 0; + + /* + * Ask for a dynamically assigned L2CAP PSM: Cover Art is + * advertised inside the AVRCP target record, which + * bluetoothd builds once it knows which PSM was handed + * out, so no separate service record is published here. + */ + g_dbus_dict_append_entry(&opt, "PSM", DBUS_TYPE_UINT16, &psm); + } if (profile->driver->record) { if (profile->driver->port != 0) xml = g_markup_printf_escaped(profile->driver->record, @@ -309,6 +320,8 @@ static const char *service2uuid(uint16_t service) return OBEX_MAS_UUID; case OBEX_MNS: return OBEX_MNS_UUID; + case OBEX_BIP: + return OBEX_BIP_AVRCP_UUID; } return NULL; diff --git a/obexd/src/obex-priv.h b/obexd/src/obex-priv.h index d2c62a596..f43a235aa 100644 --- a/obexd/src/obex-priv.h +++ b/obexd/src/obex-priv.h @@ -17,6 +17,7 @@ struct obex_session { char *src; char *dst; char *name; + char *img_handle; char *destname; char *type; char *path; diff --git a/obexd/src/obex.c b/obexd/src/obex.c index 370bfac9e..5573f33ad 100644 --- a/obexd/src/obex.c +++ b/obexd/src/obex.c @@ -582,6 +582,31 @@ static void parse_name(struct obex_session *os, GObexPacket *req) DBG("NAME: %s", os->name); } +/* + * BIP carries the image handle in an application specific header rather + * than in Name; see the Basic Imaging Profile, Img-Handle. + */ +#define OBEX_HDR_IMG_HANDLE 0x30 + +static void parse_img_handle(struct obex_session *os, GObexPacket *req) +{ + GObexHeader *hdr; + const char *handle; + + g_free(os->img_handle); + os->img_handle = NULL; + + hdr = g_obex_packet_get_header(req, OBEX_HDR_IMG_HANDLE); + if (hdr == NULL) + return; + + if (!g_obex_header_get_unicode(hdr, &handle)) + return; + + os->img_handle = g_strdup(handle); + DBG("IMG-HANDLE: %s", os->img_handle); +} + static void parse_apparam(struct obex_session *os, GObexPacket *req) { GObexHeader *hdr; @@ -638,6 +663,8 @@ static void cmd_get(GObex *obex, GObexPacket *req, gpointer user_data) parse_name(os, req); + parse_img_handle(os, req); + parse_apparam(os, req); err = os->service->get(os, os->service_data); @@ -1008,6 +1035,11 @@ const char *obex_get_name(struct obex_session *os) return os->name; } +const char *obex_get_img_handle(struct obex_session *os) +{ + return os->img_handle; +} + const char *obex_get_destname(struct obex_session *os) { return os->destname; diff --git a/obexd/src/obex.h b/obexd/src/obex.h index 755be1caf..20389284e 100644 --- a/obexd/src/obex.h +++ b/obexd/src/obex.h @@ -21,6 +21,7 @@ struct obex_session; int obex_get_stream_start(struct obex_session *os, const char *filename); int obex_put_stream_start(struct obex_session *os, const char *filename); const char *obex_get_name(struct obex_session *os); +const char *obex_get_img_handle(struct obex_session *os); const char *obex_get_destname(struct obex_session *os); void obex_set_name(struct obex_session *os, const char *name); ssize_t obex_get_size(struct obex_session *os); diff --git a/profiles/audio/avrcp.c b/profiles/audio/avrcp.c index 2194a9135..0cc680e51 100644 --- a/profiles/audio/avrcp.c +++ b/profiles/audio/avrcp.c @@ -217,6 +217,8 @@ struct avrcp_server { struct btd_adapter *adapter; bool browsing; uint32_t tg_record_id; + uint16_t cover_psm; + unsigned int cover_watch; uint32_t ct_record_id; GSList *players; GSList *sessions; @@ -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)); @@ -4867,6 +4940,11 @@ static void avrcp_target_server_remove(struct btd_profile *p, if (!server) return; + if (server->cover_watch != 0) { + btd_profile_remove_psm_watch(server->cover_watch); + server->cover_watch = 0; + } + if (server->tg_record_id != 0) { adapter_service_remove(adapter, server->tg_record_id); server->tg_record_id = 0; @@ -4876,10 +4954,56 @@ static void avrcp_target_server_remove(struct btd_profile *p, avrcp_server_unregister(server); } +/* + * The Cover Art PSM belongs to the responder in obexd, which registers its + * profile whenever the session daemon happens to start. Rebuild the record + * when that changes so the advertised PSM always matches a listening + * socket, and so no Cover Art is advertised while none is there. + */ +static int avrcp_tg_record_update(struct avrcp_server *server) +{ + sdp_record_t *record; + + if (server->tg_record_id != 0) { + adapter_service_remove(server->adapter, server->tg_record_id); + server->tg_record_id = 0; + } + + record = avrcp_tg_record(server->browsing, server->cover_psm); + if (!record) { + error("Unable to allocate new service record"); + return -1; + } + + if (adapter_service_add(server->adapter, record) < 0) { + error("Unable to register AVRCP target service record"); + sdp_record_free(record); + return -1; + } + + server->tg_record_id = record->handle; + + return 0; +} + +static void avrcp_cover_art_psm_cb(struct btd_adapter *adapter, uint16_t psm, + void *user_data) +{ + struct avrcp_server *server = user_data; + + if (server->adapter != adapter || server->cover_psm == psm) + return; + + DBG("Cover Art PSM %u", psm); + + server->cover_psm = psm; + + avrcp_tg_record_update(server); +} + static int avrcp_target_server_probe(struct btd_profile *p, struct btd_adapter *adapter) { - sdp_record_t *record; struct avrcp_server *server; DBG("path %s", adapter_get_path(adapter)); @@ -4893,20 +5017,16 @@ static int avrcp_target_server_probe(struct btd_profile *p, return -EPROTONOSUPPORT; done: - record = avrcp_tg_record(server->browsing); - if (!record) { - error("Unable to allocate new service record"); - avrcp_target_server_remove(p, adapter); - return -1; - } + if (server->cover_watch == 0) + server->cover_watch = btd_profile_add_psm_watch( + OBEX_BIP_AVRCP_UUID, + avrcp_cover_art_psm_cb, + server); - if (adapter_service_add(adapter, record) < 0) { - error("Unable to register AVRCP target service record"); + if (avrcp_tg_record_update(server) < 0) { avrcp_target_server_remove(p, adapter); - sdp_record_free(record); return -1; } - server->tg_record_id = record->handle; return 0; } diff --git a/profiles/audio/media.c b/profiles/audio/media.c index 5d9ea2cbc..4917372a8 100644 --- a/profiles/audio/media.c +++ b/profiles/audio/media.c @@ -2458,6 +2458,38 @@ static gboolean parse_int32_metadata(struct local_player *mp, const char *key, return TRUE; } +/* + * AVRCP 1.6 section 5.14: the Default Cover Art attribute carries a BIP + * image handle, seven US-ASCII digits identifying the image. + * + * The handle is derived from the art URL rather than allocated, so that + * the Cover Art responder in obexd arrives at the same value for the same + * URL without any coordination between the two daemons. It also makes the + * handle stable across reconnects and daemon restarts, which spares + * controllers a re-fetch of an image they already hold. + * + * Keep in sync with obexd/plugins/bip-avrcp.c. + */ +static gboolean parse_art_url_metadata(struct local_player *mp, + DBusMessageIter *iter) +{ + const char *url; + char handle[8]; + + if (dbus_message_iter_get_arg_type(iter) != DBUS_TYPE_STRING) + return FALSE; + + dbus_message_iter_get_basic(iter, &url); + + snprintf(handle, sizeof(handle), "%07u", + g_str_hash(url) % 10000000); + + g_hash_table_insert(mp->track, g_strdup("ImgHandle"), + g_strdup(handle)); + + return TRUE; +} + static gboolean parse_player_metadata(struct local_player *mp, DBusMessageIter *iter) { @@ -2517,6 +2549,9 @@ 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) { + if (!parse_art_url_metadata(mp, &var)) + return FALSE; } else DBG("%s not supported, ignoring", key); diff --git a/src/profile.c b/src/profile.c index 97fffe9b4..643a2e34e 100644 --- a/src/profile.c +++ b/src/profile.c @@ -59,6 +59,9 @@ #define BT_RX_MTU 32767 #define BTD_PROFILE_PSM_AUTO -1 + +static void psm_watch_notify(const char *uuid, struct btd_adapter *adapter, + uint16_t psm); #define BTD_PROFILE_CHAN_AUTO -1 #define HFP_HF_RECORD \ @@ -1444,6 +1447,8 @@ static uint32_t ext_start_servers(struct ext_profile *ext, l2cap->adapter = btd_adapter_ref(adapter); ext->servers = g_slist_append(ext->servers, l2cap); DBG("%s listening on PSM %u", ext->name, psm); + + psm_watch_notify(ext->uuid, adapter, psm); } } @@ -1494,6 +1499,66 @@ failed: return 0; } +struct psm_watch { + unsigned int id; + char *uuid; + btd_profile_psm_func func; + void *user_data; +}; + +static GSList *psm_watches = NULL; +static unsigned int psm_watch_id = 0; + +unsigned int btd_profile_add_psm_watch(const char *uuid, + btd_profile_psm_func func, + void *user_data) +{ + struct psm_watch *watch; + + if (uuid == NULL || func == NULL) + return 0; + + watch = g_new0(struct psm_watch, 1); + watch->id = ++psm_watch_id; + watch->uuid = g_strdup(uuid); + watch->func = func; + watch->user_data = user_data; + + psm_watches = g_slist_append(psm_watches, watch); + + return watch->id; +} + +static void psm_watch_notify(const char *uuid, struct btd_adapter *adapter, + uint16_t psm) +{ + GSList *l; + + for (l = psm_watches; l != NULL; l = g_slist_next(l)) { + struct psm_watch *watch = l->data; + + if (strcasecmp(watch->uuid, uuid) == 0) + watch->func(adapter, psm, watch->user_data); + } +} + +void btd_profile_remove_psm_watch(unsigned int id) +{ + GSList *l; + + for (l = psm_watches; l != NULL; l = g_slist_next(l)) { + struct psm_watch *watch = l->data; + + if (watch->id != id) + continue; + + psm_watches = g_slist_remove(psm_watches, watch); + g_free(watch->uuid); + g_free(watch); + return; + } +} + static struct ext_profile *find_ext(struct btd_profile *p) { GSList *l; @@ -1574,6 +1639,9 @@ static void ext_adapter_remove(struct btd_profile *p, if (server->adapter != adapter) continue; + if (server->proto == BTPROTO_L2CAP) + psm_watch_notify(ext->uuid, adapter, 0); + ext->servers = g_slist_remove(ext->servers, server); ext_io_destroy(server); } @@ -2213,6 +2281,15 @@ static struct default_settings { .get_record = get_mns_record, .version = 0x0104, .imtu = BT_RX_MTU, + }, { + .uuid = OBEX_BIP_AVRCP_UUID, + .name = "AVRCP Cover Art", + .psm = BTD_PROFILE_PSM_AUTO, + .mode = BT_IO_MODE_ERTM, + .sec_level = BT_IO_SEC_LOW, + .authorize = false, + .version = 0x0100, + .imtu = BT_RX_MTU, }, }; diff --git a/src/profile.h b/src/profile.h index 04a99528b..26be997e7 100644 --- a/src/profile.h +++ b/src/profile.h @@ -112,3 +112,10 @@ typedef const struct btd_profile *(*btd_profile_list_get)(void *item, void *user_data); GSList *btd_profile_sort_list(GSList *list, btd_profile_list_get get, void *user_data); + +typedef void (*btd_profile_psm_func)(struct btd_adapter *adapter, + uint16_t psm, void *user_data); +unsigned int btd_profile_add_psm_watch(const char *uuid, + btd_profile_psm_func func, + void *user_data); +void btd_profile_remove_psm_watch(unsigned int id); -- 2.55.0 ^ permalink raw reply related [flat|nested] 13+ messages in thread
* RE: [v3,BlueZ] Add cover art support 2026-09-07 19:34 ` [PATCH v3 BlueZ] " Jan-Michael @ 2026-09-07 22:07 ` bluez.test.bot 2026-09-08 18:07 ` bluez.test.bot 1 sibling, 0 replies; 13+ messages in thread From: bluez.test.bot @ 2026-09-07 22:07 UTC (permalink / raw) To: linux-bluetooth, jan.brummer [-- Attachment #1: Type: text/plain, Size: 382 bytes --] This is an automated email and please do not reply to this email. Dear Submitter, Thank you for submitting the patches to the linux bluetooth mailing list. While preparing the CI tests, the patches you submitted couldn't be applied to the current HEAD of the repository. ----- Output ----- Please resolve the issue and submit the patches again. --- Regards, Linux Bluetooth ^ permalink raw reply [flat|nested] 13+ messages in thread
* RE: [v3,BlueZ] Add cover art support 2026-09-07 19:34 ` [PATCH v3 BlueZ] " Jan-Michael 2026-09-07 22:07 ` [v3,BlueZ] " bluez.test.bot @ 2026-09-08 18:07 ` bluez.test.bot 1 sibling, 0 replies; 13+ messages in thread From: bluez.test.bot @ 2026-09-08 18:07 UTC (permalink / raw) To: linux-bluetooth, jan.brummer [-- Attachment #1: Type: text/plain, Size: 1969 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/series/1159982/ ---Test result--- Test Summary: CheckPatch FAIL 1.09 seconds GitLint PASS 0.29 seconds BuildEll PASS 20.33 seconds BluezMake PASS 566.36 seconds MakeCheck PASS 19.77 seconds MakeDistcheck PASS 155.21 seconds CheckValgrind PASS 224.36 seconds CheckSmatch PASS 300.56 seconds bluezmakeextell PASS 98.88 seconds IncrementalBuild PASS 573.47 seconds ScanBuild PASS 921.94 seconds Details ############################## Test: CheckPatch - FAIL Desc: Run checkpatch.pl script Output: [v3,BlueZ] Add cover art support ERROR:INITIALISED_STATIC: do not initialise statics to NULL #1107: FILE: src/profile.c:1509: +static GSList *psm_watches = NULL; ERROR:INITIALISED_STATIC: do not initialise statics to 0 #1108: FILE: src/profile.c:1510: +static unsigned int psm_watch_id = 0; /github/workspace/src/patch/14797210.patch total: 2 errors, 0 warnings, 1042 lines checked NOTE: For some of the reported defects, checkpatch may be able to mechanically convert to the typical style using --fix or --fix-inplace. /github/workspace/src/patch/14797210.patch has style problems, please review. NOTE: Ignored message types: COMMIT_MESSAGE COMPLEX_MACRO CONST_STRUCT FILE_PATH_CHANGES MISSING_SIGN_OFF PREFER_PACKED SPDX_LICENSE_TAG SPLIT_STRING SSCANF_TO_KSTRTO NOTE: If any of the errors are false positives, please report them to the maintainer, see CHECKPATCH in MAINTAINERS. https://github.com/bluez/bluez/pull/2500 --- Regards, Linux Bluetooth ^ permalink raw reply [flat|nested] 13+ messages in thread
end of thread, other threads:[~2026-09-08 18:07 UTC | newest]
Thread overview: 13+ 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
2026-09-07 19:34 ` [PATCH v3 BlueZ 0/1] " Jan-Michael
2026-09-07 19:34 ` [PATCH v3 BlueZ] " Jan-Michael
2026-09-07 22:07 ` [v3,BlueZ] " bluez.test.bot
2026-09-08 18:07 ` 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