import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import '../modelos/preset_ecualizador.dart'; import 'persistencia_tolerante.dart'; import 'servicio_dispositivo_audio.dart' show idAltavozInterno, prefijoDispositivoOtro; class ConfiguracionEcualizador { const ConfiguracionEcualizador({ required this.principal, required this.porEmisora, this.activo = true, this.eqMultiDeviceEnabled = false, this.presetsDispositivo = const {}, this.presetsMatriz = const {}, this.nombresDispositivos = const {}, }); final PresetEcualizador principal; final Map porEmisora; final bool activo; /// Feature toggle: when false all multi-device logic is bypassed. final bool eqMultiDeviceEnabled; /// Per-device presets: deviceId → PresetEcualizador. final Map presetsDispositivo; /// Matrix presets: "stationUuid:deviceId" → PresetEcualizador. final Map presetsMatriz; /// Custom display names for devices: deviceId → custom name. final Map nombresDispositivos; } class ServicioEcualizador { ServicioEcualizador({SharedPreferences? prefs}) : _prefs = prefs; static const _keyPresetPrincipal = 'eq_preset_principal_v1'; static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1'; static const _keyActivo = 'eq_activo_v1'; static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1'; static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1'; 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'; /// Guard flag for the one-time `builtin_speaker` collision purge. static const _keyColisionBasePurgaHecha = 'eq_builtin_speaker_purge_done_v1'; final SharedPreferences? _prefs; /// Injected startup instance (S3-R4); getInstance() is only a fallback. Future _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); Future cargar() async { await migrarClavesPlaceholder(); await migrarColisionAltavozInterno(); final prefs = await _resolverPrefs(); final principal = _leerPresetPrincipal(prefs); final porEmisora = _leerPresetsPorEmisora(prefs); final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo); final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz); final nombresDispositivos = _leerMapaStrings( prefs, _keyNombresDispositivos, ); return ConfiguracionEcualizador( principal: principal, porEmisora: porEmisora, activo: prefs.getBool(_keyActivo) ?? true, eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false, presetsDispositivo: presetsDispositivo, presetsMatriz: presetsMatriz, nombresDispositivos: nombresDispositivos, ); } /// 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 migrarClavesPlaceholder() async { final prefs = await _resolverPrefs(); if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return; await _purgarDeviceId(prefs, _placeholderMacLiteral); await prefs.setBool(_keyPlaceholderPurgaHecha, true); } /// One-time guarded migration: purges entries keyed by [idAltavozInterno]. /// /// `MainActivity.deviceToMap` used to hand the phone-speaker id to ANY output /// type it could not name (LE Audio, car bus, dock). Dart then created a /// device entry under that id, and from then on every playback through the /// phone's own speaker matched it — permanently flagging whatever the user /// had renamed it to as the active device. The native id is fixed; this /// clears what the collision already persisted. Idempotent via /// [_keyColisionBasePurgaHecha]; every other entry survives byte-for-byte. Future migrarColisionAltavozInterno() async { final prefs = await _resolverPrefs(); if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return; await _purgarDeviceId(prefs, idAltavozInterno); await _purgarPrefijoDispositivo(prefs, prefijoDispositivoOtro); await prefs.setBool(_keyColisionBasePurgaHecha, true); } /// Removes every device-keyed entry whose id starts with [prefijo]. /// /// Used for the `other:` namespace: a build that ranked every unlisted /// `AudioDeviceInfo` type above the phone speaker selected permanent internal /// sinks (TYPE_FM on Xiaomi, TYPE_BUILTIN_SPEAKER_SAFE elsewhere) as the /// active output and persisted rows for them. Genuine external outputs in /// this namespace are re-registered on their next connection, so clearing the /// whole prefix costs nothing and needs no per-type knowledge here. Future _purgarPrefijoDispositivo( SharedPreferences prefs, String prefijo, ) async { final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo); final dispositivos = presetsPorDispositivo.keys .where((clave) => clave.startsWith(prefijo)) .toList(); final nombres = _leerMapaStrings(prefs, _keyNombresDispositivos); final nombresAPurgar = nombres.keys.where((clave) => clave.startsWith(prefijo)).toList(); final matriz = _leerMapa(prefs, _keyPresetsMatriz); final matrizAPurgar = matriz.keys.where((clave) { final separador = clave.indexOf(':'); if (separador == -1) return false; return clave.substring(separador + 1).startsWith(prefijo); }).toList(); for (final clave in dispositivos) { presetsPorDispositivo.remove(clave); } for (final clave in nombresAPurgar) { nombres.remove(clave); } for (final clave in matrizAPurgar) { matriz.remove(clave); } if (dispositivos.isNotEmpty) { await _guardarMapa( prefs, _keyPresetsPorDispositivo, presetsPorDispositivo, ); } if (nombresAPurgar.isNotEmpty) { await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres); } if (matrizAPurgar.isNotEmpty) { await _guardarMapa(prefs, _keyPresetsMatriz, matriz); } } /// Removes every trace of [deviceId] from the three device-keyed SP maps. /// /// Shared by the guarded migrations and by [eliminarDispositivo]; each map is /// only rewritten when the key was actually present. Future _purgarDeviceId(SharedPreferences prefs, String deviceId) async { final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo); if (presetsPorDispositivo.remove(deviceId) != 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; return clave.substring(separador + 1) == deviceId; }).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(deviceId) != null) { await _guardarMapaStrings( prefs, _keyNombresDispositivos, nombresDispositivos, ); } } /// Forgets a device completely: its preset, its custom name and every matrix /// entry that targets it. Used by the "remove device" action so the user can /// clear stale or duplicate entries from the known-devices list. Future eliminarDispositivo(String deviceId) async { final prefs = await _resolverPrefs(); await _purgarDeviceId(prefs, deviceId); } Future guardarPrincipal(PresetEcualizador preset) async { final prefs = await _resolverPrefs(); await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson())); } Future guardarPorEmisora(String uuid, PresetEcualizador preset) async { final prefs = await _resolverPrefs(); final mapa = _leerPresetsPorEmisora(prefs); mapa[uuid] = preset; await _guardarPresetsPorEmisora(prefs, mapa); } Future guardarActivo(bool activo) async { final prefs = await _resolverPrefs(); await prefs.setBool(_keyActivo, activo); } Future eliminarPorEmisora(String uuid) async { final prefs = await _resolverPrefs(); final mapa = _leerPresetsPorEmisora(prefs); mapa.remove(uuid); await _guardarPresetsPorEmisora(prefs, mapa); } Future guardarConfiguracion(ConfiguracionEcualizador config) async { final prefs = await _resolverPrefs(); await prefs.setString( _keyPresetPrincipal, jsonEncode(config.principal.toJson()), ); await _guardarPresetsPorEmisora(prefs, config.porEmisora); await prefs.setBool(_keyActivo, config.activo); await prefs.setBool(_keyMultiDeviceEnabled, config.eqMultiDeviceEnabled); await _guardarMapa( prefs, _keyPresetsPorDispositivo, config.presetsDispositivo, ); await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz); await _guardarMapaStrings( prefs, _keyNombresDispositivos, config.nombresDispositivos, ); } /// Persists the multi-device feature toggle. Future guardarToggleMultiDispositivo(bool habilitado) async { final prefs = await _resolverPrefs(); await prefs.setBool(_keyMultiDeviceEnabled, habilitado); } /// Saves a per-device preset. Future guardarPresetDispositivo( String deviceId, PresetEcualizador preset, ) async { final prefs = await _resolverPrefs(); final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo); mapa[deviceId] = preset; await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa); } /// Saves a matrix (stationUuid:deviceId) preset entry. Future guardarPresetMatriz( String clave, PresetEcualizador preset, ) async { final prefs = await _resolverPrefs(); final mapa = _leerMapa(prefs, _keyPresetsMatriz); mapa[clave] = preset; await _guardarMapa(prefs, _keyPresetsMatriz, mapa); } /// Removes a per-device preset entry. Future eliminarPresetDispositivo(String deviceId) async { final prefs = await _resolverPrefs(); final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo); mapa.remove(deviceId); await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa); } /// Removes a matrix preset entry. Future eliminarPresetMatriz(String clave) async { final prefs = await _resolverPrefs(); final mapa = _leerMapa(prefs, _keyPresetsMatriz); mapa.remove(clave); await _guardarMapa(prefs, _keyPresetsMatriz, mapa); } /// Returns the persisted device custom names map. Future> cargarNombresDispositivos() async { final prefs = await _resolverPrefs(); return _leerMapaStrings(prefs, _keyNombresDispositivos); } /// Persists the device custom names map. Future guardarNombresDispositivos(Map nombres) async { final prefs = await _resolverPrefs(); await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres); } /// Reads a `Map` from a SharedPreferences JSON /// key with per-entry tolerance (persistence-resilience D6): a value that /// fails to parse is skipped and logged, its sibling entries survive and /// every surviving key (including the matrix's colon-delimited /// `stationUuid:deviceId` keys) is preserved byte-for-byte. A top-level /// decode failure still degrades the whole map to empty (now logged too); /// no flag/quarantine here — EQ presets are explicit-only writes and /// trivially re-creatable (D6, intentional asymmetry vs. Alarms/Stations). Map _leerMapa( SharedPreferences prefs, String key, ) { final raw = prefs.getString(key); if (raw == null || raw.isEmpty) return {}; try { final data = Map.from(jsonDecode(raw) as Map); final resultado = parseMapaTolerante( data, (valor) => PresetEcualizador.desdeJson( Map.from(valor as Map), ), subsistema: 'ecualizador', coleccion: key, ); return resultado.validas; } catch (e) { registrarSaltoPersistencia( subsistema: 'ecualizador', detalle: key, razon: e.toString(), ); return {}; } } Future _guardarMapa( SharedPreferences prefs, String key, Map mapa, ) async { final serializado = mapa.map((k, v) => MapEntry(k, v.toJson())); await prefs.setString(key, jsonEncode(serializado)); } /// Reads a `Map` from a SharedPreferences JSON key with /// per-entry tolerance (persistence-resilience D6): a value that is not a /// String is skipped and logged, sibling entries and keys survive /// untouched. A top-level decode failure still degrades the whole map to /// empty (now logged too); no flag/quarantine here (D6). Map _leerMapaStrings(SharedPreferences prefs, String key) { final raw = prefs.getString(key); if (raw == null || raw.isEmpty) return {}; try { final data = Map.from(jsonDecode(raw) as Map); final resultado = parseMapaTolerante( data, (valor) => valor as String, subsistema: 'ecualizador', coleccion: key, ); return resultado.validas; } catch (e) { registrarSaltoPersistencia( subsistema: 'ecualizador', detalle: key, razon: e.toString(), ); return {}; } } Future _guardarMapaStrings( SharedPreferences prefs, String key, Map mapa, ) async { await prefs.setString(key, jsonEncode(mapa)); } /// Reads the single principal-preset key. Single-value (not a collection) /// so there is nothing to skip per-entry; a decode failure still falls /// back to [PresetEcualizador.flat] (unchanged behavior) but is now /// logged too (persistence-resilience D6, diagnostics). PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { final raw = prefs.getString(_keyPresetPrincipal); if (raw == null || raw.isEmpty) { return PresetEcualizador.flat; } try { return PresetEcualizador.desdeJson( Map.from(jsonDecode(raw) as Map), ); } catch (e) { registrarSaltoPersistencia( subsistema: 'ecualizador', detalle: _keyPresetPrincipal, razon: e.toString(), ); return PresetEcualizador.flat; } } /// Reads the per-station preset map with per-entry tolerance /// (persistence-resilience D6) via the same shared helper as [_leerMapa]; /// a station whose preset value fails to parse is skipped and logged, its /// siblings survive under their original uuid keys. Map _leerPresetsPorEmisora( SharedPreferences prefs, ) { final raw = prefs.getString(_keyPresetsPorEmisora); if (raw == null || raw.isEmpty) { return {}; } try { final data = Map.from(jsonDecode(raw) as Map); final resultado = parseMapaTolerante( data, (valor) => PresetEcualizador.desdeJson( Map.from(valor as Map), ), subsistema: 'ecualizador', coleccion: _keyPresetsPorEmisora, ); return resultado.validas; } catch (e) { registrarSaltoPersistencia( subsistema: 'ecualizador', detalle: _keyPresetsPorEmisora, razon: e.toString(), ); return {}; } } Future _guardarPresetsPorEmisora( SharedPreferences prefs, Map mapa, ) async { final serializado = mapa.map( (uuid, preset) => MapEntry(uuid, preset.toJson()), ); await prefs.setString(_keyPresetsPorEmisora, jsonEncode(serializado)); } }