fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s

A single malformed alarm entry (bad/missing id, wrong type) used to
discard the ENTIRE persisted list on next load, and a fully unparseable
payload let the periodic recalculation silently overwrite it with an
empty one -- both destroyed valid alarms with no user action.

Adds a shared per-entry tolerant-parse helper (persistencia_tolerante.dart)
that skips and logs only the bad entry; ServicioAlarmas now normalizes its
cached raw after a partial load (no dirty-guard thrash) and sets a
degraded-read flag after a total decode failure that suppresses automatic
writes until a good read or an explicit user mutation restores authority.
This commit is contained in:
2026-07-11 12:27:39 +02:00
parent 23ab3494a7
commit 65c1ac2085
5 changed files with 514 additions and 24 deletions
+8
View File
@@ -142,6 +142,14 @@ class AlarmaMusical {
'actualizadaEn': actualizadaEn?.toIso8601String(), 'actualizadaEn': actualizadaEn?.toIso8601String(),
}; };
// persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast
// on purpose -- a missing/wrong-type id must throw, not fall back to a
// fabricated value. Callers that read persisted collections (e.g.
// ServicioAlarmas._parsear via persistencia_tolerante.dart) wrap each
// fromJson call in a per-entry try: a thrown entry is skipped and
// logged, never replacing this required field with a sentinel/fabricated
// id ("skip-never-fabricate"). This boundary also tolerates any future
// required-field break the same way, not just id.
factory AlarmaMusical.fromJson(Map<String, dynamic> json) { factory AlarmaMusical.fromJson(Map<String, dynamic> json) {
return AlarmaMusical( return AlarmaMusical(
id: json['id'] as String, id: json['id'] as String,
+109
View File
@@ -0,0 +1,109 @@
import 'package:flutter/foundation.dart';
/// Shared per-entry tolerant-parse primitives (persistence-resilience D1).
///
/// Every subsystem that persists a collection (alarms, custom stations, EQ
/// presets/device names) decodes its own top-level payload first — total
/// top-level decode failure is a subsystem-specific policy (see each
/// caller's own degraded-read handling) and is intentionally OUT of scope
/// here. What IS shared is what happens once the container decoded fine but
/// one entry inside it did not: that single entry must be skipped and
/// logged, never allowed to take its valid siblings down with it, and never
/// replaced by a fabricated identity.
/// Result of a tolerant, per-entry parse pass over a persisted collection.
///
/// [validas] holds whatever survived (a `List<T>` for
/// [parseListaTolerante], a `Map<String, V>` for [parseMapaTolerante]).
/// [saltadas] counts how many entries were skipped because they failed to
/// parse — callers use this to detect a PARTIAL read (>0) versus a fully
/// healthy one (0) without inspecting [validas] itself.
typedef ResultadoTolerante<T> = ({T validas, int saltadas});
/// Parses each entry of the ALREADY-decoded [datos] independently via
/// [parser]; an entry that is not a [Map], or whose [parser] throws (e.g. a
/// missing/invalid identity field), is skipped and logged instead of
/// aborting the whole parse. Order of survivors matches [datos]' order.
///
/// [datos] must already be the decoded top-level list (e.g. the result of
/// `jsonDecode(raw) as Map<String, dynamic>` -> `map['alarmas'] as List?`).
/// This helper never calls `jsonDecode` itself — total-failure policy for
/// an unparseable top-level payload differs per subsystem and stays with
/// the caller.
ResultadoTolerante<List<T>> parseListaTolerante<T>(
List<dynamic>? datos,
T Function(Map<String, dynamic> entrada) parser, {
required String subsistema,
required String coleccion,
}) {
if (datos == null || datos.isEmpty) {
return (validas: <T>[], saltadas: 0);
}
final validas = <T>[];
var saltadas = 0;
for (var indice = 0; indice < datos.length; indice++) {
try {
final entrada = datos[indice];
if (entrada is! Map) {
throw const FormatException('la entrada no es un mapa');
}
validas.add(parser(Map<String, dynamic>.from(entrada)));
} catch (e) {
saltadas++;
registrarSaltoPersistencia(
subsistema: subsistema,
detalle: '$coleccion[$indice]',
razon: e.toString(),
);
}
}
return (validas: validas, saltadas: saltadas);
}
/// Parses each VALUE of the ALREADY-decoded [datos] independently via
/// [parser]; a value whose [parser] throws is skipped and logged, and only
/// its key is dropped from the result. Keys of surviving entries are
/// preserved byte-for-byte (needed by callers whose keys carry meaning,
/// e.g. EQ's colon-delimited `stationUuid:deviceId` matrix keys).
///
/// [datos] must already be the decoded top-level map (e.g. the result of
/// `jsonDecode(raw) as Map<String, dynamic>?`). This helper never calls
/// `jsonDecode` itself — see [parseListaTolerante] for the same rationale.
ResultadoTolerante<Map<String, V>> parseMapaTolerante<V>(
Map<String, dynamic>? datos,
V Function(dynamic valor) parser, {
required String subsistema,
required String coleccion,
}) {
if (datos == null || datos.isEmpty) {
return (validas: <String, V>{}, saltadas: 0);
}
final validas = <String, V>{};
var saltadas = 0;
for (final entrada in datos.entries) {
try {
validas[entrada.key] = parser(entrada.value);
} catch (e) {
saltadas++;
registrarSaltoPersistencia(
subsistema: subsistema,
detalle: '$coleccion[${entrada.key}]',
razon: e.toString(),
);
}
}
return (validas: validas, saltadas: saltadas);
}
/// Logs a persistence skip or degraded read with enough context to
/// diagnose it later: which subsystem, which entry/key/reason. Developer
/// facing only — this MUST NOT surface as user-visible copy or gain an
/// l10n key (persistence-resilience spec, "Diagnostics are developer-facing
/// only").
void registrarSaltoPersistencia({
required String subsistema,
required String detalle,
required String razon,
}) {
debugPrint('[PluriWave][persistencia] $subsistema $detalle: $razon');
}
+73 -24
View File
@@ -5,6 +5,7 @@ import 'package:uuid/uuid.dart';
import '../modelos/alarma_musical.dart'; import '../modelos/alarma_musical.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import 'persistencia_tolerante.dart';
import 'servicio_programacion_alarmas.dart'; import 'servicio_programacion_alarmas.dart';
class ConfiguracionAlarmas { class ConfiguracionAlarmas {
@@ -41,6 +42,14 @@ class ServicioAlarmas {
String? _cacheRaw; String? _cacheRaw;
Future<void> _cola = Future<void>.value(); Future<void> _cola = Future<void>.value();
// persistence-resilience (D4): set when the top-level payload could not
// be decoded at all (vs. an individual entry inside it). While true, the
// AUTOMATIC writer (recalcularTodas) must never reach disk -- only an
// explicit user mutation (which always funnels through _guardar) may
// overwrite a payload we could not read. Cleared by _guardar and by the
// next successful (even if partial) read.
bool _lecturaAlarmasDegradada = false;
Future<T> _enCola<T>(Future<T> Function() accion) { Future<T> _enCola<T>(Future<T> Function() accion) {
final resultado = _cola.then((_) => accion()); final resultado = _cola.then((_) => accion());
_cola = resultado.then((_) {}, onError: (_) {}); _cola = resultado.then((_) {}, onError: (_) {});
@@ -63,12 +72,29 @@ class ServicioAlarmas {
final raw = prefs.getString(_keyConfig); final raw = prefs.getString(_keyConfig);
final config = _parsear(raw); final config = _parsear(raw);
_cache = config; _cache = config;
_cacheRaw = raw;
return config; return config;
} }
/// Parses the persisted [raw] payload with per-entry tolerance
/// (persistence-resilience D1/D2) and updates [_cacheRaw] /
/// [_lecturaAlarmasDegradada] as a side effect, since each outcome needs
/// a DIFFERENT cached-raw value:
/// - top-level decode failure (bad JSON, or any container-level field of
/// the wrong shape) -> DEGRADED: empty config, [_cacheRaw] keeps the
/// corrupt [raw] untouched, [_lecturaAlarmasDegradada] set so
/// `recalcularTodas` never overwrites it (D4).
/// - decodes, but one or more entries are malformed -> PARTIAL: only the
/// survivors are kept, [_cacheRaw] is normalized to their own
/// serialization -- NOT the corrupt raw (D3) -- so the automatic
/// writer's dirty-guard compares against a coherent baseline instead of
/// re-firing on every 60s tick.
/// - fully healthy payload -> unchanged behavior; clears the flag if a
/// previous read had set it (suppression lifts on next successful
/// read).
ConfiguracionAlarmas _parsear(String? raw) { ConfiguracionAlarmas _parsear(String? raw) {
if (raw == null || raw.trim().isEmpty) { if (raw == null || raw.trim().isEmpty) {
_lecturaAlarmasDegradada = false;
_cacheRaw = raw;
return const ConfiguracionAlarmas( return const ConfiguracionAlarmas(
alarmas: [], alarmas: [],
vacaciones: [], vacaciones: [],
@@ -77,30 +103,44 @@ class ServicioAlarmas {
} }
try { try {
final data = jsonDecode(raw) as Map<String, dynamic>; final data = jsonDecode(raw) as Map<String, dynamic>;
return ConfiguracionAlarmas( final alarmas = parseListaTolerante<AlarmaMusical>(
alarmas: data['alarmas'] as List?,
(data['alarmas'] as List? ?? const []) AlarmaMusical.fromJson,
.whereType<Map>() subsistema: 'alarmas',
.map( coleccion: 'alarmas',
(e) => AlarmaMusical.fromJson(Map<String, dynamic>.from(e)), );
) final vacaciones = parseListaTolerante<RangoVacaciones>(
.toList(), data['vacaciones'] as List?,
vacaciones: RangoVacaciones.fromJson,
(data['vacaciones'] as List? ?? const []) subsistema: 'alarmas',
.whereType<Map>() coleccion: 'vacaciones',
.map( );
(e) => RangoVacaciones.fromJson(Map<String, dynamic>.from(e)), final excepciones = parseListaTolerante<ExcepcionAlarma>(
) data['excepciones'] as List?,
.toList(), ExcepcionAlarma.fromJson,
excepciones: subsistema: 'alarmas',
(data['excepciones'] as List? ?? const []) coleccion: 'excepciones',
.whereType<Map>() );
.map( final config = ConfiguracionAlarmas(
(e) => ExcepcionAlarma.fromJson(Map<String, dynamic>.from(e)), alarmas: alarmas.validas,
) vacaciones: vacaciones.validas,
.toList(), excepciones: excepciones.validas,
);
_lecturaAlarmasDegradada = false;
final huboSaltos =
alarmas.saltadas > 0 ||
vacaciones.saltadas > 0 ||
excepciones.saltadas > 0;
_cacheRaw = huboSaltos ? _serializar(config) : raw;
return config;
} catch (e) {
_lecturaAlarmasDegradada = true;
_cacheRaw = raw;
registrarSaltoPersistencia(
subsistema: 'alarmas',
detalle: _keyConfig,
razon: e.toString(),
); );
} catch (_) {
return const ConfiguracionAlarmas( return const ConfiguracionAlarmas(
alarmas: [], alarmas: [],
vacaciones: [], vacaciones: [],
@@ -200,6 +240,11 @@ class ServicioAlarmas {
Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async { Future<ConfiguracionAlarmas> recalcularTodas() => _enCola(() async {
final config = await _configActual(); final config = await _configActual();
// persistence-resilience (D4): a degraded top-level read must never
// let this AUTOMATIC writer reach disk -- only an explicit user
// mutation (guardarAlarma/eliminarAlarma/etc., via _guardar) may
// persist over a payload we could not read.
if (_lecturaAlarmasDegradada) return config;
final alarmas = _recalcularLista( final alarmas = _recalcularLista(
config.alarmas, config.alarmas,
config.vacaciones, config.vacaciones,
@@ -427,6 +472,10 @@ class ServicioAlarmas {
await prefs.setString(_keyConfig, serializado); await prefs.setString(_keyConfig, serializado);
_cache = config; _cache = config;
_cacheRaw = serializado; _cacheRaw = serializado;
// persistence-resilience (D4): every explicit mutation funnels through
// here, so this is where write authority is restored after a degraded
// read -- user intent wins over a suppressed automatic writer.
_lecturaAlarmasDegradada = false;
} }
String _serializar(ConfiguracionAlarmas config) => jsonEncode({ String _serializar(ConfiguracionAlarmas config) => jsonEncode({
@@ -0,0 +1,141 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/persistencia_tolerante.dart';
void main() {
late DebugPrintCallback debugPrintOriginal;
late List<String> logs;
setUp(() {
debugPrintOriginal = debugPrint;
logs = [];
debugPrint = (String? message, {int? wrapWidth}) {
if (message != null) logs.add(message);
};
});
tearDown(() {
debugPrint = debugPrintOriginal;
});
group('parseListaTolerante', () {
test(
'salta 1 entrada invalida entre 3 validas, retorna las 3 sobrevivientes y loguea el salto',
() {
final List<dynamic> datos = [
{'valor': 1},
{'valor': 2},
'entrada-invalida', // not a Map -> must be skipped, not thrown
{'valor': 3},
];
final resultado = parseListaTolerante<int>(
datos,
(entrada) => entrada['valor'] as int,
subsistema: 'prueba',
coleccion: 'items',
);
expect(resultado.validas, [1, 2, 3]);
expect(resultado.saltadas, 1);
expect(
logs.any((l) => l.startsWith('[PluriWave][persistencia]')),
isTrue,
reason: 'debe loguear el salto con el prefijo de diagnostico',
);
},
);
test(
'entrada valida cuyo parser lanza tambien se salta y se loguea (no solo tipos incorrectos)',
() {
final List<dynamic> datos = [
{'valor': 1},
{'otraClave': 'sin valor'}, // Map valido, pero el parser fallara
];
final resultado = parseListaTolerante<int>(
datos,
(entrada) => entrada['valor'] as int,
subsistema: 'prueba',
coleccion: 'items',
);
expect(resultado.validas, [1]);
expect(resultado.saltadas, 1);
},
);
test('datos null retorna sobrevivientes vacios sin loguear', () {
final resultado = parseListaTolerante<int>(
null,
(entrada) => entrada['valor'] as int,
subsistema: 'prueba',
coleccion: 'items',
);
expect(resultado.validas, isEmpty);
expect(resultado.saltadas, 0);
expect(logs, isEmpty);
});
});
group('parseMapaTolerante', () {
test(
'salta 1 VALOR invalido entre 3 entradas, preserva las claves originales',
() {
final Map<String, dynamic> datos = {
'k1': {'valor': 10},
'k2': 'valor-no-parseable',
'k3': {'valor': 30},
'k4': {'valor': 40},
};
final resultado = parseMapaTolerante<int>(
datos,
(valor) => (valor as Map)['valor'] as int,
subsistema: 'prueba',
coleccion: 'mapa',
);
expect(resultado.validas, {'k1': 10, 'k3': 30, 'k4': 40});
expect(resultado.saltadas, 1);
expect(
resultado.validas.keys,
containsAll(['k1', 'k3', 'k4']),
reason: 'las claves originales de las entradas sobrevivientes no deben alterarse',
);
},
);
});
group('degradado total', () {
test(
'ambos helpers en entrada 100% invalida retornan sobrevivientes vacios sin lanzar excepcion, y loguean',
() {
final List<dynamic> datosLista = ['x', 'y', 'z'];
final resultadoLista = parseListaTolerante<int>(
datosLista,
(entrada) => entrada['valor'] as int,
subsistema: 'prueba',
coleccion: 'items',
);
expect(resultadoLista.validas, isEmpty);
expect(resultadoLista.saltadas, 3);
expect(logs, isNotEmpty);
logs.clear();
final Map<String, dynamic> datosMapa = {'k1': 'x', 'k2': 'y'};
final resultadoMapa = parseMapaTolerante<int>(
datosMapa,
(valor) => (valor as Map)['valor'] as int,
subsistema: 'prueba',
coleccion: 'mapa',
);
expect(resultadoMapa.validas, isEmpty);
expect(resultadoMapa.saltadas, 2);
expect(logs, isNotEmpty);
},
);
});
}
@@ -0,0 +1,183 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _claveAlarmas = 'alarmas_musicales_v1';
/// SharedPreferences spy: only the members ServicioAlarmas touches are
/// implemented; everything else throws via noSuchMethod. Mirrors
/// servicio_alarmas_cache_test.dart's _PrefsEspia, plus initial-value
/// seeding so corruption scenarios can be set up directly.
class _PrefsEspia implements SharedPreferences {
_PrefsEspia({Map<String, Object>? inicial}) : _datos = {...?inicial};
final Map<String, Object> _datos;
int escriturasString = 0;
int lecturasString = 0;
@override
String? getString(String key) {
lecturasString++;
return _datos[key] as String?;
}
@override
Future<bool> setString(String key, String value) async {
escriturasString++;
_datos[key] = value;
return true;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Map<String, dynamic> _alarmaValida(String id, int hora) => {
'id': id,
'nombre': 'Alarma $id',
'hora': hora,
'minuto': 0,
'tipoProgramacion': 'diaria',
'diasSemana': <int>[],
};
void main() {
group(
'ServicioAlarmas — lectura tolerante y guardia de degradacion '
'(persistence-resilience)',
() {
test(
'carga parcial (2 validas + 1 con id de tipo incorrecto): '
'recalcularTodas no reescribe en cada tick (D3, no-thrash)',
() async {
final raw = jsonEncode({
'alarmas': [
_alarmaValida('a1', 7),
_alarmaValida('a2', 8),
// id con tipo incorrecto -> AlarmaMusical.fromJson lanza al
// hacer `json['id'] as String`; debe saltarse, no tumbar todo.
{..._alarmaValida('a3', 9), 'id': 42},
],
'vacaciones': [],
'excepciones': [],
});
final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw});
final ahora = DateTime(2026, 6, 11, 6, 0);
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora);
final config = await servicio.cargar();
expect(config.alarmas, hasLength(2));
expect(config.alarmas.map((a) => a.id).toSet(), {'a1', 'a2'});
final escriturasBase = prefs.escriturasString;
await servicio.recalcularTodas();
await servicio.recalcularTodas();
expect(
prefs.escriturasString - escriturasBase,
lessThanOrEqualTo(1),
reason:
'la cache normalizada evita que el dirty-guard se dispare '
'en cada tick de 60s (D3)',
);
},
);
test(
'carga totalmente corrupta: cargar() queda vacio, recalcularTodas '
'no escribe, el raw original persiste intacto (D4)',
() async {
const raw = '{bad';
final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw});
final servicio = ServicioAlarmas(
prefs: prefs,
reloj: () => DateTime(2026, 6, 11, 6, 0),
);
final config = await servicio.cargar();
expect(config.alarmas, isEmpty);
final escriturasBase = prefs.escriturasString;
await servicio.recalcularTodas();
expect(prefs.escriturasString, escriturasBase);
expect(
prefs.getString(_claveAlarmas),
raw,
reason:
'el raw corrupto original debe preservarse intacto en '
'disco mientras la lectura este degradada',
);
},
);
test(
'guardarAlarma tras una lectura degradada escribe una vez y '
'restaura la autoridad de escritura (D4)',
() async {
const raw = '{bad';
final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw});
var ahora = DateTime(2026, 6, 11, 6, 0);
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora);
await servicio.cargar();
final escriturasBase = prefs.escriturasString;
final nueva = servicio.crearAlarma(
nombre: 'Nueva',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
await servicio.guardarAlarma(nueva);
expect(prefs.escriturasString, escriturasBase + 1);
expect(prefs.getString(_claveAlarmas), isNot(raw));
// Un cambio real de agenda despues del guardado explicito debe
// volver a escribir con normalidad -- la bandera de degradacion
// no puede quedar pegada para siempre.
final escriturasTrasGuardar = prefs.escriturasString;
ahora = DateTime(2026, 6, 12, 8, 0);
await servicio.recalcularTodas();
expect(prefs.escriturasString, escriturasTrasGuardar + 1);
},
);
test(
'entrada sin id se omite y jamas se le fabrica uno nuevo (D2)',
() async {
final raw = jsonEncode({
'alarmas': [
{
'nombre': 'Sin id',
'hora': 6,
'minuto': 0,
'tipoProgramacion': 'diaria',
'diasSemana': <int>[],
},
_alarmaValida('a1', 7),
_alarmaValida('a2', 8),
],
'vacaciones': [],
'excepciones': [],
});
final prefs = _PrefsEspia(inicial: {_claveAlarmas: raw});
final servicio = ServicioAlarmas(
prefs: prefs,
reloj: () => DateTime(2026, 6, 11, 6, 0),
);
final config = await servicio.cargar();
expect(config.alarmas, hasLength(2));
expect(config.alarmas.map((a) => a.id).toSet(), {'a1', 'a2'});
},
);
},
);
}