[PATCH BlueZ 1/1] Add cover art support
Jan-Michael <[email protected]> Mon, 3 Aug 2026 21:55:01 +0200
| Newsgroups | org.kernel.vger.linux-bluetooth |
|---|---|
| Message-ID | <[email protected]> |
From: Jan-Michael Brummer <[email protected]> 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 <[email protected]> + * + */ + +#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 <[email protected]> + * + */ + +#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