feat(eq): name Bluetooth devices from the system pairing list
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m34s

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:<uppercase MAC> 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.
This commit is contained in:
2026-07-25 17:01:40 +02:00
parent 2391fb767b
commit 4f91f490b8
6 changed files with 215 additions and 1 deletions
+29
View File
@@ -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<void> _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<void> _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();
}
@@ -55,6 +55,19 @@ abstract class ServicioDispositivoAudio {
/// denied.
Future<bool> 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<Map<String, String>> obtenerNombresEmparejados() async => const {};
/// Cancels the device-change subscription and releases resources.
Future<void> dispose();
}
@@ -130,6 +143,22 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
return granted ?? false;
}
@override
Future<Map<String, String>> obtenerNombresEmparejados() async {
try {
final raw = await _methodChannel.invokeMethod<Map>('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<void> dispose() async {
await _eventSub?.cancel();