fix(radio): quarantine corrupt custom-station files instead of silently emptying them
A single malformed custom-station entry (missing uuid/url) used to wipe the ENTIRE list on next load, and an unparseable file was treated the same as an unreadable one -- both destroyed the user's saved stations with no way to recover the original bytes. Custom stations now parse per-entry via the shared persistencia_tolerante helper (survivors kept, bad entries skipped+logged); a file that reads but fails to decode is quarantined into a `.corrupt` sidecar instead of being dropped, clearing the live path so the next add/remove starts fresh. A file that cannot be READ at the OS level is left untouched and a _customDegradado flag suppresses writes for the session -- unlike alarms, this suppression is intentionally not lifted by an explicit add/remove, since the file may still be intact on disk.
This commit is contained in:
@@ -16,6 +16,7 @@ import 'estado_busqueda.dart';
|
||||
import 'estado_ecualizador.dart';
|
||||
import 'estado_grabacion.dart';
|
||||
import 'orden_emisoras.dart';
|
||||
import '../servicios/persistencia_tolerante.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_dispositivo_audio.dart';
|
||||
import '../servicios/servicio_ecualizador.dart';
|
||||
@@ -133,6 +134,18 @@ class EstadoRadio extends ChangeNotifier {
|
||||
List<GrupoFavoritos> _gruposFavoritos = [];
|
||||
List<Emisora> _emisorasCustom = [];
|
||||
|
||||
// persistence-resilience (D5): set when the custom-stations file EXISTS
|
||||
// but could not be READ at the OS level (vs. a parse failure, which
|
||||
// quarantines the file instead -- see _ponerEnCuarentena). While true,
|
||||
// _guardarEmisorasCustom is suppressed for the rest of this session: we
|
||||
// cannot tell whether the underlying file is actually intact, so we never
|
||||
// risk clobbering it with an empty in-memory list. Unlike the Alarms
|
||||
// degraded flag, this is intentionally NOT cleared by an explicit
|
||||
// add/remove -- only the NEXT clean/partial load in _cargarEmisorasCustom
|
||||
// clears it (D5's asymmetry: a transiently-unreadable file may still be
|
||||
// intact on disk).
|
||||
bool _customDegradado = false;
|
||||
|
||||
bool _cargandoPopulares = false;
|
||||
String? _errorCarga;
|
||||
|
||||
@@ -500,27 +513,96 @@ class EstadoRadio extends ChangeNotifier {
|
||||
return File('${dir.path}/emisoras_custom.json');
|
||||
}
|
||||
|
||||
/// Loads the custom-stations file with per-entry tolerance
|
||||
/// (persistence-resilience D1/D5). Two DIFFERENT failure kinds get TWO
|
||||
/// different treatments because they carry different guarantees about
|
||||
/// whether the file itself is still intact:
|
||||
/// - the file cannot be READ at the OS level (see [_leerContenidoCustom])
|
||||
/// -> IO-fail: unknown whether the file is intact, so it is left
|
||||
/// completely untouched and [_customDegradado] suppresses writes.
|
||||
/// - the file reads fine but its top-level JSON/shape is invalid -> the
|
||||
/// bytes we DID manage to read are definitely the corrupt culprit, so
|
||||
/// they are quarantined into a `.corrupt` sidecar and the live path is
|
||||
/// cleared for the next write ([_ponerEnCuarentena]); no flag needed,
|
||||
/// the cleared path is itself the "authority restored" signal.
|
||||
/// - per-entry failures inside an otherwise-valid list are handled by the
|
||||
/// shared [parseListaTolerante] (D1): survivors are kept, no flag/
|
||||
/// quarantine at all.
|
||||
Future<void> _cargarEmisorasCustom() async {
|
||||
try {
|
||||
final archivo = await _archivoCustom();
|
||||
if (!await archivo.exists()) {
|
||||
_emisorasCustom = [];
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
final archivo = await _archivoCustom();
|
||||
final contenido = await _leerContenidoCustom(archivo);
|
||||
if (contenido == null) return; // ya resuelto: vacio o degradado por IO.
|
||||
|
||||
final data = jsonDecode(await archivo.readAsString()) as List;
|
||||
_emisorasCustom =
|
||||
data
|
||||
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
try {
|
||||
final data = jsonDecode(contenido) as List;
|
||||
final resultado = parseListaTolerante<Emisora>(
|
||||
data,
|
||||
Emisora.fromMap,
|
||||
subsistema: 'emisoras_custom',
|
||||
coleccion: 'emisoras_custom',
|
||||
);
|
||||
_emisorasCustom = resultado.validas;
|
||||
_customDegradado = false;
|
||||
} catch (e) {
|
||||
await _ponerEnCuarentena(archivo);
|
||||
_emisorasCustom = [];
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'emisoras_custom',
|
||||
detalle: archivo.path,
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reads the raw content of [archivo]; returns null when the outcome was
|
||||
/// already fully resolved here, so the caller has nothing left to parse:
|
||||
/// - the file does not exist -> healthy empty state (unchanged behavior).
|
||||
/// - `exists()`/`readAsString()` throws -> IO-fail (D5): the file is left
|
||||
/// untouched (we cannot know if it is actually intact) and
|
||||
/// [_customDegradado] suppresses [_guardarEmisorasCustom] for the rest
|
||||
/// of this session.
|
||||
Future<String?> _leerContenidoCustom(File archivo) async {
|
||||
try {
|
||||
if (!await archivo.exists()) {
|
||||
_emisorasCustom = [];
|
||||
_customDegradado = false;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
return await archivo.readAsString();
|
||||
} catch (e) {
|
||||
_emisorasCustom = [];
|
||||
_customDegradado = true;
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'emisoras_custom',
|
||||
detalle: archivo.path,
|
||||
razon: e.toString(),
|
||||
);
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves an unparseable custom-stations file out of the live path so the
|
||||
/// next add/remove starts fresh. If a `.corrupt` sidecar from a PREVIOUS
|
||||
/// quarantine already exists, that earlier payload is preserved untouched
|
||||
/// and the newly-corrupt live file is simply dropped (D5) -- a sidecar
|
||||
/// only ever holds the OLDEST unresolved quarantine, never overwritten by
|
||||
/// a newer one.
|
||||
Future<void> _ponerEnCuarentena(File archivo) async {
|
||||
final sidecar = File('${archivo.path}.corrupt');
|
||||
if (await sidecar.exists()) {
|
||||
await archivo.delete();
|
||||
} else {
|
||||
await archivo.rename(sidecar.path);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _guardarEmisorasCustom() async {
|
||||
// persistence-resilience (D5): never write while an IO-degraded read
|
||||
// means we cannot be sure the on-disk file is still intact.
|
||||
if (_customDegradado) return;
|
||||
final archivo = await _archivoCustom();
|
||||
await archivo.writeAsString(
|
||||
jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()),
|
||||
|
||||
Reference in New Issue
Block a user