Files
pluriwave/openspec/changes/alarm-system-overhaul/proposal.md
T
Javier Bautista Fernández 29f7d54e85
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
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.
2026-07-22 23:52:36 +02:00

6.0 KiB

Proposal: Alarm System Overhaul — Fail-Safe Stop/Dismiss

Intent

A real alarm rang ~15 min and could not be stopped by any means; the user uninstalled. Two confirmed causes: (1) native stopAlarm silently no-ops on alarmId != activeAlarmId while the Dart screen dismisses regardless ("dismiss-by-design" masks the failure); (2) disabling/editing a ringing alarm never stops audio. No bounded auto-silence and no durable firing record exist — an unbounded ring is the literal incident. Make the alarm system fail-safe per the acceptance bar (always rings; every action does exactly what it says; stop/snooze/disable/edit always silence a live ring), grounded in the Alarm Platform Contract and verifiable WITHOUT an Android build.

Scope

In Scope

  • P0 — Stop/dismiss correctness (incident class):
    • Fail-safe native stop: the ringing UI and notification Stop action use id-agnostic stopActive semantics — stop MUST NEVER silently no-op. Concurrent-alarm case uses verify-and-report; ambiguity fails toward silence.
    • Any Dart mutation of the ringing alarm (disable/edit/save/delete/snooze) stops audio first — shared guard in EstadoAlarmas.
    • Bounded auto-silence (fixed 10 min this slice) via AlarmManager-scheduled FIRED→MISSED transition; repeating alarms rearm next occurrence; one-shots disable.
    • Atomic stop: stopForeground(REMOVE) + stopSelf() + wakelock release in one path.
    • Durable "firing since T" record + onStartCommand re-validation; START_NOT_STICKY (no blind resurrection); stale record past timeout → clean up as missed.
    • Dart verification feedback: stop/snooze channel calls return success/failure; dismiss-by-design stays, but failures raise a persistent retryable "Force stop" affordance.
  • P1 — Hardening: canUseFullScreenIntent() check + heads-up fallback; exact-alarm-denial and POST_NOTIFICATIONS-denial in-app warnings.

Out of Scope

  • iOS; Android Auto local-music; radio-playback behavior.
  • P2 (deferred): dedicated ringing Activity (vs MainActivity routing); formal state-machine class; belt-and-suspenders audio_service teardown on stop (native already owns ring audio); user-configurable silence duration.
  • Executing the stale alarm-clock-module / app-quality-and-native-alarms tasks (see Dependencies).

Capabilities

New Capabilities

  • alarm-stop-safety: Dart-side fail-safe orchestration — any mutation targeting the ringing alarm guarantees audio stops; stop/snooze expose verifiable success/failure with a retryable force-stop path.

Modified Capabilities

  • native-alarms: stop never silently no-ops (fail-safe id semantics); atomic stopForeground+stopSelf+wakelock coupling; bounded auto-silence → MISSED with next-occurrence rearm; durable firing record + onStartCommand re-validation; START_NOT_STICKY; FSI/permission fallbacks.

Approach

Evolve the current architecture — do NOT rewrite. The native service already owns audio and notification actions already use PendingIntent.getService. Build on that: add an id-agnostic stop action, a durable firing record, an AlarmManager-armed missed transition, and a coupled stop method. Push all testable logic to Dart (mutation-while-ringing guard, stop-result handling, auto-silence timing/state model) with new fake failure switches; keep Kotlin changes trivially static-grep-verifiable. No new plugin dependencies.

Affected Areas

Area Impact Description
PluriWaveAlarmService.kt Modified Fail-safe stopActive, atomic stop, durable record, onStartCommand revalidate, auto-silence, START_NOT_STICKY
AlarmScheduler.kt Modified Arm FIRED→MISSED transition; missed cleanup + rearm
MainActivity.kt Modified Stop channel returns result; FSI/permission checks
estado/estado_alarmas.dart Modified Mutation-while-ringing stop guard; stop/snooze result handling
servicios/servicio_alarmas_android.dart Modified Channel methods return success/failure
pantallas/pantalla_alarma_sonando.dart Modified Retryable force-stop affordance on failure
test/ (Dart) + fakes New Failure switches for stop/dismiss; mutation-while-ringing; auto-silence timing

Risks

Risk Likelihood Mitigation
Native changes unverifiable without build env High Keep Kotlin trivially readable; push logic + tests to Dart; on-device QA checklist
Fail-safe stop silences the wrong concurrent alarm Low Verify-and-report; fail toward silence — stuck-ring cost >> wrong-stop cost
Auto-silence fires early on a legitimately long ring Low 10-min bound matches wakelock cap; configurability deferred to P2
P0+P1 exceeds 800-line budget Med tasks phase slices P0 first, P1 as follow-up

Rollback Plan

Single feature branch to main. Revert the branch/PR; native and Dart changes are additive to existing stop paths, so reverting restores current behavior. Durable firing record is a new pref key — orphaned entries are self-cleaning (age check), so no migration rollback needed.

Dependencies

  • Stale artifact reconciliation (no work done here): alarm-clock-module (stuck tasks-ready) and app-quality-and-native-alarms (apply-complete, never verified) are SUPERSEDED by this change — this proposal re-specifies the alarm-reliability behavior authoritatively. Recommend archiving both as superseded during this change's archive phase; do NOT re-verify or execute their tasks.

Success Criteria

  • Stop/snooze/disable/edit/delete of a ringing alarm ALWAYS silences audio (Dart tests with failure fakes).
  • Native stopAlarm has no branch that silently no-ops a live ring.
  • A ring cannot exceed the bounded auto-silence window; missed transition + rearm covered.
  • Durable firing record + onStartCommand re-validation prevent resurrection; START_NOT_STICKY set.
  • Stop/snooze failures are visible and retryable in-app (force-stop).
  • P1 FSI/permission fallbacks present and static-verifiable.