fix(eq): stop the phone speaker from impersonating a Bluetooth device
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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user