fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes, only uninstall silenced it) plus systematic hardening of every stop path. Native (Kotlin): - Verified stop: stopActiveAlarm now derives its result from the real post-teardown state (companion instance + synchronous stopEverything + activeRingingId check) instead of reporting unconditional success. - Atomic teardown: every stop path (stop action, notification button, snooze, missed, onDestroy, startForeground failure) funnels through one stopEverything() covering audio, wakelock, notification, foreground state and firing-record cleanup; player.release() guarded. - Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a FIRED->MISSED transition with a localized missed-alarm notification; repeating alarms keep their native rearm, deleted alarms never produce ghost MISSED notifications. - Durable firing record with onStartCommand re-validation (resurrection guard) and boot-time stale cleanup; firing records cleared on every refuse/mismatch/cancel path. - New notification-only dismissal channel (dismissAlarmNotificationOnly) so UI-level dedup can never kill a live ring's audio. Flutter (Dart): - Stop/disable/edit/delete of a ringing alarm always attempt to silence it; on native-query failure the stop falls back toward silence via the id-scoped legacy stop. - Verified-stop results surface failures: the ringing screen keeps dismiss-by-design on success, but on a verified failure it stays up with a persistent force-stop banner (guarded against double-dismiss) and auto-dismisses if the ring ends externally (missed/notification). - Missed events sync alarm bookkeeping without opening the ringing UI. - 4 new l10n keys translated across all 13 locales (ARB guard green). 550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds (2 deterministic + 1 refuter-corroborated critical fixed); formal gentle-ai receipt waived by maintainer authorization (correction scope legitimately exceeded the frozen genesis paths). On-device QA checklist in openspec/changes/alarm-system-overhaul/tasks.md pending before archive.
This commit is contained in:
@@ -54,6 +54,10 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
bool _cargando = false;
|
||||
String? _error;
|
||||
|
||||
/// Last alarm id recorded as MISSED (RES-1): lets the ringing screen
|
||||
/// detect an external end-of-ring for its own alarm and reconcile.
|
||||
String? ultimaAlarmaPerdidaId;
|
||||
|
||||
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
|
||||
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
|
||||
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
|
||||
@@ -100,6 +104,10 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
|
||||
);
|
||||
// Mutation-while-ringing stop guard (SS-1a/SS-1b): fires BEFORE the save
|
||||
// persists so an edit/toggle-off of the currently-ringing alarm always
|
||||
// silences it first.
|
||||
await _detenerSiEstaSonando(alarma.id);
|
||||
final config = await servicio.guardarAlarma(alarma);
|
||||
_aplicar(config);
|
||||
try {
|
||||
@@ -156,11 +164,40 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
debugPrint('[PluriWave][alarmas] eliminar id=$id');
|
||||
final config = await servicio.eliminarAlarma(id);
|
||||
_aplicar(config);
|
||||
await android.detenerSonidoNativo(id);
|
||||
// Deleting the ringing alarm stops audio (SS-1c, regression lock): the
|
||||
// centralized guard runs before cancelar, same as guardarAlarma.
|
||||
await _detenerSiEstaSonando(id);
|
||||
await android.cancelar(id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Centralized mutation-while-ringing stop guard (Decision 5): every
|
||||
/// mutation of the currently-ringing alarm routes through this ONE check
|
||||
/// instead of per-call-site logic, so a mutation of a DIFFERENT (non-
|
||||
/// ringing) alarm never touches the live ring (SS-1d).
|
||||
Future<void> _detenerSiEstaSonando(String id) async {
|
||||
try {
|
||||
final sonando = await android.alarmaSonandoId();
|
||||
if (sonando == id) {
|
||||
await android.detenerSonidoActivo();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] detenerSiEstaSonando ERROR $e');
|
||||
// Fail-toward-silence (Finding 2, eliminarAlarma regression): a failed
|
||||
// query must not silently skip the stop when the alarm might genuinely
|
||||
// be ringing. Fall back to the id-scoped legacy stop (the native side
|
||||
// no-ops safely on a mismatch) inside its own try/catch so this outer
|
||||
// flow (guardarAlarma/eliminarAlarma) always proceeds regardless.
|
||||
try {
|
||||
await android.detenerSonidoNativo(id);
|
||||
} catch (fallbackError) {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] detenerSiEstaSonando fallback ERROR $fallbackError',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
|
||||
await guardarAlarma(alarma.copyWith(activa: activa));
|
||||
}
|
||||
@@ -271,6 +308,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
|
||||
Future<void> finalizarEjecucion(String alarmaId) async {
|
||||
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
|
||||
_error = null;
|
||||
final alarma = _buscarAlarma(alarmaId);
|
||||
final ejecucion =
|
||||
alarma?.snoozeOrigen ??
|
||||
@@ -278,12 +316,34 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
alarma?.snoozeHasta ??
|
||||
DateTime.now();
|
||||
await android.ocultarNotificacionAlarma(alarmaId);
|
||||
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
|
||||
// id-agnostic fail-safe stop directly (it always targets whatever is
|
||||
// ringing). `detenido` reflects the VERIFIED native teardown state
|
||||
// (activeRingingId cleared same-process after a synchronous stop), not a
|
||||
// literal dispatch acknowledgement, so a genuine failure is never
|
||||
// swallowed.
|
||||
final resultado = await android.detenerSonidoActivo();
|
||||
if (!resultado.detenido) {
|
||||
_error = 'No se pudo confirmar que la alarma dejo de sonar.';
|
||||
}
|
||||
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
|
||||
_aplicar(config);
|
||||
await _sincronizarTodas();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Retryable force-stop affordance (SS-3b): re-invokes the same fail-safe
|
||||
/// stop; success clears the recorded failure, another failure keeps it.
|
||||
Future<void> forzarDetencion(String alarmaId) async {
|
||||
debugPrint('[PluriWave][alarmas] forzar detencion id=$alarmaId');
|
||||
final resultado = await android.detenerSonidoActivo();
|
||||
_error =
|
||||
resultado.detenido
|
||||
? null
|
||||
: 'No se pudo detener la alarma. Intentalo de nuevo.';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
|
||||
final nuevos = [..._vacaciones, rango];
|
||||
await guardarVacaciones(nuevos);
|
||||
@@ -319,6 +379,10 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
await _registrarCancelacionSnoozeNativa(evento);
|
||||
return;
|
||||
}
|
||||
if (evento.accion == EventoAlarmaAndroid.accionMissed) {
|
||||
await _registrarEjecucionPerdida(evento);
|
||||
return;
|
||||
}
|
||||
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
|
||||
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
|
||||
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
|
||||
@@ -366,6 +430,29 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a native auto-silence (MISSED) transition (Phase 6): the native
|
||||
/// scheduler already rearmed the next occurrence (repeating) or left it
|
||||
/// disabled (one-shot) at fire time, so this only marks the occurrence
|
||||
/// handled -- it MUST NOT call android.programar again.
|
||||
Future<void> _registrarEjecucionPerdida(EventoAlarmaAndroid evento) async {
|
||||
if (evento.alarmaId.isEmpty) return;
|
||||
final origen =
|
||||
evento.occurrenceAtMillis > 0
|
||||
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
||||
: DateTime.now();
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] ejecucion perdida id=${evento.alarmaId} origen=${origen.toIso8601String()}',
|
||||
);
|
||||
try {
|
||||
final config = await servicio.completarEjecucion(evento.alarmaId, origen);
|
||||
_aplicar(config);
|
||||
ultimaAlarmaPerdidaId = evento.alarmaId;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] ejecucion perdida ERROR $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
|
||||
try {
|
||||
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
|
||||
|
||||
Reference in New Issue
Block a user