fix(eq): stop the phone speaker from impersonating a Bluetooth device
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s

deviceToMap handed the builtin_speaker id to EVERY output type its `when`
did not name. A car stereo on LE Audio (TYPE_BLE_HEADSET) or an automotive
bus (TYPE_BUS) therefore arrived in Dart under the phone speaker's own id,
carrying a type that maps to `desconocido` -- which slipped past the
type-only esBase guard and persisted a device entry keyed builtin_speaker.
From that moment on, every playback through the phone's own speaker matched
that entry, so the green active-output dot stayed pinned to whatever the user
had renamed it to (a car, in the reported case) whether or not anything was
connected. The dot was never wrong; the row was poisoned.

Give unnamed output types their own `other:<type>:<address>` id namespace,
and match esBase by id as well as by type so no future native regression can
re-create the collision. A guarded one-time migration purges what the
collision already persisted from all three device-keyed maps.

Fix the ranking too: builtin_speaker sat inside the priority list as a peer,
so any type absent from that list sorted BELOW the always-present speaker
and could never win. The speaker is now the explicit last resort, externally
connected outputs outrank it, and virtual or call-only sinks (earpiece,
telephony, remote submix, SCO) are ranked below it so they can never be
reported as where music is playing.

Route every AudioDeviceInfo.getAddress read through a version-guarded
helper. It is API 28 with minSdk 24, and two pre-existing unguarded calls in
this same method were latent NoSuchMethodError crashes on Android 7-8.1.
Android lint for :app goes from 8 errors to 6.

Also lets the user manage the list, which is how they recover from a bad
entry without waiting for a release: a remove action clears a device's
preset, name and matrix entries, unnamed rows show their transport and
address tail instead of a raw bt_a2dp:AA:BB:... id, and the green dot
finally carries a tooltip and a semantics label saying what it means.

Device QA pending for wired and USB outputs: no jack or adapter available to
exercise those paths. Their detection is unchanged by this commit.
This commit is contained in:
2026-07-25 16:10:36 +02:00
parent eee2ae98d0
commit 39ead7bea4
36 changed files with 694 additions and 41 deletions
+78 -6
View File
@@ -914,14 +914,28 @@ class _FilaDispositivo extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final isActive = eq.dispositivoActualId == deviceId;
final displayName = eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
final displayName = _nombreLegible(
deviceId,
eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId)),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
// The dot marks where audio is coming out RIGHT NOW, which is not the
// same as "paired" or "connected" — it needs a label, both for screen
// readers and for anyone wondering what a bare green dot means.
if (isActive)
const Icon(Icons.circle, size: 10, color: Colors.green)
Tooltip(
message: l10n.eqDeviceActiveOutput,
child: Icon(
Icons.circle,
size: 10,
color: Colors.green,
semanticLabel: l10n.eqDeviceActiveOutput,
),
)
else
const SizedBox(width: 10),
const SizedBox(width: 8),
@@ -954,6 +968,34 @@ class _FilaDispositivo extends StatelessWidget {
);
}
/// Turns a device id the user never named into something readable.
///
/// [nombreVisible] falls back to the raw id when neither a custom name nor a
/// platform name is known — which is the normal case for a Bluetooth device
/// that is not currently connected, since platform names are cached in memory
/// only. Showing `bt_a2dp:AA:BB:CC:DD:EE:FF` tells the user nothing, so keep
/// the transport plus the tail of the address, which is what distinguishes
/// two otherwise identical rows.
static String _nombreLegible(String deviceId, String nombreVisible) {
if (nombreVisible != deviceId) return nombreVisible;
final separador = deviceId.indexOf(':');
if (separador == -1) return deviceId;
final transporte = deviceId.substring(0, separador);
final resto = deviceId.substring(separador + 1);
final etiqueta = switch (transporte) {
'bt_a2dp' => 'Bluetooth',
'usb_headset' => 'USB',
_ => transporte,
};
final cola = resto.split(':').where((p) => p.isNotEmpty).toList();
if (cola.isEmpty) return etiqueta;
final sufijo = cola.length >= 2
? cola.sublist(cola.length - 2).join(':')
: cola.last;
return '$etiqueta · $sufijo';
}
Future<void> _abrirModal(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
@@ -1014,6 +1056,22 @@ class _DialogoEdicionDispositivoState
if (mounted) Navigator.of(context).pop();
}
Future<void> _eliminar() async {
final eq = context.read<EstadoEcualizador>();
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final nombre = _nombreCtrl.text.trim().isEmpty
? widget.deviceId
: _nombreCtrl.text.trim();
await eq.eliminarDispositivo(widget.deviceId);
if (!mounted) return;
Navigator.of(context).pop();
messenger.showSnackBar(
SnackBar(content: Text(l10n.eqDeviceRemoved(nombre))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -1046,10 +1104,24 @@ class _DialogoEdicionDispositivoState
onCambio: (p) => setState(() => _presetActual = p),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.save_rounded),
label: Text(l10n.eqDeviceNameConfirm),
),
),
const SizedBox(width: 12),
// Lets the user clear stale or duplicate rows. The device comes
// back on its next connection, so this is recoverable.
OutlinedButton.icon(
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.eqDeviceRemove),
),
],
),
],
),