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.
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
stopActivesemantics — 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→MISSEDtransition; repeating alarms rearm next occurrence; one-shots disable. - Atomic stop:
stopForeground(REMOVE)+stopSelf()+ wakelock release in one path. - Durable "firing since T" record +
onStartCommandre-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.
- Fail-safe native stop: the ringing UI and notification Stop action use id-agnostic
- P1 — Hardening:
canUseFullScreenIntent()check + heads-up fallback; exact-alarm-denial andPOST_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_serviceteardown on stop (native already owns ring audio); user-configurable silence duration. - Executing the stale
alarm-clock-module/app-quality-and-native-alarmstasks (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); atomicstopForeground+stopSelf+wakelock coupling; bounded auto-silence →MISSEDwith next-occurrence rearm; durable firing record +onStartCommandre-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(stucktasks-ready) andapp-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
stopAlarmhas 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 +
onStartCommandre-validation prevent resurrection;START_NOT_STICKYset. - Stop/snooze failures are visible and retryable in-app (force-stop).
- P1 FSI/permission fallbacks present and static-verifiable.