fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s

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:
Javier Bautista Fernández
2026-07-22 23:52:36 +02:00
parent 0f9a6a1719
commit 29f7d54e85
50 changed files with 2461 additions and 24 deletions
+94 -5
View File
@@ -30,9 +30,31 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
/// (a second _dismissScreen would pop the route UNDER the alarm screen).
bool _salidaEnCurso = false;
/// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` /
/// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure
/// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar,
/// this drives a persistent in-screen banner that stays until a confirmed
/// stop clears it — the ring is still audible while this is true, so the
/// screen intentionally does NOT dismiss.
bool _falloDetencionVisible = false;
late final EstadoAlarmas _alarmas;
@override
void initState() {
super.initState();
_alarmas = context.read<EstadoAlarmas>();
_alarmas.addListener(_alReconciliarFinExterno);
}
/// External end-of-ring reconciliation (RES-1): if this alarm's occurrence
/// gets recorded as MISSED while this screen is up, auto-dismiss instead of
/// leaving a stale ringing screen with no audio behind it.
void _alReconciliarFinExterno() {
if (_salidaEnCurso || !mounted) return;
if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return;
_salidaEnCurso = true;
_dismissScreen();
}
/// Pure UI: the ring's audio is owned entirely by the native
@@ -43,15 +65,46 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
// Dismiss is run from finally so a failing reschedule/teardown can never
// leave the ringing screen stuck open (which would also block the next
// ring via the _alarmaSonandoActiva guard in app.dart).
final alarmaId = widget.alarma.id;
try {
await alarmas.finalizarEjecucion(widget.alarma.id);
await alarmas.finalizarEjecucion(alarmaId);
if (alarmas.error != null) {
// Verified stop failure (Finding A): the alarm is still ringing, so
// dismissing now would hide the only retry affordance. Reset the
// single-exit guard so a retry (this button again, back gesture, or
// the banner's own action below) can run the teardown again.
_salidaEnCurso = false;
if (mounted) setState(() => _falloDetencionVisible = true);
return;
}
} catch (e) {
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
} finally {
// Unknown state (Finding A): treat exactly like a verified failure —
// stay and show the retry banner. The notification's native Stop
// action remains the out-of-band fallback, and PopScope already routes
// back through this same method on a subsequent back-press.
_salidaEnCurso = false;
if (mounted) setState(() => _falloDetencionVisible = true);
return;
}
if (mounted) _dismissScreen();
}
/// Retry action bound to the persistent force-stop banner (Finding A,
/// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a
/// confirmed success, otherwise the banner stays exactly as it was.
Future<void> _forzarDetencion() async {
if (_salidaEnCurso) return;
_salidaEnCurso = true;
final alarmas = context.read<EstadoAlarmas>();
await alarmas.forzarDetencion(widget.alarma.id);
if (alarmas.error == null) {
if (mounted) _dismissScreen();
} else {
// Verified failure (RES-2): reset the guard so the banner's own retry
// action (or another button) can run the teardown again.
_salidaEnCurso = false;
if (mounted) setState(() {});
}
}
@@ -109,6 +162,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
@override
void dispose() {
_alarmas.removeListener(_alReconciliarFinExterno);
super.dispose();
}
@@ -209,6 +263,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
icon: const Icon(Icons.stop_rounded),
label: Text(l10n.stopAlarmAction),
),
if (_falloDetencionVisible) ...[
const SizedBox(height: 14),
_bannerFalloDetencion(context, l10n, tokens),
],
],
),
).pluriFadeIn(context),
@@ -217,6 +275,37 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
);
}
/// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety`
/// / "Retryable Force-Stop Affordance"): an in-screen section rather than a
/// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a
/// stop (or the screen is torn down externally) instead of auto-dismissing
/// after a fixed duration.
Widget _bannerFalloDetencion(
BuildContext context,
AppLocalizations l10n,
PluriWaveTokens tokens,
) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _forzarDetencion,
child: Text(l10n.alarmForceStopAction),
),
],
),
);
}
}
String _hora(AlarmaMusical alarma) =>