fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s

Dart half of bt-device-identity. EstadoEcualizador now caches each
device's platform-reported name in memory so the settings screen
shows the device's own Bluetooth name instead of its raw id when no
custom rename exists, and skips auto-creating preset entries for the
composite-placeholder sentinel. Enabling multi-device EQ triggers the
Bluetooth permission request through the new channel contract. A
flag-guarded one-time migration purges only entries keyed by the
exact literal placeholder id from the three per-device preference
maps, since those collided entries cannot be attributed to a device.

Work unit 2/2 of bt-device-identity (Dart state + migration).
This commit is contained in:
2026-07-11 00:25:00 +02:00
parent 224763bca3
commit b17c582572
11 changed files with 711 additions and 7 deletions
+47 -2
View File
@@ -26,6 +26,12 @@ import '../servicios/servicio_ecualizador.dart';
/// When the toggle is false, resolution falls back to the original 2-level
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
class EstadoEcualizador extends ChangeNotifier {
/// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a
/// device whose real Bluetooth MAC is not yet known (BLUETOOTH_CONNECT
/// denied or unresolved). Devices with this prefix still update the
/// platform-name cache but never auto-create a `presetsDispositivo` entry.
static const _prefijoPlaceholderCompuesto = 'bt_a2dp:name:';
EstadoEcualizador({
required this.audio,
ServicioEcualizador? servicio,
@@ -54,6 +60,13 @@ class EstadoEcualizador extends ChangeNotifier {
/// Custom display names for devices: deviceId → custom name.
final Map<String, String> _nombresDispositivos = {};
/// Last-seen platform (Bluetooth/productName) name per deviceId.
///
/// In-memory only (bt-device-identity ADR-4) — NOT persisted. Devices
/// re-report their name on every enumeration, so this cache self-heals
/// every session without needing a SharedPreferences key or migration.
final Map<String, String> _nombresPlataforma = {};
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true;
@@ -210,15 +223,27 @@ class EstadoEcualizador extends ChangeNotifier {
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
if (!_eqMultiDeviceEnabled) return;
// Cache updates on every event regardless of whether a preset entry
// gets created below (bt-device-identity ADR-4).
_nombresPlataforma[dispositivo.id] = dispositivo.nombre;
_dispositivoActualId = dispositivo.id;
// First-seen device: copy the current resolved preset as its starting
// point. builtin_speaker is excluded: it must always fall through to
// the hierarchy (L4 global) instead of being pinned by a forced L3
// device-level copy, otherwise a later global-preset change would be
// masked by this stale entry for the base device.
// masked by this stale entry for the base device. Composite-placeholder
// ids are also excluded (bt-device-identity ADR-6): they are transient
// fallback ids for a device whose real MAC is not yet known, so
// persisting a preset entry for them would create dead noise that never
// resolves to the eventual real-MAC id.
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
if (!esBase && !_presetsDispositivo.containsKey(dispositivo.id)) {
final esPlaceholderCompuesto = dispositivo.id.startsWith(
_prefijoPlaceholderCompuesto,
);
if (!esBase &&
!esPlaceholderCompuesto &&
!_presetsDispositivo.containsKey(dispositivo.id)) {
final presetBase = _resolverPresetActivo();
_presetsDispositivo[dispositivo.id] = presetBase;
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
@@ -252,6 +277,21 @@ class EstadoEcualizador extends ChangeNotifier {
if (notificar) notifyListeners();
}
/// Requests `BLUETOOTH_CONNECT` (API 31+) at the point the
/// device-management UI is opened (bt-device-identity ADR-1).
///
/// Thin passthrough to the injected device service so callers don't need
/// [ServicioDispositivoAudio] wired as its own top-level `Provider` (Task
/// 4.8 — `ServicioDispositivoAudio` is only ever constructed inside
/// `EstadoRadio` today, not exposed via the provider tree; routing through
/// here avoids adding wiring the tree doesn't already have). Returns false
/// when no device service is injected.
Future<bool> solicitarPermisoBluetooth() async {
final svc = _dispositivoAudio;
if (svc == null) return false;
return svc.solicitarPermisoBluetooth();
}
Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, {
bool notificar = true,
@@ -353,6 +393,11 @@ class EstadoEcualizador extends ChangeNotifier {
String obtenerNombreDispositivo(String deviceId) =>
_nombresDispositivos[deviceId] ?? '';
/// Returns the last-seen platform name for [deviceId], or an empty string
/// if none has been observed yet (bt-device-identity ADR-4).
String nombrePlataforma(String deviceId) =>
_nombresPlataforma[deviceId] ?? '';
/// Resolves the display name for [deviceId] using the fallback chain:
/// custom name → [platformName] → raw [deviceId].
String nombreVisible(String deviceId, String platformName) {
+20 -4
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
@@ -698,7 +699,8 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap:
() => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled),
() =>
_alternarMultiDevice(eq, !multiDeviceEnabled),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -718,7 +720,7 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
Switch.adaptive(
value: multiDeviceEnabled,
onChanged:
(habilitado) => eq.cambiarMultiDeviceEnabled(habilitado),
(habilitado) => _alternarMultiDevice(eq, habilitado),
),
],
),
@@ -749,6 +751,17 @@ class _SeccionEcualizadorAvanzado extends StatelessWidget {
),
);
}
/// Toggles the multi-device EQ feature and, when turning it ON, requests
/// `BLUETOOTH_CONNECT` at this point-of-intent (bt-device-identity ADR-1)
/// so BT devices report their real MAC instead of the OS placeholder.
/// Fire-and-forget: neither call blocks the toggle UI on its result.
void _alternarMultiDevice(EstadoEcualizador eq, bool habilitado) {
unawaited(eq.cambiarMultiDeviceEnabled(habilitado));
if (habilitado) {
unawaited(eq.solicitarPermisoBluetooth());
}
}
}
/// A single device row in the known-devices list.
@@ -766,7 +779,7 @@ class _FilaDispositivo extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final isActive = eq.dispositivoActualId == deviceId;
final displayName = eq.nombreVisible(deviceId, '');
final displayName = eq.nombreVisible(deviceId, eq.nombrePlataforma(deviceId));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
@@ -843,7 +856,10 @@ class _DialogoEdicionDispositivoState
void initState() {
super.initState();
final eq = context.read<EstadoEcualizador>();
final displayName = eq.nombreVisible(widget.deviceId, '');
final displayName = eq.nombreVisible(
widget.deviceId,
eq.nombrePlataforma(widget.deviceId),
);
_nombreCtrl = TextEditingController(text: displayName);
_presetActual = widget.preset;
}
@@ -21,6 +21,12 @@ abstract class ServicioDispositivoAudio {
/// (method channel round-trip). Returns the cached value if already known.
Future<DispositivoAudio> obtenerDispositivoActual();
/// Requests the `BLUETOOTH_CONNECT` runtime permission (API 31+) at the
/// point the device-management UI is opened (bt-device-identity ADR-1).
/// Returns true when granted or not required (SDK < 31, iOS); false when
/// denied.
Future<bool> solicitarPermisoBluetooth();
/// Cancels the device-change subscription and releases resources.
Future<void> dispose();
}
@@ -82,6 +88,14 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
return device;
}
@override
Future<bool> solicitarPermisoBluetooth() async {
final granted = await _methodChannel.invokeMethod<bool>(
'requestBluetoothConnect',
);
return granted ?? false;
}
@override
Future<void> dispose() async {
await _eventSub?.cancel();
+58
View File
@@ -43,6 +43,13 @@ class ServicioEcualizador {
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1';
/// bt-device-identity ADR-5: guard flag for the one-time placeholder purge.
static const _keyPlaceholderPurgaHecha = 'eq_placeholder_purge_done_v1';
/// bt-device-identity ADR-3: the exact OS placeholder MAC, in the
/// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps.
static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00';
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
@@ -50,6 +57,7 @@ class ServicioEcualizador {
_prefs ?? SharedPreferences.getInstance();
Future<ConfiguracionEcualizador> cargar() async {
await migrarClavesPlaceholder();
final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs);
@@ -67,6 +75,56 @@ class ServicioEcualizador {
);
}
/// One-time guarded migration (bt-device-identity ADR-5): purges entries
/// keyed by the exact OS placeholder MAC ([_placeholderMacLiteral]) from
/// all three device-keyed SP maps. Idempotent — short-circuits via
/// [_keyPlaceholderPurgaHecha] after the first successful run. Every other
/// entry (including near-miss keys) is preserved byte-for-byte.
Future<void> migrarClavesPlaceholder() async {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return;
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
if (presetsPorDispositivo.remove(_placeholderMacLiteral) != null) {
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
presetsPorDispositivo,
);
}
// Matrix keys are "stationUuid:deviceId"; split on the FIRST colon only
// (station UUIDs are RFC4122 and contain no colons — multi-device-eq
// ADR-3), since deviceId itself may contain colons (e.g. a MAC-based id).
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
final deviceIdSegmento = clave.substring(separador + 1);
return deviceIdSegmento == _placeholderMacLiteral;
}).toList();
if (clavesMatrizAPurgar.isNotEmpty) {
for (final clave in clavesMatrizAPurgar) {
presetsMatriz.remove(clave);
}
await _guardarMapa(prefs, _keyPresetsMatriz, presetsMatriz);
}
final nombresDispositivos = _leerMapaStrings(
prefs,
_keyNombresDispositivos,
);
if (nombresDispositivos.remove(_placeholderMacLiteral) != null) {
await _guardarMapaStrings(
prefs,
_keyNombresDispositivos,
nombresDispositivos,
);
}
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
}
Future<void> guardarPrincipal(PresetEcualizador preset) async {
final prefs = await _resolverPrefs();
await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson()));