feat(alarmas): verify native registration after a successful save

android.programar() returning without throwing was treated as proof
the OS registered the alarm -- this is exactly the gap the reported
case fell through. guardarAlarma now cross-checks a fresh native
pending-alarm count against how many alarms Dart believes are
active-with-a-next-run right after a successful schedule call, and
records a failure for the just-saved alarm when the native count
falls short.

FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a
count derived from programar()/cancelar() calls (mirroring the real
native scheduler's own registry) instead of a frozen 0, while any
test that explicitly assigns the field keeps getting exactly that
value regardless of what programar/cancelar do afterward -- verified
against the full suite, no regressions.
This commit is contained in:
2026-07-31 21:26:41 +02:00
parent c107c0e18a
commit 7722f204ca
3 changed files with 136 additions and 4 deletions
+36 -3
View File
@@ -118,6 +118,7 @@ class EstadoAlarmas extends ChangeNotifier {
); );
await android.programar(guardada); await android.programar(guardada);
await _limpiarFalloProgramacion(guardada.id); await _limpiarFalloProgramacion(guardada.id);
await _verificarRegistroNativo(guardada.id);
} catch (e) { } catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
await _registrarFalloProgramacion(alarma.id); await _registrarFalloProgramacion(alarma.id);
@@ -221,9 +222,7 @@ class EstadoAlarmas extends ChangeNotifier {
); );
_aplicar(config); _aplicar(config);
} catch (e) { } catch (e) {
debugPrint( debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e');
'[PluriWave][alarmas] registrar fallo programacion ERROR $e',
);
} }
} }
@@ -239,6 +238,40 @@ class EstadoAlarmas extends ChangeNotifier {
} }
} }
/// Verifies the OS genuinely registered [alarmaId] after a successful
/// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a
/// scheduling call that returns without throwing is not proof enough by
/// itself -- this cross-check against the native pending-alarm count is
/// exactly what would have caught the reported "alarm never rings, no
/// exception anywhere" case. Compares a FRESH native count against how
/// many alarms Dart believes are currently active-with-a-next-run; a
/// native count that falls short is recorded as a failure for the alarm
/// the user just interacted with. Never overrides an already-caught
/// programar() exception (this only runs on ITS success path).
Future<void> _verificarRegistroNativo(String alarmaId) async {
try {
final alarma = _buscarAlarma(alarmaId);
if (alarma == null ||
!alarma.activa ||
alarma.proximaProgramable == null) {
return;
}
final diag = await android.diagnostico();
_diagnostico = diag;
final esperadas =
_alarmas
.where((a) => a.activa && a.proximaProgramable != null)
.length;
if (diag.alarmasNativasPendientes < esperadas) {
_error =
'Alarma guardada, pero el sistema no confirma que quedó registrada.';
await _registrarFalloProgramacion(alarmaId);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e');
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async { Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa)); await guardarAlarma(alarma.copyWith(activa: activa));
} }
@@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually
/// registered, and say so if it is not". `android.programar` returning
/// without throwing is not proof enough by itself -- this is the check that
/// would have caught the reported case immediately (the native side can
/// silently fail to persist the registration even when the channel call
/// itself reports success).
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('guardarAlarma detecta que el conteo nativo no refleja la alarma '
'guardada, aunque android.programar no haya lanzado', () async {
// Fixed at 0 regardless of what programar() does internally --
// simulates the native side accepting the channel call but never
// actually persisting the registration.
final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'silenciosa1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('silenciosa1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
});
test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra '
'fallo alguno', () async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'sana1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('sana1'), isNull);
expect(estado.error, isNull);
});
}
+24 -1
View File
@@ -26,10 +26,27 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
bool puedeProgramarExactas = true; bool puedeProgramarExactas = true;
bool notificacionesPermitidas = true; bool notificacionesPermitidas = true;
bool puedeUsarPantallaCompleta = true; bool puedeUsarPantallaCompleta = true;
int alarmasNativasPendientes = 0;
String fabricante = 'test'; String fabricante = 'test';
int versionSdk = 35; int versionSdk = 35;
/// Ids [programar] most recently scheduled as active-with-a-next-run (kept
/// in sync with [cancelar] too), mirroring the real native scheduler's own
/// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify
/// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s
/// DEFAULT so a test that never touches that field gets a value that
/// tracks reality instead of a frozen `0` -- a test that explicitly
/// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases
/// do, to model a stale/corrupt native count on purpose) keeps getting
/// EXACTLY that value regardless of what programar/cancelar do afterward.
final _idsRegistradosNativamente = <String>{};
int? _alarmasNativasPendientesFijado;
int get alarmasNativasPendientes =>
_alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length;
set alarmasNativasPendientes(int valor) =>
_alarmasNativasPendientesFijado = valor;
/// Test-only failure switch (diagnostics screen, "intent not resolving" /// Test-only failure switch (diagnostics screen, "intent not resolving"
/// coverage): when true, every `abrir*`/`solicitar*` system-screen action /// coverage): when true, every `abrir*`/`solicitar*` system-screen action
/// below reports failure (as a real device does when a ROM lacks that /// below reports failure (as a real device does when a ROM lacks that
@@ -83,11 +100,17 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
throw StateError('fake programar failure'); throw StateError('fake programar failure');
} }
programadas.add(alarma); programadas.add(alarma);
if (alarma.activa && alarma.proximaProgramable != null) {
_idsRegistradosNativamente.add(alarma.id);
} else {
_idsRegistradosNativamente.remove(alarma.id);
}
} }
@override @override
Future<void> cancelar(String alarmaId) async { Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId); canceladas.add(alarmaId);
_idsRegistradosNativamente.remove(alarmaId);
} }
@override @override