feat(eq): name Bluetooth devices from the system pairing list
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:
@@ -15,6 +15,14 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
|
<!--
|
||||||
|
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
|
||||||
|
and by this legacy permission below it. Normal permission: granted at
|
||||||
|
install, no runtime prompt.
|
||||||
|
-->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH"
|
||||||
|
android:maxSdkVersion="30"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="PluriWave"
|
android:label="PluriWave"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package es.freetimelab.pluriwave
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
@@ -1080,6 +1081,11 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
Log.d(tag, "audio_devices.requestBluetoothConnect")
|
Log.d(tag, "audio_devices.requestBluetoothConnect")
|
||||||
result.success(requestBluetoothConnect())
|
result.success(requestBluetoothConnect())
|
||||||
}
|
}
|
||||||
|
"getBondedDeviceNames" -> {
|
||||||
|
val names = bondedDeviceNames()
|
||||||
|
Log.d(tag, "audio_devices.getBondedDeviceNames count=${names.size}")
|
||||||
|
result.success(names)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1128,46 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
return true
|
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<String, String> {
|
||||||
|
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() {
|
private fun registerAudioDeviceCallback() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
|
||||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
|
|
||||||
// Seed current device immediately so presets resolve before first event.
|
// Seed current device immediately so presets resolve before first event.
|
||||||
if (_eqMultiDeviceEnabled) {
|
if (_eqMultiDeviceEnabled) {
|
||||||
|
await _sembrarNombresEmparejados();
|
||||||
await _sembrarDispositivoActual();
|
await _sembrarDispositivoActual();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} 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
|
/// Queries the current device and seeds [_dispositivoActualId] without
|
||||||
/// waiting for a stream event. Falls back to `'builtin_speaker'` on error.
|
/// waiting for a stream event. Falls back to `'builtin_speaker'` on error.
|
||||||
Future<void> _sembrarDispositivoActual() async {
|
Future<void> _sembrarDispositivoActual() async {
|
||||||
@@ -232,6 +257,10 @@ class EstadoEcualizador extends ChangeNotifier {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// A failed resubscribe must never block the fresh-query re-seed below.
|
// 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();
|
await _sembrarDispositivoActual();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ abstract class ServicioDispositivoAudio {
|
|||||||
/// denied.
|
/// denied.
|
||||||
Future<bool> solicitarPermisoBluetooth();
|
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.
|
/// Cancels the device-change subscription and releases resources.
|
||||||
Future<void> dispose();
|
Future<void> dispose();
|
||||||
}
|
}
|
||||||
@@ -130,6 +143,22 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
|
|||||||
return granted ?? false;
|
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
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _eventSub?.cancel();
|
await _eventSub?.cancel();
|
||||||
|
|||||||
@@ -1543,6 +1543,93 @@ void main() {
|
|||||||
// builtin_speaker id collision + device removal
|
// 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<void>.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', () {
|
group('EstadoEcualizador — builtin_speaker collision guard', () {
|
||||||
test(
|
test(
|
||||||
'an unknown-type device reported under the base id creates no entry',
|
'an unknown-type device reported under the base id creates no entry',
|
||||||
|
|||||||
+16
-1
@@ -465,6 +465,11 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
|||||||
throw Exception('Platform channel error: device unavailable');
|
throw Exception('Platform channel error: device unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> obtenerNombresEmparejados() async {
|
||||||
|
throw Exception('Platform channel error: bonded devices unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> resubscribir() async {
|
Future<void> resubscribir() async {
|
||||||
resubscribirLlamadas++;
|
resubscribirLlamadas++;
|
||||||
@@ -485,7 +490,17 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
|||||||
/// Use [emitirDispositivo] to push device change events without needing
|
/// Use [emitirDispositivo] to push device change events without needing
|
||||||
/// a real platform channel.
|
/// a real platform channel.
|
||||||
class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
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<String, String> nombresEmparejados;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> obtenerNombresEmparejados() async =>
|
||||||
|
nombresEmparejados;
|
||||||
|
|
||||||
/// Value returned by [solicitarPermisoBluetooth] (bt-device-identity
|
/// Value returned by [solicitarPermisoBluetooth] (bt-device-identity
|
||||||
/// Phase 2/4 testing strategy — defaults to granted so existing tests
|
/// Phase 2/4 testing strategy — defaults to granted so existing tests
|
||||||
|
|||||||
Reference in New Issue
Block a user