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_ecualizador.dart';
|
||||||
import 'estado_grabacion.dart';
|
import 'estado_grabacion.dart';
|
||||||
import 'orden_emisoras.dart';
|
import 'orden_emisoras.dart';
|
||||||
|
import '../servicios/persistencia_tolerante.dart';
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
import '../servicios/servicio_dispositivo_audio.dart';
|
import '../servicios/servicio_dispositivo_audio.dart';
|
||||||
import '../servicios/servicio_ecualizador.dart';
|
import '../servicios/servicio_ecualizador.dart';
|
||||||
@@ -133,6 +134,18 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
List<GrupoFavoritos> _gruposFavoritos = [];
|
List<GrupoFavoritos> _gruposFavoritos = [];
|
||||||
List<Emisora> _emisorasCustom = [];
|
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;
|
bool _cargandoPopulares = false;
|
||||||
String? _errorCarga;
|
String? _errorCarga;
|
||||||
|
|
||||||
@@ -500,27 +513,96 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
return File('${dir.path}/emisoras_custom.json');
|
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 {
|
Future<void> _cargarEmisorasCustom() async {
|
||||||
try {
|
final archivo = await _archivoCustom();
|
||||||
final archivo = await _archivoCustom();
|
final contenido = await _leerContenidoCustom(archivo);
|
||||||
if (!await archivo.exists()) {
|
if (contenido == null) return; // ya resuelto: vacio o degradado por IO.
|
||||||
_emisorasCustom = [];
|
|
||||||
notifyListeners();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = jsonDecode(await archivo.readAsString()) as List;
|
try {
|
||||||
_emisorasCustom =
|
final data = jsonDecode(contenido) as List;
|
||||||
data
|
final resultado = parseListaTolerante<Emisora>(
|
||||||
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
data,
|
||||||
.toList();
|
Emisora.fromMap,
|
||||||
} catch (_) {
|
subsistema: 'emisoras_custom',
|
||||||
|
coleccion: 'emisoras_custom',
|
||||||
|
);
|
||||||
|
_emisorasCustom = resultado.validas;
|
||||||
|
_customDegradado = false;
|
||||||
|
} catch (e) {
|
||||||
|
await _ponerEnCuarentena(archivo);
|
||||||
_emisorasCustom = [];
|
_emisorasCustom = [];
|
||||||
|
registrarSaltoPersistencia(
|
||||||
|
subsistema: 'emisoras_custom',
|
||||||
|
detalle: archivo.path,
|
||||||
|
razon: e.toString(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
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 {
|
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();
|
final archivo = await _archivoCustom();
|
||||||
await archivo.writeAsString(
|
await archivo.writeAsString(
|
||||||
jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()),
|
jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()),
|
||||||
|
|||||||
@@ -370,6 +370,215 @@ void main() {
|
|||||||
expect(estado.listaFavoritos.first.grupoFavoritosId, 'sin_asignar');
|
expect(estado.listaFavoritos.first.grupoFavoritosId, 'sin_asignar');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group(
|
||||||
|
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
|
||||||
|
'degradacion (persistence-resilience)',
|
||||||
|
() {
|
||||||
|
test(
|
||||||
|
'entradas invalidas se omiten sin perder las validas ni fabricar '
|
||||||
|
'uuid (D5 parcial)',
|
||||||
|
() async {
|
||||||
|
final archivo = await _crearArchivoCustomRaw(
|
||||||
|
jsonEncode([
|
||||||
|
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
|
||||||
|
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
|
||||||
|
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
|
||||||
|
{'uuid': 'custom-3', 'nombre': 'Sin url'},
|
||||||
|
// falta 'uuid' -> Emisora.fromMap lanza.
|
||||||
|
{'nombre': 'Sin uuid', 'url': 'http://d'},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
resolverArchivoCustom: () async => archivo,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await estado.inicializar();
|
||||||
|
|
||||||
|
expect(estado.emisorasCustom, hasLength(2));
|
||||||
|
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
|
||||||
|
'custom-1',
|
||||||
|
'custom-2',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'JSON invalido al nivel superior pone en cuarentena el archivo '
|
||||||
|
'original (D5 parse-fail)',
|
||||||
|
() async {
|
||||||
|
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
resolverArchivoCustom: () async => archivo,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await estado.inicializar();
|
||||||
|
|
||||||
|
expect(estado.emisorasCustom, isEmpty);
|
||||||
|
final sidecar = File('${archivo.path}.corrupt');
|
||||||
|
expect(await sidecar.exists(), isTrue);
|
||||||
|
expect(await sidecar.readAsString(), '{bad');
|
||||||
|
expect(await archivo.exists(), isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'agregar tras la cuarentena escribe solo la nueva emisora y no '
|
||||||
|
'toca el sidecar (D5, autoridad de escritura restaurada)',
|
||||||
|
() async {
|
||||||
|
final archivo = await _crearArchivoCustomRaw('{bad');
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
resolverArchivoCustom: () async => archivo,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
await estado.inicializar();
|
||||||
|
final sidecar = File('${archivo.path}.corrupt');
|
||||||
|
final sidecarPrevio = await sidecar.readAsString();
|
||||||
|
|
||||||
|
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
|
||||||
|
await estado.agregarEmisoraCustom(nueva);
|
||||||
|
|
||||||
|
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
|
||||||
|
final contenidoVivo =
|
||||||
|
jsonDecode(await archivo.readAsString()) as List;
|
||||||
|
expect(contenidoVivo, hasLength(1));
|
||||||
|
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
|
||||||
|
expect(await sidecar.readAsString(), sidecarPrevio);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'fallo de IO al leer suprime la escritura y no se restaura con un '
|
||||||
|
'alta explicita (D5 IO-fail)',
|
||||||
|
() async {
|
||||||
|
final espia = _ArchivoEspia(
|
||||||
|
path: '/fake/emisoras_custom.json',
|
||||||
|
exists: () async => true,
|
||||||
|
readAsString:
|
||||||
|
() async => throw const FileSystemException(
|
||||||
|
'fallo simulado de lectura',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
resolverArchivoCustom: () async => espia,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await estado.inicializar();
|
||||||
|
expect(estado.emisorasCustom, isEmpty);
|
||||||
|
|
||||||
|
await estado.agregarEmisoraCustom(
|
||||||
|
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
estado.emisorasCustom.map((e) => e.uuid),
|
||||||
|
contains('nueva-x'),
|
||||||
|
);
|
||||||
|
expect(espia.writeAsStringCalls, 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
|
||||||
|
'archivo vivo (D5)',
|
||||||
|
() async {
|
||||||
|
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
|
||||||
|
final sidecar = File('${archivo.path}.corrupt');
|
||||||
|
await sidecar.writeAsString('contenido-previo-X');
|
||||||
|
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
resolverArchivoCustom: () async => archivo,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await estado.inicializar();
|
||||||
|
|
||||||
|
expect(await sidecar.readAsString(), 'contenido-previo-X');
|
||||||
|
expect(await archivo.exists(), isFalse);
|
||||||
|
expect(estado.emisorasCustom, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [File] spy: only the members `_cargarEmisorasCustom`/
|
||||||
|
/// `_guardarEmisorasCustom` touch are implemented (with injectable
|
||||||
|
/// overrides); everything else throws via noSuchMethod. Mirrors
|
||||||
|
/// servicio_alarmas_corrupcion_test.dart's `_PrefsEspia` pattern, adapted
|
||||||
|
/// to force an IO failure independent of the host OS (persistence-
|
||||||
|
/// resilience D5).
|
||||||
|
class _ArchivoEspia implements File {
|
||||||
|
_ArchivoEspia({
|
||||||
|
required String path,
|
||||||
|
Future<bool> Function()? exists,
|
||||||
|
Future<String> Function()? readAsString,
|
||||||
|
Future<File> Function(String contenido)? writeAsString,
|
||||||
|
Future<File> Function(String nuevoPath)? rename,
|
||||||
|
}) : _path = path,
|
||||||
|
_existsImpl = exists,
|
||||||
|
_readAsStringImpl = readAsString,
|
||||||
|
_writeAsStringImpl = writeAsString,
|
||||||
|
_renameImpl = rename;
|
||||||
|
|
||||||
|
final String _path;
|
||||||
|
final Future<bool> Function()? _existsImpl;
|
||||||
|
final Future<String> Function()? _readAsStringImpl;
|
||||||
|
final Future<File> Function(String contenido)? _writeAsStringImpl;
|
||||||
|
final Future<File> Function(String nuevoPath)? _renameImpl;
|
||||||
|
|
||||||
|
int writeAsStringCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get path => _path;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> exists() => (_existsImpl ?? () async => true)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> readAsString({Encoding encoding = utf8}) =>
|
||||||
|
(_readAsStringImpl ?? () async => '')();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<File> writeAsString(
|
||||||
|
String contents, {
|
||||||
|
FileMode mode = FileMode.write,
|
||||||
|
Encoding encoding = utf8,
|
||||||
|
bool flush = false,
|
||||||
|
}) {
|
||||||
|
writeAsStringCalls++;
|
||||||
|
return (_writeAsStringImpl ?? (_) async => this)(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<File> rename(String newPath) =>
|
||||||
|
(_renameImpl ?? (_) async => this)(newPath);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AudioControlado extends ServicioAudio {
|
class _AudioControlado extends ServicioAudio {
|
||||||
@@ -416,3 +625,14 @@ Future<File> _crearArchivoCustom(List<Emisora> emisoras) async {
|
|||||||
);
|
);
|
||||||
return archivo;
|
return archivo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes RAW (already-encoded) [contenido] to a fresh temp
|
||||||
|
/// `emisoras_custom.json` -- unlike [_crearArchivoCustom], this bypasses
|
||||||
|
/// `Emisora.toMap` so tests can seed malformed/partially-invalid JSON that
|
||||||
|
/// a real Emisora could never produce (persistence-resilience D5).
|
||||||
|
Future<File> _crearArchivoCustomRaw(String contenido) async {
|
||||||
|
final dir = await Directory.systemTemp.createTemp('pluriwave-test-');
|
||||||
|
final archivo = File('${dir.path}/emisoras_custom.json');
|
||||||
|
await archivo.writeAsString(contenido);
|
||||||
|
return archivo;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user