Files
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

73 lines
6.0 KiB
Markdown

# 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.