Thread (12 messages) flat view 12 messages, 4 authors, 1d ago
WARM1d

[PATCH v3 BlueZ] Add cover art support

From: Jan-Michael <hidden>
Date: 2026-09-07 19:34:24
Subsystem: library code, the rest · Maintainers: Andrew Morton, Linus Torvalds

From: Jan-Michael Brummer <redacted>

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
Keyboard shortcuts
hback out one level
jnext message in thread
kprevious message in thread
ldrill in
Escclose help / fold thread tree
?toggle this help