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

5.1 KiB

Delta for Native Alarms

ADDED Requirements

Requirement: Fail-Safe stopActive Semantics

PluriWaveAlarmService MUST expose an id-agnostic stop action (stopActive), used by the ringing UI and the notification Stop action, that stops the currently-active ring regardless of the alarm id it is called with. It MUST NEVER silently no-op while a ring is active. On concurrent-alarm ambiguity, it MUST verify state and report the outcome, failing toward silencing the active ring.

Scenario: Stop with mismatched id still silences the active ring

Testability: [kotlin-static]

  • GIVEN activeAlarmId = A is ringing
  • WHEN stopActive is invoked with id B (mismatch)
  • THEN ring A is stopped, not silently ignored, and the caller gets a verified result

Scenario: Stop with no active ring reports cleanly

Testability: [kotlin-static]

  • GIVEN no alarm is currently ringing
  • WHEN stopActive is invoked
  • THEN it reports "nothing to stop" rather than throwing or hanging

Scenario: Concurrent ambiguity fails toward silence

Testability: [device-qa]

  • GIVEN two alarms could plausibly be "the active one"
  • WHEN stopActive resolves the ambiguity
  • THEN the active ring MUST be silenced (over-stop preferred over leaving it ringing)

Requirement: Atomic Stop Coupling

Every stop path MUST perform stopForeground(STOP_FOREGROUND_REMOVE), stopSelf(), and wakelock release together, with no branch that performs a subset.

Scenario: Stop path releases all three resources together

Testability: [kotlin-static]

  • GIVEN any stop entry point in PluriWaveAlarmService
  • WHEN read/inspected
  • THEN all three teardown calls are reachable from it with no partial-teardown branch

Requirement: Bounded Auto-Silence at 10 Minutes

If a fired alarm is not stopped within 10 minutes, the system MUST transition it FIRED→MISSED via the atomic stop path, post a missed-alarm notification, rearm the next occurrence for repeating alarms, and leave one-shot alarms disabled.

Scenario: Unattended ring auto-silences at 10 minutes

Testability: [kotlin-static] + [device-qa]

  • GIVEN an alarm fires and is never stopped
  • WHEN 10 minutes elapse
  • THEN audio stops via the atomic stop path and a missed notification posts

Scenario: Repeating alarm rearms after auto-silence

Testability: [kotlin-static]

  • GIVEN a repeating alarm auto-silences
  • WHEN the MISSED transition completes
  • THEN AlarmScheduler arms the next occurrence

Scenario: One-shot alarm disables after auto-silence

Testability: [kotlin-static]

  • GIVEN a one-shot alarm auto-silences
  • WHEN the MISSED transition completes
  • THEN the alarm is left disabled, not rearmed

Requirement: Durable Firing Record + Restart Re-Validation

Before playback starts, the service MUST persist a durable "firing since T" record. onStartCommand MUST re-validate it on every entry, and the service MUST run START_NOT_STICKY. The record MUST clear only on a confirmed stop or a completed auto-silence transition.

Scenario: Firing record persists before audio starts

Testability: [kotlin-static]

  • GIVEN a fire event
  • WHEN startAlarm begins
  • THEN a durable record is written before MediaPlayer.start()

Scenario: onStartCommand re-validates on restart

Testability: [kotlin-static]

  • GIVEN the service process is killed and restarted by the OS
  • WHEN onStartCommand runs again
  • THEN it checks the record's age/state before resuming any audio action and returns START_NOT_STICKY

Scenario: Confirmed stop clears the record

Testability: [kotlin-static]

  • GIVEN a confirmed stop via stopActive
  • WHEN the atomic stop path completes
  • THEN the durable firing record is cleared

Requirement: Boot/Restart Cleanup of Stale Firing Records

On boot/unlock/package-replace, a durable firing record older than the 10-minute auto-silence bound MUST be treated as missed and cleaned up, not left dangling.

Scenario: Stale record cleaned at boot

Testability: [kotlin-static]

  • GIVEN a firing record older than 10 minutes exists at boot
  • WHEN PluriWaveBootReceiver runs reschedulePersistedAlarms
  • THEN the stale record is cleared and treated as a MISSED transition

Requirement: P1 — FSI, Exact-Alarm, and Notification-Permission Fallbacks

The system MUST call canUseFullScreenIntent() before relying on FSI and fall back to a heads-up notification when denied. It MUST show an in-app warning when exact-alarm scheduling permission is denied and when POST_NOTIFICATIONS is denied.

Scenario: FSI unavailable falls back to heads-up

Testability: [kotlin-static]

  • GIVEN canUseFullScreenIntent() returns false
  • WHEN a fire notification is built
  • THEN it posts as heads-up instead of FSI

Scenario: Exact-alarm denial warns in-app

Testability: [dart-testable]

  • GIVEN exact-alarm scheduling permission is denied
  • WHEN the user schedules/saves an alarm
  • THEN an in-app warning is shown

Scenario: POST_NOTIFICATIONS denial warns in-app

Testability: [dart-testable]

  • GIVEN POST_NOTIFICATIONS permission is denied
  • WHEN the user schedules an alarm
  • THEN an in-app warning is shown