From 4f91f490b8203aa44b74bb736c19fc934c02b1eb Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 25 Jul 2026 17:00:02 +0200 Subject: [PATCH] feat(eq): name Bluetooth devices from the system pairing list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Bluetooth device only reports its own name through AudioDeviceInfo.productName while it is enumerated as an active output, i.e. while it is connected. Paired-but-switched-off devices therefore had no name to fall back on, and the platform-name cache is in-memory only by design (bt-device-identity ADR-4), so it self-heals per session ONLY for whatever happens to be connected. Every other device showed its raw id. Android already knows those names: BluetoothAdapter.getBondedDevices() lists every pairing with its name and MAC, connected or not, and nothing in this app was asking. Read it and seed the platform-name cache from it, keyed bt_a2dp: to match the ids the audio layer emits. Seeded BEFORE the active-device query so a live enumeration name, being the fresher of the two, still wins; a user's custom name outranks both. Re-read on refrescarDispositivoActual so pairing or renaming a device in system settings shows up as soon as the list becomes visible. Reading the bond list is gated by BLUETOOTH_CONNECT from API 31 and by the legacy BLUETOOTH permission below it, so declare the latter with maxSdkVersion 30. It is a normal permission: granted at install, no runtime prompt, no new friction. When the answer is unavailable — permission denied, no adapter, Bluetooth off — both layers return an empty map rather than throwing, and the row degrades to the id exactly as before. Does not help rows persisted under a bt_a2dp:name: placeholder id: those never had a MAC to match against. --- android/app/src/main/AndroidManifest.xml | 8 ++ .../es/freetimelab/pluriwave/MainActivity.kt | 46 ++++++++++ lib/estado/estado_ecualizador.dart | 29 +++++++ lib/servicios/servicio_dispositivo_audio.dart | 29 +++++++ test/estado/estado_ecualizador_test.dart | 87 +++++++++++++++++++ test/helpers/fakes.dart | 17 +++- 6 files changed, 215 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 39f7c12..ba0614e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,14 @@ + + { + val names = bondedDeviceNames() + Log.d(tag, "audio_devices.getBondedDeviceNames count=${names.size}") + result.success(names) + } else -> result.notImplemented() } } @@ -1122,6 +1128,46 @@ class MainActivity : AudioServiceActivity() { return true } + /** + * Returns MAC -> name for every PAIRED Bluetooth device, connected or not. + * + * `AudioDeviceInfo.productName` only exists while a device is enumerated as + * an active output, so a paired-but-switched-off device can never report its + * own name and its row falls back to the raw id. The bond list is the + * system's own record and is the only source that survives disconnection. + * + * Returns an empty map instead of throwing when the answer is unavailable + * (BLUETOOTH_CONNECT denied, no adapter, device with Bluetooth off): a + * missing name must degrade to the id, never break device resolution. + */ + private fun bondedDeviceNames(): Map { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED + ) { + Log.d(tag, "audio_devices.bondedDeviceNames BLUETOOTH_CONNECT not granted") + return emptyMap() + } + return try { + val manager = getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager + val adapter = manager?.adapter ?: return emptyMap() + adapter.bondedDevices + .orEmpty() + .mapNotNull { device -> + val address = device.address + ?.takeIf { it.isNotBlank() && it != bluetoothMacPlaceholder } + ?: return@mapNotNull null + val name = device.name?.takeIf { it.isNotBlank() } + ?: return@mapNotNull null + address.uppercase() to name + } + .toMap() + } catch (error: Throwable) { + Log.w(tag, "audio_devices.bondedDeviceNames failed", error) + emptyMap() + } + } + private fun registerAudioDeviceCallback() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index f757b46..c262bca 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -176,6 +176,7 @@ class EstadoEcualizador extends ChangeNotifier { // Seed current device immediately so presets resolve before first event. if (_eqMultiDeviceEnabled) { + await _sembrarNombresEmparejados(); await _sembrarDispositivoActual(); } } catch (_) { @@ -190,6 +191,30 @@ class EstadoEcualizador extends ChangeNotifier { } } + /// Fills [_nombresPlataforma] from the system's paired-device list. + /// + /// A Bluetooth device only reports its own name while it is connected, so + /// without this a device the user never renamed shows its raw id whenever it + /// is switched off — which is most of the time. The bond list is the system's + /// own record and survives disconnection. + /// + /// Seeded BEFORE [_sembrarDispositivoActual] so a live enumeration name (the + /// fresher of the two) overwrites the paired one rather than the reverse. + /// Never throws: a device with no resolvable name just falls back to its id. + Future _sembrarNombresEmparejados() async { + final svc = _dispositivoAudio; + if (svc == null) return; + try { + final emparejados = await svc.obtenerNombresEmparejados(); + for (final entry in emparejados.entries) { + if (entry.value.isEmpty) continue; + _nombresPlataforma['bt_a2dp:${entry.key}'] = entry.value; + } + } catch (_) { + // Permission denied or no adapter: keep whatever names we already have. + } + } + /// Queries the current device and seeds [_dispositivoActualId] without /// waiting for a stream event. Falls back to `'builtin_speaker'` on error. Future _sembrarDispositivoActual() async { @@ -232,6 +257,10 @@ class EstadoEcualizador extends ChangeNotifier { } catch (_) { // A failed resubscribe must never block the fresh-query re-seed below. } + // Re-read the bond list too: the user may have paired or renamed a device + // in system settings since the app started, and this runs right as the + // device list becomes visible. + await _sembrarNombresEmparejados(); await _sembrarDispositivoActual(); } diff --git a/lib/servicios/servicio_dispositivo_audio.dart b/lib/servicios/servicio_dispositivo_audio.dart index 303f849..080d600 100644 --- a/lib/servicios/servicio_dispositivo_audio.dart +++ b/lib/servicios/servicio_dispositivo_audio.dart @@ -55,6 +55,19 @@ abstract class ServicioDispositivoAudio { /// denied. Future solicitarPermisoBluetooth(); + /// Returns the names the system remembers for every PAIRED Bluetooth device, + /// keyed by uppercase MAC. + /// + /// `AudioDeviceInfo.productName` only exists for devices the platform is + /// enumerating right now, i.e. currently connected ones, so a paired device + /// sitting in a drawer can never report its own name. The bond list is the + /// authoritative source that survives disconnection. + /// + /// Returns an empty map when the answer is unavailable rather than throwing: + /// `BLUETOOTH_CONNECT` may be denied, and a missing name must degrade to the + /// id fallback, never break device resolution. + Future> obtenerNombresEmparejados() async => const {}; + /// Cancels the device-change subscription and releases resources. Future dispose(); } @@ -130,6 +143,22 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio { return granted ?? false; } + @override + Future> obtenerNombresEmparejados() async { + try { + final raw = await _methodChannel.invokeMethod('getBondedDeviceNames'); + if (raw == null) return const {}; + return { + for (final entry in raw.entries) + if (entry.key is String && entry.value is String) + (entry.key as String).toUpperCase(): entry.value as String, + }; + } catch (_) { + // Denied permission or no adapter: fall back to the id, never throw. + return const {}; + } + } + @override Future dispose() async { await _eventSub?.cancel(); diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 6ba3dba..b5dd8cb 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1543,6 +1543,93 @@ void main() { // builtin_speaker id collision + device removal // --------------------------------------------------------------------------- + group('EstadoEcualizador — bonded Bluetooth names', () { + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + + test('a paired but disconnected device resolves its system name', () async { + final fakeDispositivo = FakeServicioDispositivoAudio( + nombresEmparejados: {'AA:BB:CC:DD:EE:FF': 'Omoda'}, + ); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador( + eqMultiDeviceEnabled: true, + presetsDispositivo: {deviceId: PresetEcualizador.rock}, + ), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + + await eq.cargarPersistido(); + + // The device was never seen on the stream this session, so the only + // possible source is the system's bonded-device list. + expect(eq.nombrePlataforma(deviceId), equals('Omoda')); + eq.dispose(); + }); + + test('a live enumeration name wins over the bonded one', () async { + final fakeDispositivo = FakeServicioDispositivoAudio( + nombresEmparejados: {'AA:BB:CC:DD:EE:FF': 'Stale pairing name'}, + ); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + + fakeDispositivo.emitirDispositivo( + const DispositivoAudio( + id: deviceId, + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'Omoda', + ), + ); + await Future.delayed(Duration.zero); + + expect(eq.nombrePlataforma(deviceId), equals('Omoda')); + eq.dispose(); + }); + + test('a custom name still beats the bonded one', () async { + final fakeDispositivo = FakeServicioDispositivoAudio( + nombresEmparejados: {'AA:BB:CC:DD:EE:FF': 'OMODA 5'}, + ); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador( + eqMultiDeviceEnabled: true, + nombresDispositivos: {deviceId: 'Coche'}, + ), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + + expect( + eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)), + equals('Coche'), + ); + eq.dispose(); + }); + + test('a failing bonded-name lookup never breaks startup', () async { + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: FakeServicioDispositivoAudioThrows(), + emisoraActualUuid: () => null, + ); + + await eq.cargarPersistido(); + + expect(eq.nombrePlataforma('bt_a2dp:AA:BB'), isEmpty); + eq.dispose(); + }); + }); + group('EstadoEcualizador — builtin_speaker collision guard', () { test( 'an unknown-type device reported under the base id creates no entry', diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 18e2162..ee3abd8 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -465,6 +465,11 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio { throw Exception('Platform channel error: device unavailable'); } + @override + Future> obtenerNombresEmparejados() async { + throw Exception('Platform channel error: bonded devices unavailable'); + } + @override Future resubscribir() async { resubscribirLlamadas++; @@ -485,7 +490,17 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio { /// Use [emitirDispositivo] to push device change events without needing /// a real platform channel. class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { - FakeServicioDispositivoAudio({this.permisoBluetoothConcedido = true}); + FakeServicioDispositivoAudio({ + this.permisoBluetoothConcedido = true, + this.nombresEmparejados = const {}, + }); + + /// MAC → name entries the system would report for PAIRED devices. + final Map nombresEmparejados; + + @override + Future> obtenerNombresEmparejados() async => + nombresEmparejados; /// Value returned by [solicitarPermisoBluetooth] (bt-device-identity /// Phase 2/4 testing strategy — defaults to granted so existing tests