From: Jan-Michael <jan.brummer@tabos.org>
To: linux-bluetooth@vger.kernel.org
Cc: jan.brummer@tabos.org
Subject: [PATCH BlueZ] Add cover art support
Date: Fri, 9 Oct 2026 15:49:24 +0200 [thread overview]
Message-ID: <20261009134924.122040-2-jan.brummer@tabos.org> (raw)
In-Reply-To: <20261009134924.122040-1-jan.brummer@tabos.org>
From: Jan-Michael Brummer <jan.brummer@tabos.org>
Add bluetooth cover art support based on the existing code.
Tested with VW head unit and Fairphone 5.
---
Makefile.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..ffa192136
--- /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..449eb9a5d 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;
+static unsigned int psm_watch_id;
+
+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.56.0
next prev parent reply other threads:[~2026-10-09 13:49 UTC|newest]
Thread overview: 17+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-08-03 19:55 [PATCH BlueZ 0/1] Add cover art support Jan-Michael
2026-08-03 19:55 ` [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
2026-09-23 18:44 ` bluez.test.bot
2026-10-09 13:49 ` [PATCH v4 BlueZ 0/1] " Jan-Michael
2026-10-09 13:49 ` Jan-Michael [this message]
2026-10-09 15:58 ` [BlueZ] " bluez.test.bot
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261009134924.122040-2-jan.brummer@tabos.org \
--to=jan.brummer@tabos.org \
--cc=linux-bluetooth@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox