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.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# alarm-system-overhaul — Apply Progress (Slice A)
|
||||
|
||||
## Status: complete (pending final full-suite verification + bounded review)
|
||||
|
||||
Slice A (Phases 1-7 of tasks.md) is fully implemented and every task checkbox is marked with
|
||||
verified evidence. Phase 8 / Slice B (P1 permission fallbacks) is intentionally untouched —
|
||||
deferred to a follow-up delivery per the orchestrator decision recorded in Engram
|
||||
(`decision/alarm-system-overhaul-pre-apply-orchestrator-decisions`).
|
||||
|
||||
## How this phase ran (recovery notes)
|
||||
|
||||
- The initial apply agent implemented most of Slice A but stalled (stream watchdog) before
|
||||
recording progress; a second continuation agent also stalled early. Recovery was done in
|
||||
smaller stages: (1) a mapping agent verified every Phase 1-7 task acceptance criterion
|
||||
against the working tree (grep/diff evidence, table below summarized) and marked tasks.md;
|
||||
(2) a surgical TDD agent closed the one functional gap the mapping found (task 7.4);
|
||||
(3) the orchestrator ran the final full-suite verification.
|
||||
- Baseline established during recovery: full `flutter test` suite green (544 tests) and
|
||||
`flutter analyze` clean (1 known pre-existing info in test/estado/estado_radio_test.dart:641)
|
||||
BEFORE the 7.4 fix; final run after 7.4 recorded below.
|
||||
|
||||
## Task evidence summary (full table in the mapping agent report; all grep-verified)
|
||||
|
||||
- 1.1-1.4 (fail-safe native stop): `@Volatile activeRingingId` companion snapshot;
|
||||
atomic `stopEverything()` (stopForeground(REMOVE)+stopSelf+wakelock+MediaPlayer+notification)
|
||||
routed from ACTION_STOP/ACTION_STOP_ACTIVE/onDestroy/missed paths; id-agnostic
|
||||
`ACTION_STOP_ACTIVE` for ringing UI + notification Stop (`stopPendingIntent` action updated).
|
||||
- 2.1-2.7 (auto-silence + durable firing record): `AUTO_SILENCE_MILLIS` (10 min),
|
||||
AlarmManager-armed `ACTION_MISSED` → `onAlarmMissed` (stops if active, posts missed
|
||||
notification via existing channel, clears firing record, no re-arm of the fired instance);
|
||||
durable firing record written before `player.start()`, `onStartCommand` stale re-validation,
|
||||
`START_NOT_STICKY` preserved; boot-time `cleanupStaleFiringRecords()` in
|
||||
`reschedulePersistedAlarms`; `missedTitle`/`missedText` in AlarmNotificationStrings.
|
||||
- 3.1 (channel contract): `stopActiveAlarm` + `getActiveRingingAlarmId` in MainActivity dispatcher;
|
||||
`stopActiveAlarm` reports `STOP_FAILED` errors instead of silent void.
|
||||
- 4.1-4.6 (Dart orchestration): `ResultadoDetencion`, `alarmaSonandoId()`,
|
||||
`detenerSonidoActivo()` on the puerto; fakes gained `fallaDetener`/`alarmaSonandoIdValor`/
|
||||
`detencionesActivas`; `_detenerSiEstaSonando` wired into `guardarAlarma` (covers
|
||||
`cambiarActiva`) and `eliminarAlarma`; `finalizarEjecucion` stops active sound and surfaces
|
||||
`_error` on failure. Spec scenarios SS-1a/b/c/d, SS-2a/b covered by tests.
|
||||
- 5.1-5.4 (ringing screen fail-safe UX): `forzarDetencion()`; `_detener()` keeps
|
||||
dismiss-by-design but captures ScaffoldMessenger pre-dismiss and shows a retryable
|
||||
force-stop SnackBar on failure (mirrors the existing snooze-failure pattern).
|
||||
SS-3a/b/c covered by tests.
|
||||
- 6.1-6.2 (missed-event sync): `accionMissed` native event → `_registrarEjecucionPerdida`
|
||||
→ `completarEjecucion`, no re-arm. Test present.
|
||||
- 7.1-7.4 (l10n): 4 new keys in en+es ARBs only (11 other locales untouched — CI ARB guard);
|
||||
missed-alarm strings wired through `setNotificationStrings` (`missedTitle`,
|
||||
`missedTemplate` with `{name}` sentinel via new `_plantillaNombre` helper) — 7.4 closed the
|
||||
gap found during mapping (RED→GREEN evidence in task report).
|
||||
|
||||
## Deviations
|
||||
|
||||
- Task 7.4 added during apply verification (missed-string wiring was implied by NA-3a intent
|
||||
but not an explicit task). No other scope deviations; no unexpected diff content.
|
||||
|
||||
## Changed-line accounting (excluding generated lib/l10n/gen/*)
|
||||
|
||||
- Working tree at mapping time: ~1045 insertions total incl. generated; non-generated portion
|
||||
within the 800-line session ceiling (final figure recorded by the review lifecycle at
|
||||
review start).
|
||||
|
||||
## Remaining work
|
||||
|
||||
- None for Slice A code. Next: bounded 4-lens review → sdd-verify → commit/push.
|
||||
- Slice B (Phase 8, P1 fallbacks) — follow-up delivery.
|
||||
- On-device QA checklist (tasks.md) — requires the user with real hardware; sdd-archive held
|
||||
until that passes.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Design: Alarm System Overhaul — Fail-Safe Stop/Dismiss
|
||||
|
||||
## Technical Approach
|
||||
|
||||
Evolve the existing native-owns-audio architecture; no rewrite. The single key enabler is that
|
||||
the service, activity and receivers share ONE process (confirmed: no `android:process`), so
|
||||
`MainActivity` can read a `@Volatile` companion field on `PluriWaveAlarmService` synchronously to
|
||||
build verifiable stop results without a service round-trip. Four surgical additions: (1) an
|
||||
id-agnostic fail-safe stop that can never no-op a live ring; (2) an atomic `stopEverything()` all
|
||||
stop paths funnel through; (3) an AlarmManager-armed `FIRED→MISSED` auto-silence bound; (4) a
|
||||
durable firing record in the existing device-protected prefs for process-death recovery. All
|
||||
decision logic lives in Dart (mutation-while-ringing guard, stop-result handling) behind new fake
|
||||
switches; Kotlin stays trivially static-grep-verifiable. Maps to proposal P0 (stop-safety) + P1
|
||||
(fallbacks); P2 deferred.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Decision 1 — Native stop semantics (verify-and-report)
|
||||
|
||||
| Option | Tradeoff | Decision |
|
||||
|---|---|---|
|
||||
| Service writes result to static field, channel polls | Race between async `onStartCommand` and channel read | Rejected |
|
||||
| Channel reads `@Volatile` companion snapshot (same process), then dispatches id-agnostic stop | Snapshot is authoritative pre-stop; stop cannot no-op | **Chosen** |
|
||||
|
||||
`PluriWaveAlarmService` gains companion `@Volatile var activeRingingId: String?` (set in
|
||||
`startAlarm`, cleared in `stopEverything`). New channel methods:
|
||||
- `getActiveRingingAlarmId(): String?` — synchronous companion read; this is HOW Dart knows what
|
||||
is ringing.
|
||||
- `stopActiveAlarm(): { stopped: bool, wasRinging: bool, activeAlarmId: String? }` — reads the
|
||||
snapshot, dispatches `ACTION_STOP_ACTIVE` (id-agnostic full teardown), returns the pre-stop
|
||||
snapshot. `stopped` = a non-no-op teardown was dispatched (always true on success path);
|
||||
`wasRinging` = `activeAlarmId != null`. **Error contract**: internal exception →
|
||||
`result.error("STOP_FAILED", msg, null)`; Dart treats a thrown channel error OR `stopped==false`
|
||||
as failure → retryable force-stop.
|
||||
|
||||
**Concurrent case**: only one alarm rings at a time (`startAlarm` early-returns while
|
||||
`activeAlarmId != null`). `stopActiveAlarm` silences the ONE audible ring and returns its id so
|
||||
Dart reconciles; the id-agnostic form is used ONLY by the ringing UI Stop and the notification Stop
|
||||
action (explicit "silence what I hear" intents). Mutation guards (Decision 5) gate on the returned
|
||||
`activeAlarmId` so a background toggle of a non-ringing alarm never stops a live one. Ambiguity
|
||||
fails toward silence per the acceptance bar.
|
||||
|
||||
### Decision 2 — Atomic `stopEverything()`
|
||||
|
||||
Extract the current teardown block (`PluriWaveAlarmService.kt` L410-433) into one private
|
||||
`stopEverything()`: cancel fallback+fade runnables, `player.stop()/release()`, `activeAlarmId=null`,
|
||||
clear companion, `releaseWakeLock()`, `abandonAlarmAudioFocus()`, cancel notification,
|
||||
`AlarmScheduler(this).clearFiringRecord(id)` + `cancelAutoSilence(id)`,
|
||||
`stopForeground(REMOVE)`, `stopSelf()`. Every path routes through it: `ACTION_STOP`
|
||||
(id match/null), `ACTION_STOP_ACTIVE`, `ACTION_SNOOZE`, `ACTION_MISSED`, `onDestroy`. `stopAlarm(id)`
|
||||
stays as the id-scoped wrapper (foreign-id mismatch → cancel that id's notification only, then
|
||||
return; else `stopEverything()`).
|
||||
|
||||
### Decision 3 — Auto-silence (AlarmManager-armed MISSED)
|
||||
|
||||
| Option | Tradeoff | Decision |
|
||||
|---|---|---|
|
||||
| In-service `Handler.postDelayed(10min)` | Dies with process; no missed-notification/rearm if service later gone | Rejected |
|
||||
| AlarmManager `setExactAndAllowWhileIdle` → receiver `ACTION_MISSED` | Survives process death; posts missed notification + rearm even if FGS was killed; reuses existing scheduling infra | **Chosen** |
|
||||
|
||||
Fixed 10 min (matches the existing wakelock cap; configurability = P2). Armed in `onAlarmFired`
|
||||
(already runs at fire time and already rearms the next occurrence, so `onAlarmMissed` must NOT
|
||||
re-rearm — only silence + notify + clear record). Cancelled in `snooze`, `skipNext`, `cancelAlarm`
|
||||
and `stopEverything`. `onAlarmMissed(id)`: stop the service if this id still rings, post a missed
|
||||
notification (reuse the pre-notice non-FSI channel + `AlarmNotificationStrings`, new keys
|
||||
`missedTitle`/`missedText`), clear the firing record.
|
||||
|
||||
### Decision 4 — Durable firing record
|
||||
|
||||
Store in the existing device-protected prefs `pluriwave_alarm_scheduler` (`AlarmScheduler.prefs()`,
|
||||
direct-boot safe). Schema: `KEY_FIRING_IDS` (string set) + `firing_<id>` → `firedAtMillis` (Long).
|
||||
**Write** in `onAlarmFired` BEFORE the service/audio starts (receiver runs `onAlarmFired` first).
|
||||
**Clear** in `stopEverything`/`onAlarmMissed`. **`onStartCommand` re-validation**: `startAlarm`
|
||||
checks `firingRecordAgeMillis(id)`; if `> AUTO_SILENCE_MILLIS` → abort start, run `onAlarmMissed`
|
||||
cleanup (defends against redelivery/resurrection; `START_NOT_STICKY` already prevents blind
|
||||
restart). **Boot cleanup**: `reschedulePersistedAlarms` calls `cleanupStaleFiringRecords()` first —
|
||||
any record older than the window is cleared as missed, so a reboot mid-ring never resurrects audio.
|
||||
|
||||
### Decision 5 — Dart orchestration
|
||||
|
||||
`PuertoAlarmasAndroid` gains `Future<String?> alarmaSonandoId()` and
|
||||
`Future<ResultadoDetencion> detenerSonidoActivo()` (new value type
|
||||
`ResultadoDetencion{ bool detenido; bool estabaSonando; String? alarmaId; }`); `detenerSonidoNativo`
|
||||
stays for compatibility. `EstadoAlarmas` gains a shared guard
|
||||
`_detenerSiEstaSonando(String id)`: query `alarmaSonandoId()`; if it equals `id` →
|
||||
`detenerSonidoActivo()`. Wired into `guardarAlarma`/`cambiarActiva(false)`/`eliminarAlarma`
|
||||
(upgrade its existing `detenerSonidoNativo` call) so any mutation of the ringing alarm silences it
|
||||
first. `finalizarEjecucion` (the Stop path) calls `detenerSonidoActivo()` directly; on failure it
|
||||
sets `_error` (same channel the snooze SnackBar already reads). New `forzarDetencion()` re-invokes
|
||||
`detenerSonidoActivo()` for the retry action.
|
||||
|
||||
**UX** (`pantalla_alarma_sonando.dart`): keep dismiss-by-design, EXCEPT on a verified `_detener()`
|
||||
failure — a timed SnackBar would auto-dismiss while the alarm is still audibly ringing, hiding the
|
||||
only retry affordance. **Amended by review round 2**: instead the screen stays up and renders a
|
||||
persistent in-screen banner (`alarmStopFailedMessage` + a `alarmForceStopAction` button calling
|
||||
`forzarDetencion()`) that clears only on a confirmed stop, never on a timer.
|
||||
|
||||
**l10n** (new keys): `alarmStopFailedMessage`, `alarmForceStopAction`,
|
||||
`alarmMissedNotificationTitle`, `alarmMissedNotificationText({name})`. Provide `en` (template, with
|
||||
`@`-metadata + placeholders) and `es`. **Policy for the other 12 locales**: given the ARB
|
||||
placeholder-corruption CI guard, define placeholder metadata ONLY in the template and mirror it
|
||||
byte-exactly in `es`; omit the keys from the remaining locales so gen-l10n falls back to `en` at
|
||||
runtime (untranslated-message warning is acceptable) — this avoids introducing placeholder metadata
|
||||
into 12 files and the corruption risk the guard protects against. Flag full translation as a
|
||||
follow-up.
|
||||
|
||||
### Decision 6 — P1 permission/FSI fallbacks
|
||||
|
||||
FSI fallback is largely automatic: `buildNotification` keeps `IMPORTANCE_HIGH` +
|
||||
`setFullScreenIntent`, which the platform degrades to heads-up when `canUseFullScreenIntent()` is
|
||||
false. The P1 work is the in-app WARNING: `diagnostics` already exposes
|
||||
`canUseFullScreenIntent`/`notificationsEnabled`/`canScheduleExactAlarms`; surface warning banners on
|
||||
the existing diagnostics/settings surface bound to `EstadoAlarmas.diagnostico` (new warning strings).
|
||||
Lightly specified; sliced after P0.
|
||||
|
||||
## Data Flow
|
||||
|
||||
Fire: Receiver(ACTION_FIRE) ─→ AlarmScheduler.onAlarmFired
|
||||
│ writes firing record (before audio) + arms MISSED + rearms next
|
||||
└─→ Service.startAlarm ─→ companion.activeRingingId=id ─→ audio
|
||||
|
||||
Stop: RingingUI/NotifStop ─→ (channel) stopActiveAlarm
|
||||
│ reads companion snapshot ─→ result{stopped,wasRinging,activeAlarmId}
|
||||
└─→ ACTION_STOP_ACTIVE ─→ stopEverything() ─→ clear record + cancel MISSED
|
||||
|
||||
Mutate: EstadoAlarmas.guardar/cambiar/eliminar ─→ alarmaSonandoId()
|
||||
└─ if == target ─→ detenerSonidoActivo() ─→ stopEverything()
|
||||
|
||||
Timeout: AlarmManager(MISSED) ─→ Receiver(ACTION_MISSED) ─→ onAlarmMissed
|
||||
└─→ stop service + missed notification + clear record (no re-rearm)
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Action | Change |
|
||||
|---|---|---|
|
||||
| `PluriWaveAlarmService.kt` | Modify | companion `activeRingingId`/`ACTION_STOP_ACTIVE`/`AUTO_SILENCE_MILLIS`/`stopActive()`; `onStartCommand` +ACTION_STOP_ACTIVE; `startAlarm` sets companion + stale-record re-validation; extract `stopEverything()` |
|
||||
| `AlarmScheduler.kt` | Modify | `recordFiring`/`clearFiringRecord`/`firingRecordAgeMillis`/`cleanupStaleFiringRecords`; `armAutoSilence`/`cancelAutoSilence`/`onAlarmMissed`; `onAlarmFired`+arm+record; `reschedulePersistedAlarms`+cleanup; cancel in snooze/skipNext/cancelAlarm |
|
||||
| `PluriWaveAlarmReceiver.kt` | Modify | `ACTION_MISSED` constant + branch → `onAlarmMissed`; `pendingMissedIntent` helper |
|
||||
| `MainActivity.kt` | Modify | channel `stopActiveAlarm` (returns map) + `getActiveRingingAlarmId`; pass missed strings in `setNotificationStrings` |
|
||||
| `AlarmNotificationStrings.kt` | Modify | `missedTitle`/`missedText` getters+setters |
|
||||
| `servicio_alarmas_android.dart` | Modify | `ResultadoDetencion`; `alarmaSonandoId()`; `detenerSonidoActivo()`; interface additions |
|
||||
| `estado/estado_alarmas.dart` | Modify | `_detenerSiEstaSonando` guard; wire into guardar/cambiar/eliminar/finalizar; `forzarDetencion()`; missed-event handling |
|
||||
| `pantallas/pantalla_alarma_sonando.dart` | Modify | force-stop SnackBar action on stop failure |
|
||||
| `l10n/arb/app_en.arb`, `app_es.arb` | Modify | 4 new keys (en template + es) |
|
||||
| `test/helpers/fakes_alarmas.dart` | Modify | `fallaDetener`, `alarmaSonandoIdValor`, `detencionesActivas`, new interface impls |
|
||||
| `test/**` (Dart) | New | stop/mutation/force-stop/missed tests |
|
||||
|
||||
## Interfaces / Contracts
|
||||
|
||||
```dart
|
||||
class ResultadoDetencion {
|
||||
final bool detenido; // stop dispatched, cannot no-op
|
||||
final bool estabaSonando; // audio was live
|
||||
final String? alarmaId; // what was actually ringing
|
||||
}
|
||||
abstract class PuertoAlarmasAndroid {
|
||||
Future<String?> alarmaSonandoId(); // getActiveRingingAlarmId
|
||||
Future<ResultadoDetencion> detenerSonidoActivo(); // stopActiveAlarm
|
||||
// ...existing members unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Channel (`pluriwave/alarm_scheduler`): `getActiveRingingAlarmId → String?`,
|
||||
`stopActiveAlarm → {stopped,wasRinging,activeAlarmId}`.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Layer | What | How |
|
||||
|---|---|---|
|
||||
| Unit (Dart) | Stop success/failure surfaces result | `fallaDetener` → `EstadoAlarmas.error` set; assert `detenerSonidoActivo` called |
|
||||
| Unit (Dart) | Mutation-while-ringing stops audio | `alarmaSonandoIdValor=target` → `cambiarActiva(false)`/`guardarAlarma(inactive)`/`eliminarAlarma` assert force-stop invoked |
|
||||
| Unit (Dart) | No false stop on non-ringing target | mismatched `alarmaSonandoIdValor` → assert force-stop NOT invoked |
|
||||
| Widget | Force-stop retry affordance | stop failure → SnackBar with action → action calls `forzarDetencion` |
|
||||
| Unit (Dart) | Missed event bookkeeping | native `missed` event → `completarEjecucion` recorded |
|
||||
| On-device QA | Native-only proofs | see checklist |
|
||||
|
||||
**On-device QA checklist** (only a device proves): Stop from ringing UI (id match AND mismatch);
|
||||
Stop from lock-screen notification; disable/edit/delete while ringing; 10-min untouched →
|
||||
auto-silence + missed notification + repeating rearm; kill app mid-ring (audio stops); reboot
|
||||
mid-ring (boot cleanup, no resurrection); concurrent second alarm; FSI-denied heads-up fallback.
|
||||
|
||||
## Threat Matrix
|
||||
|
||||
N/A — no routing, shell, subprocess, VCS/PR automation, executable-file classification, or
|
||||
shell/process-integration boundary. (Android service/IPC is not in scope of the shell/subprocess
|
||||
threat matrix.)
|
||||
|
||||
## Migration / Rollout
|
||||
|
||||
No migration. New pref keys (`firing_<id>`, `KEY_FIRING_IDS`) are additive and self-cleaning by age;
|
||||
orphaned entries are ignored if the reader is reverted. Single feature branch to `main`; P0 first,
|
||||
P1 as a follow-up slice within budget.
|
||||
|
||||
## Consequences / Rollback
|
||||
|
||||
Ships to live alarm users. Regression manifestations & mitigations:
|
||||
- **Wrong concurrent alarm silenced** — bounded by the one-ring-at-a-time guard; `stopActiveAlarm`
|
||||
returns the stopped id for reconciliation.
|
||||
- **Auto-silence fires early** — bounded to 10 min = wakelock cap; user re-arm unaffected.
|
||||
- **Firing-record bug → false "missed" or blocked start** — age-gated; stale-only cleanup; worst
|
||||
case a legitimate ring is cut at the 10-min bound (still better than the unbounded incident).
|
||||
|
||||
**Fastest rollback**: `git revert` the merge/PR — native and Dart changes are additive to existing
|
||||
stop paths, so revert restores current behavior; orphaned firing-record keys go unread (no migration
|
||||
rollback needed).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [ ] Missed notification channel: reuse the pre-notice channel vs. a dedicated low-importance
|
||||
`missed` channel? (Leaning reuse for minimal diff; confirm in tasks.)
|
||||
- [ ] Should `finalizarEjecucion` return `Future<bool>` for a cleaner contract, or keep the
|
||||
`_error`-field convention the snooze path uses? (Leaning `_error` for consistency.)
|
||||
@@ -0,0 +1,56 @@
|
||||
# alarm-system-overhaul — Codebase Deep-Dive (explore phase 1/2)
|
||||
|
||||
## 1. Inventory
|
||||
|
||||
### Dart (lib/)
|
||||
- `estado/estado_alarmas.dart` — ChangeNotifier, canonical alarm state (`ConfiguracionAlarmas`). Key methods: `guardarAlarma` (L99, calls `android.programar`, NEVER calls `detenerSonidoNativo` even if the saved alarm is the one currently ringing), `eliminarAlarma` (L155, DOES call `android.detenerSonidoNativo` before `cancelar`), `cambiarActiva` (L164, delegates to `guardarAlarma` — same gap), `posponerAlarma` (L195), `posponerProximaDesdePreaviso` (L241), `finalizarEjecucion` (L272, calls `android.ocultarNotificacionAlarma` then `servicio.completarEjecucion`), `_alRecibirEventoNativo`/`_registrarCancelacionSnoozeNativa` (native-event sync), `_importarSnoozesNativosActivos` (cold-start snooze import).
|
||||
- `servicios/servicio_alarmas_android.dart` — `ServicioAlarmasAndroid implements PuertoAlarmasAndroid`, wraps `MethodChannel('pluriwave/alarm_scheduler')`. `_logAndInvokeVoid` (L386) invokes the channel with NO try/catch — errors propagate to caller. Methods: `programar`, `cancelar`→`cancelAlarm`, `ocultarNotificacionAlarma`→`dismissAlarmNotification`, `detenerSonidoNativo`→`stopNativeAlarmSound`. `_instalarHandler` (L394) receives native `alarmFired` events.
|
||||
- `servicios/servicio_alarmas.dart`, `servicio_programacion_alarmas.dart` — pure scheduling/next-occurrence math + persistence (`persistencia_tolerante.dart`).
|
||||
- `pantallas/pantalla_alarma_sonando.dart` — ringing screen, audio-free (native owns audio). `_detener()` (L42) and `_posponer()` (L61) both: single-exit guard `_salidaEnCurso`, call into `EstadoAlarmas`, wrap in try/catch, **`finally { _dismissScreen() }` — the screen ALWAYS closes even if the native stop/snooze call throws** ("dismiss-by-design", intentional per comments, to avoid a stuck screen — but it also means a failed/no-op native stop is invisible to the user). `PopScope(canPop:false)` routes system back through the same `_detener()`.
|
||||
- `app.dart` — `_alarmaSonandoActiva`/`_alarmaSonandoId` guard (L107-109) is now `finally`-protected (L388-392) — the historical stuck-modal/skipped-next-ring bug (single failure, two symptoms) is fixed. `_mostrarAlarmaSonando` (L354) correctly no-ops a duplicate delivery of the SAME ring and hides the notification only for a genuinely different concurrent alarm id (L371-373).
|
||||
|
||||
### Kotlin (android/app/src/main/kotlin/es/freetimelab/pluriwave/)
|
||||
- `PluriWaveAlarmService.kt` — foreground service, SOLE audio owner (MediaPlayer on STREAM_ALARM/USAGE_ALARM). `onStartCommand` (L51) dispatches ACTION_STOP→`stopAlarm` (L58), ACTION_SNOOZE→native `AlarmScheduler.snooze` + `stopAlarm` (L61-84), FIRE/null→`startAlarm` (L85). `startAlarm` (L91) early-returns if `activeAlarmId != null` (single-ring-at-a-time). `startAudio`/`startStationAudio`/`startFallbackAudio` implement a 3-stage fallback chain (station → fallback station → bundled WAV) each with a 15s timeout (`scheduleStationFallback`) and a shared exponential dB fade loop (`startFadeLoop`/`computeFadeVolume`). **`stopAlarm` (L390) has an id-scoped guard: `if (alarmId != null && activeAlarmId != null && alarmId != activeAlarmId)` → does NOT stop audio/service, only cancels the notification for the mismatched id** — deliberate (protects the real ring from a second alarm's stop request) but is also the single point where an id mismatch would silently no-op a real stop. `buildNotification` (L436) posts the ONE fire notification (NOTIFICATION_ID=92841) with Snooze+Stop actions as `PendingIntent.getService` DIRECTLY to this service (bypasses Flutter entirely — robust even with a dead engine). WakeLock capped at 10 min (L509) — irrelevant to audio stoppability (CPU only).
|
||||
- `PluriWaveAlarmReceiver.kt` — BroadcastReceiver for FIRE/PRE_NOTICE/SKIP_NEXT/POSTPONE_NEXT/SNOOZE_COUNTDOWN/SNOOZE_AGAIN/CANCEL_SNOOZE. `notificationIdForAlarm`/`fireNotificationIdForAlarm` (L274-275) are deterministic hash-based ids (53*hash+7 / 59*hash+9) — two DIFFERENT notification ids per alarm id (pre-notice/countdown vs. fire), so no id collision between the two channels.
|
||||
- `AlarmScheduler.kt` — `scheduleAlarm`/`scheduleSpec` (trusts Dart's trigger when fresh, native recompute only as fallback — documented on-device divergence bug already fixed), `onAlarmFired`, `snooze`/`postponeNext`/`snoozeAgain` (anchor semantics documented), `cancelSnooze`, `cancelAlarm` (L659, does NOT touch the running service/audio — only cancels PendingIntents/notifications), `dismissFireNotification` (L676, notification-cancel only, no audio stop), `reschedulePersistedAlarms` (boot/unlock/TZ-change/package-replace/exact-alarm-permission-change).
|
||||
- `MainActivity.kt` — MethodChannel `pluriwave/alarm_scheduler` handler (L89): `scheduleAlarm`, `cancelAlarm`, `dismissAlarmNotification` (L144, calls **both** `PluriWaveAlarmService.stop(this,id)` AND `alarmScheduler.dismissFireNotification(id)`), `stopNativeAlarmSound` (L155, calls only `PluriWaveAlarmService.stop`), `diagnostics`, permission requests, `getInitialAlarmIntent`/`getHandledAlarmOccurrences`/`getNativeSnoozeState`, `setNotificationStrings`. `notifyAlarmEvent` (companion, L1204) forwards native-originated events to Flutter ONLY if `activeInstance` (the live Activity) is non-null — dead-engine snoozes rely on cold-start `getNativeSnoozeState` sync instead.
|
||||
- `PluriWaveBootReceiver.kt` — BOOT_COMPLETED/LOCKED_BOOT_COMPLETED/USER_UNLOCKED/MY_PACKAGE_REPLACED/TIME_SET/TIMEZONE_CHANGED/SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED → `AlarmScheduler.reschedulePersistedAlarms()`.
|
||||
- `AlarmNotificationStrings.kt`, `NotificationBrand.kt` — device-protected-storage-backed i18n strings pushed from Dart via `setNotificationStrings` (works even before first unlock, direct-boot-aware).
|
||||
- No separate `android:process` — service/activity/receivers all share the app's default process; `MainActivity.activeInstance` reachability is NOT a cross-process concern.
|
||||
|
||||
### Channels / persistence
|
||||
- MethodChannel `pluriwave/alarm_scheduler` (Dart↔Kotlin): scheduleAlarm, cancelAlarm, dismissAlarmNotification, stopNativeAlarmSound, diagnostics, requestExactAlarmPermission, requestPostNotificationsPermission, requestFullScreenIntentPermission, requestIgnoreBatteryOptimizations, getInitialAlarmIntent, getHandledAlarmOccurrences, getNativeSnoozeState, setNotificationStrings; reverse direction `alarmFired` (native→Dart).
|
||||
- SharedPreferences (regular, per-alarm native spec store) + device-protected-storage prefs (`pluriwave_alarm_channels` migration flag, `AlarmNotificationStrings`).
|
||||
- Dart side: `persistencia_tolerante.dart` for alarm config resilience against corruption.
|
||||
|
||||
## 2. Full lifecycle traces — key points
|
||||
- Scheduling always prefers `setAlarmClock` (L235), falls back through `setExactAndAllowWhileIdle`→`setAndAllowWhileIdle`→`set` depending on SDK/exact-alarm permission (`scheduleMainAlarm`, AlarmScheduler.kt L228-278).
|
||||
- Fire path: Receiver (ACTION_FIRE) → `AlarmScheduler.onAlarmFired` (reschedule bookkeeping) → `PluriWaveAlarmService.start` (posts FSI notification BEFORE audio prepares) → `startActivity(MainActivity)` (brings UI forward regardless of process state) → native audio 3-stage fallback with fade-in.
|
||||
- Stop from notification: `PendingIntent.getService` → service `ACTION_STOP` directly (Flutter-independent, most robust path).
|
||||
- Stop from in-app modal: MethodChannel → `stopNativeAlarmSound`/`dismissAlarmNotification` → `PluriWaveAlarmService.stop` (same code path as notification button) — but gated by Flutter engine being alive AND the call succeeding.
|
||||
- Dead-app fire: Receiver creates process, starts service + activity; Flutter engine boots concurrently; `getInitialAlarmIntent`/cold-start sync reconciles state once engine is up.
|
||||
|
||||
## 3. FAILURE-MODE ANALYSIS (ranked by likelihood/evidence)
|
||||
|
||||
1. **[HIGHEST] Silent no-op on native id mismatch, masked by Dart's "dismiss-by-design".** `PluriWaveAlarmService.stopAlarm` (L400-409) silently no-ops the actual stop when `alarmId != activeAlarmId` (only cancels a notification). This call NEVER throws in that branch, so Dart's `_detener()`/`_posponer()` try/catch never fires and the ringing screen closes as if it worked (comment at pantalla_alarma_sonando.dart:38-41, and the "dismiss-by-design preserved" test explicitly locks in this behavior for the SNOOZE path only). If ANY id-derivation drift exists between what Dart passes and the service's `activeAlarmId` (e.g. after a snooze/reschedule mutates the spec, or during the documented "second alarm during ring" scenario), the user sees the screen close/app return to normal while the native `MediaPlayer` keeps playing — matching "not by opening the app" in the incident exactly (opening the app and tapping Stop APPEARED to work, screen closed, but audio never stopped).
|
||||
2. **[HIGH] Toggling an alarm off (or editing/saving it) while it is the one currently ringing does not stop the native audio.** `EstadoAlarmas.guardarAlarma` (L99-116) → `android.programar` → (if now inactive) `cancelar` (native `cancelAlarm`, AlarmScheduler.kt L659) — cancels FUTURE schedules/notifications only, never calls `PluriWaveAlarmService.stop`. Only `eliminarAlarma` (full delete) calls `detenerSonidoNativo` first. A user who — after a failed/ambiguous Stop tap — panics and disables the alarm from the Alarms list will NOT stop the ringing audio, and will have destroyed the association between the alarm config and the still-ringing id, making a subsequent recovery attempt harder to reason about.
|
||||
3. **[MEDIUM] Untested failure path for the exact defensive code that exists.** `FakePuertoAlarmasAndroid` (test/helpers/fakes_alarmas.dart) has a `fallaProgramar` failure switch for `programar` (used to test the snooze-failure SnackBar), but NO equivalent switch for `ocultarNotificacionAlarma`/`detenerSonidoNativo`. No test exercises `_detener()`'s catch/finally when the STOP call itself fails — the exact guard meant to catch this class of incident is unverified by CI.
|
||||
4. **[MEDIUM] OEM background-execution restrictions / Doze / battery optimization.** `diagnostics` channel already surfaces `isIgnoringBatteryOptimizations` and requests exemption, but this is opt-in/dismissible by the user; on aggressive OEM skins (MIUI/EMUI/etc.) a `startService()` call from a notification action can be delayed or dropped even when the app already runs a foreground service — flagged as a plausible but unverifiable-from-code contributor.
|
||||
5. **[LOWER] Reschedule/notification double-post races across concurrent alarms.** Code has explicit guards (`activeAlarmId != null` early-return in `startAlarm`, id-scoped `stopAlarm`) that appear to correctly prevent a second alarm's fire/stop from disturbing an active ring — analysis suggests this is already handled, kept as a residual risk only if the guard's assumptions (single Service instance, sequential onStartCommand dispatch) are violated by an OS-specific behavior.
|
||||
|
||||
### Other fragilities found (not part of the core incident but real gaps)
|
||||
- `cancelAlarm`/`dismissFireNotification` (AlarmScheduler.kt) never stop an active ring — see #2.
|
||||
- WakeLock hardcoded 10-minute cap (PluriWaveAlarmService.kt:509) — does not affect stoppability but could affect CPU scheduling on rings intentionally left running longer (fade-in test/edge cases).
|
||||
- `alarm-clock-module` OpenSpec change is stuck at `status: planned / phase: tasks-ready` since 2026-05-21 despite the alarm feature clearly being implemented and iterated on since — stale/orphaned SDD tracking artifact, needs reconciliation.
|
||||
- `app-quality-and-native-alarms` is `status: proposed / phase: apply-complete` (2026-06-12) and was NEVER moved to verify/archive — explicitly flagged in its own risk table ("`alarm-clock-module` state drift... out of scope to fix mid-flight"). Contains Slice 1 (native reliability: foreground-service type, dedup notifications, channel sound, fallback station, battery exemption, native fade-in) and Slice 2 (full snooze-path audit) — need to verify against CURRENT code which of these already landed (current code already shows FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK|SYSTEM_EXEMPTED, single FSI-owning service notification, fallback station support, native fade-in — so Slice 1 appears substantially implemented even though the artifact was never archived).
|
||||
|
||||
## 4. Test coverage map
|
||||
- Covered (Dart): snooze failure SnackBar + dismiss-by-design (`pantalla_alarma_sonando_dismiss_guard_test.dart`), `_alarmaSonandoActiva` guard regressions, native snooze sync (`estado_alarmas_snooze_test.dart`), pre-notice/countdown templates, alarm cache/corruption/persistence tolerance.
|
||||
- NOT covered (Dart): failure/no-op of `ocultarNotificacionAlarma`/`detenerSonidoNativo` from the Stop path (no fake failure switch exists); toggling/saving an alarm while it is the one actively ringing; concurrent-alarm id-mismatch stop scenario end-to-end.
|
||||
- NOT covered (Kotlin): **zero** — no Kotlin test files exist in the repo (`android/**/*Test*.kt` glob returns nothing) and there is no Android build environment available in this session to add/run any. All native-service claims above (id-scoped stop guard, 3-stage audio fallback, fade loop, wakelock) are verified only by static code reading, never executed.
|
||||
|
||||
## 5. Known-debt from prior OpenSpec alarm changes
|
||||
- `alarm-clock-module` (2026-05-21): stuck at tasks-ready, never applied/archived in SDD tracking — reconcile or supersede.
|
||||
- `app-quality-and-native-alarms` (2026-06-11/12): apply-complete but never verified/archived; its own risk table flags the `alarm-clock-module` drift as deferred. Needs a fresh verify pass against current code to confirm which of its 7 slices actually landed.
|
||||
- `alarm-live-countdown` (archived 2026-06-28, PASS WITH WARNINGS): pre-notice l10n + snooze dismiss guard — done, warnings were about Spanish-only button labels (deferred) and absent Kotlin test infra (still absent today).
|
||||
- `snooze-reschedule-fix` (archived 2026-07-01, PASS WITH WARNINGS): posponerAlarma/posponerProximaDesdePreaviso error-handling parity — done; noted follow-ups: DI seams for PluriWaveApp testability (still not done — contributed to inability to widget-test app.dart's routing), dedicated l10n key for snooze failure (still reuses androidExactAlarmScheduleError).
|
||||
@@ -0,0 +1,112 @@
|
||||
# Alarm Platform Contract — Android Alarm Reliability Reference (mid-2026)
|
||||
|
||||
Scope: authoritative rules for building a bulletproof Android alarm clock, targeting Android 14/15/16-era devices (targetSdk 35/36). Grounds the `alarm-system-overhaul` behavior spec and refactor. Repo context checked: `android/app/src/main/AndroidManifest.xml` declares `SCHEDULE_EXACT_ALARM`+`USE_EXACT_ALARM` (redundant pairing, see Rule 3), `USE_FULL_SCREEN_INTENT`, `POST_NOTIFICATIONS`, `FOREGROUND_SERVICE_MEDIA_PLAYBACK`+`FOREGROUND_SERVICE_SYSTEM_EXEMPTED`; has custom native `.PluriWaveAlarmService` (`foregroundServiceType=mediaPlayback|systemExempted`), `.PluriWaveAlarmReceiver`, `.PluriWaveBootReceiver` (handles LOCKED_BOOT_COMPLETED/BOOT_COMPLETED/USER_UNLOCKED/MY_PACKAGE_REPLACED/TIME_SET/TIMEZONE_CHANGED/SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED — good coverage of reschedule triggers) — AND a separate `com.ryanheise.audioservice.AudioService` (Flutter `audio_service` plugin, used for radio streaming). Two independent audio/media-session owners in one app is the most likely root cause of the app's documented incident (alarm rang 15 min, only uninstall stopped it).
|
||||
|
||||
## 1. Scheduling
|
||||
|
||||
1.1 Use `AlarmManager.setAlarmClock()` for user-facing alarm-clock semantics, not `setExactAndAllowWhileIdle()`. `setAlarmClock()` is the highest-priority alarm type: the system exits Doze/App Standby to deliver it and never defers it, and it shows the alarm-clock icon in the status bar. `setExactAndAllowWhileIdle()` is "nearly precise" and intended for non-user-visible exact work, not primary alarm firing. [Android AlarmManager docs](https://developer.android.com/develop/background-work/services/alarms/schedule) — all API levels, but the precision distinction matters most from Android 6 (Doze) onward.
|
||||
|
||||
1.2 On Android 12+ (API 31+), declare `SCHEDULE_EXACT_ALARM` and call `AlarmManager.canScheduleExactAlarms()` before scheduling; a `SecurityException` is thrown otherwise. On Android 14 (API 34), this permission is **no longer pre-granted on fresh installs** — apps must send the user to `ACTION_REQUEST_SCHEDULE_EXACT_ALARM` with an in-app rationale first. [Android 14 behavior change](https://developer.android.com/about/versions/14/changes/schedule-exact-alarms)
|
||||
|
||||
1.3 `USE_EXACT_ALARM` (Android 13+/API 33+) is an install-time-granted, non-revocable-by-user permission but is restricted by Play Store policy to apps whose **core function** is alarms or calendars. Declaring BOTH `SCHEDULE_EXACT_ALARM` and `USE_EXACT_ALARM` in the same manifest (as this app currently does) is redundant and risky: Play may reject `USE_EXACT_ALARM` if core-functionality review fails, silently falling back to the revocable permission — the app must not assume `USE_EXACT_ALARM` guarantees a grant. [Schedule alarms guide](https://developer.android.com/develop/background-work/services/alarms/schedule)
|
||||
|
||||
1.4 Register a receiver for `AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` and, on receipt, re-check `canScheduleExactAlarms()` and reschedule/rebuild every pending alarm instance. Do not trust a cached permission flag — the system can revoke exact-alarm permission automatically (e.g. long-unused apps) and this broadcast is the only signal. Repo already listens for this action in `PluriWaveBootReceiver` — verify the handler actually reschedules rather than just logging.
|
||||
|
||||
1.5 Reschedule ALL alarms on: `BOOT_COMPLETED` (+ `LOCKED_BOOT_COMPLETED` for direct-boot-aware alarms), `MY_PACKAGE_REPLACED` (app update wipes AlarmManager state), `TIME_SET`, `TIMEZONE_CHANGED`. Missing any of these is a classic bug class ("alarm survived reboot but not timezone change"). Repo's `PluriWaveBootReceiver` already covers all four — confirm the Kotlin implementation actually recomputes trigger times rather than re-arming stale timestamps.
|
||||
|
||||
1.6 If exact-alarm permission is denied, fall back to `setAndAllowWhileIdle()`/`setWindow()`, never silently drop the alarm — and surface a persistent "exact alarms disabled, alarm may be late" warning in-app, mirroring AOSP DeskClock's user-facing permission nags.
|
||||
|
||||
## 2. Firing / Ringing (native-only responsibility)
|
||||
|
||||
2.1 Canonical pattern: `AlarmManager` fires a `PendingIntent` → **BroadcastReceiver.onReceive()** (short-lived, <10s budget) → immediately calls `Context.startForegroundService()` (or `startForeground()` from the service within 5s per Android 8+ FGS rules) → the **Foreground Service** owns the wake lock, the ringtone/media player, and vibration for the entire ringing lifetime. This exact chain is what AOSP DeskClock's `AlarmService` + `AlarmActivity` do, and what the `alarm` (gdelataillade) Flutter plugin's native Android layer does — audio ownership lives in native Kotlin/Java, never in a Dart isolate, because Dart isolates are not guaranteed to be alive when the alarm fires. [Alarm plugin Android install guide](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md)
|
||||
|
||||
2.2 Foreground service type: use `mediaPlayback` (Android 10+ requirement) — AOSP DeskClock and the `alarm` plugin both use a media-playback-typed FGS for the ringing service. `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` alone is not a substitute; this app's `PluriWaveAlarmService` already declares `mediaPlayback|systemExempted`, which is correct, but the app must ensure exact-alarm-triggered FGS starts are exempt from the Android 12+ background-start restrictions (they are, by design, per platform docs: exact alarms are excluded from FGS-from-background limits).
|
||||
|
||||
2.3 Do NOT rely on the audio system's implicit wake lock alone for anything beyond audio — the FGS itself must hold `PARTIAL_WAKE_LOCK` (`WAKE_LOCK` permission, already declared) for any non-audio work (vibration loop, timeout logic, UI signaling) and must release it deterministically in every stop path (`onDestroy`, explicit stop, timeout). When only `MediaPlayer`/`ExoPlayer` plays audio with `AudioAttributes.USAGE_ALARM`, the audio framework manages its own wake lock for the playback itself — but application-level state transitions still need an explicit lock if the CPU could otherwise sleep between them. [Background work: wake locks](https://developer.android.com/develop/background-work/background-tasks/awake/wakelock/identify-wls)
|
||||
|
||||
2.4 Play ringtone via `MediaPlayer`/`ExoPlayer`/`Ringtone` configured with `AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)`. This ties playback to the **alarm** audio stream (`STREAM_ALARM`), which (a) bypasses Do Not Disturb by default — Android treats alarm-stream audio as a high-priority interruption that sounds regardless of DND unless the user explicitly disabled "Alarms" under DND exceptions — and (b) uses the alarm volume slider, not media/ring volume. **This is the single highest-value correctness rule for a hybrid Flutter+native app**: if the alarm ever plays through the `audio_service`/`just_audio` media-session pipeline (music/media stream) instead of a dedicated `STREAM_ALARM`/`USAGE_ALARM` player, it will (a) respect DND media-silencing, (b) follow media volume (can be 0), and (c) fight the radio-streaming engine for the same media session — a plausible root cause of "two engines playing / can't stop" incidents.
|
||||
|
||||
2.5 Auto-silence: industry-standard timeout is a bounded window (AOSP DeskClock uses a configurable timeout, default historically ~15 minutes, now user-configurable "Silence after" 1–30 min or "Never"); after timeout, stop audio/vibration, transition to a "missed alarm" notification, and — for repeating alarms — compute and arm the next occurrence. Never let the ringing service loop indefinitely with no upper bound; an unbounded loop is exactly the "rang 15 minutes, uninstall required" failure mode. [AOSP DeskClock `AlarmStateManager`](https://github.com/LineageOS/android_packages_apps_DeskClock/blob/89aae7601d0bc17bf5f6e89f5a0b919a184256ae/src/com/android/deskclock/alarms/AlarmStateManager.java)
|
||||
|
||||
## 3. UI over lock screen
|
||||
|
||||
3.1 Android 14+ (API 34): `USE_FULL_SCREEN_INTENT` becomes a **special app access** permission auto-granted by Play only to apps whose core function is calls or alarms; otherwise it must be requested via `ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT` and checked with `NotificationManager.canUseFullScreenIntent()`. An alarm-clock app qualifies for default grant, but must still runtime-check the flag (Play/OEM can still revoke) and have a non-FSI fallback (heads-up high-priority notification) if denied. [source.android.com FSI limits](https://source.android.com/docs/core/permissions/fsi-limits) — effective policy date May 31 2024 / enforced from Jan 22 2025.
|
||||
|
||||
3.2 Notification channel for ringing must be `IMPORTANCE_HIGH` (or `IMPORTANCE_MAX` where the platform still exposes it) with `setSound(null, ...)` — the CHANNEL must not play its own sound because the foreground service is already playing the alarm tone; a channel sound + service audio together is a double-audio bug. Set `category = CATEGORY_ALARM`. Attach `setFullScreenIntent(pendingIntent, true)` pointing at a dedicated full-screen Activity, and `setOngoing(true)`.
|
||||
|
||||
3.3 The full-screen ringing Activity must call `setShowWhenLocked(true)` + `setTurnScreenOn(true)` (API 27+) instead of the deprecated window flags, matching this app's `MainActivity` manifest attributes — but the ringing UI should be its OWN activity (not `MainActivity`) with `android:excludeFromRecents`, `singleInstance`/`singleTask`, so it can be shown/dismissed independently of app navigation state.
|
||||
|
||||
3.4 Android 12+ (API 31+) notification trampoline restriction: Stop/Snooze notification action buttons must be `PendingIntent.getForegroundService()` or `PendingIntent.getBroadcast()` directly — **never** a broadcast/service that itself calls `startActivity()`. Any notification action that needs to show UI after tapping must build a `PendingIntent` pointing straight at the destination Activity; do not chain through a receiver-that-launches-activity ("trampoline"), which is blocked and produces silent no-ops or logcat-only failures on Android 12+. [Notification trampoline restrictions](https://developer.android.com/about/versions/12/behavior-changes-12)
|
||||
|
||||
## 4. Stop/Snooze correctness (single source of truth)
|
||||
|
||||
4.1 There must be exactly ONE process/component that owns "is the alarm currently ringing" state and exactly ONE component that owns the audio player instance. In a Flutter app, this MUST be native Kotlin (the foreground service), not Dart — Dart/Flutter engine lifecycle (background isolates, `audio_service`'s Flutter-side handlers) is not guaranteed to be running or reachable when the user taps Stop from a lock-screen notification while the main Flutter engine is not attached. All Stop/Snooze `PendingIntent`s must route to the SAME native receiver/service that started the ringing, using a stable, unique request-code/alarm-instance-id so the intent unambiguously identifies the alarm to stop — never a broadcast that "maybe" reaches Dart via a plugin channel.
|
||||
|
||||
4.2 Foreground service must NOT use `START_STICKY` for the ringing service if that causes the OS to resurrect it after being killed with no user-visible way to stop it. If restart-on-kill is desired for reliability, the restarted service must re-check persisted "is this alarm still supposed to be ringing" state (e.g., a stop-timestamp or dismissed flag in SharedPreferences/DB) on `onStartCommand` and self-terminate immediately if the alarm was already dismissed/stopped — otherwise a killed-and-restarted service can resume playing after the user already stopped it, exactly matching "un-dismissable alarm" reports. [START_STICKY restart discussion](https://issuetracker.google.com/issues/36986292)
|
||||
|
||||
4.3 Notification cancellation and service lifecycle must be coupled atomically: cancelling/removing the notification does NOT stop a foreground service, and stopping the service without cancelling the notification leaves a stuck notification. Every stop path (user tap, timeout, snooze) must call BOTH `stopForeground(STOP_FOREGROUND_REMOVE)` (or equivalent) AND `stopSelf()`, plus release the wake lock, in one atomic method — never rely on the notification's own dismissal to imply the service stopped.
|
||||
|
||||
4.4 If audio is ever routed through a second engine (e.g., `audio_service`'s `AudioService`/media session, used here for radio streaming) instead of the alarm-ringing native player, the Stop action must explicitly stop/abandon audio focus and release that engine too — a stop path that only signals the native alarm service while a second, independently-running audio engine continues playing is a documented Flutter/plugin-interaction failure mode (`audio_service` + background alarm plugin conflicts are reported upstream). [audio_service background-alarm issue](https://github.com/ryanheise/audio_service/issues/704)
|
||||
|
||||
4.5 Handle process death mid-ring: persist "alarm X is currently firing since T" to durable storage (not just in-memory) BEFORE starting playback, and clear it only on a confirmed stop. On any service restart/reboot, if a "firing" record has no matching stop record and its age exceeds the auto-silence timeout, treat it as missed and clean up rather than resuming indefinitely.
|
||||
|
||||
## 5. Reference state machine (AOSP DeskClock `AlarmStateManager`)
|
||||
|
||||
States: `SILENT_STATE` → `LOW_NOTIFICATION_STATE` → `HIGH_NOTIFICATION_STATE` → `FIRED_STATE` → (`SNOOZE_STATE` | `MISSED_STATE`) → `DISMISSED_STATE`, plus a transient `HIDE_NOTIFICATION_STATE`.
|
||||
- SILENT → LOW_NOTIFICATION: scheduled ahead-of-time via `setSilentState()`/`scheduleInstanceStateChange()`.
|
||||
- LOW → HIGH_NOTIFICATION: automatic at `getHighNotificationTime()`; HIGH cannot be user-hidden (only dismiss/snooze).
|
||||
- HIGH → FIRED: at the actual alarm trigger time; `setFiredState()` arms a timeout via `scheduleInstanceStateChange(context, timeout, instance, MISSED_STATE)`.
|
||||
- FIRED → SNOOZE: user action, increments instance trigger time and re-arms.
|
||||
- FIRED → MISSED: automatic on timeout expiry (this IS the auto-silence mechanism).
|
||||
- MISSED → (delete | disable | reschedule next occurrence): `updateParentAlarm()` — one-shot alarms with "delete after use" are deleted, others disabled; repeating alarms create the next instance via `createInstanceAfter()`.
|
||||
- Any state → DISMISSED: final, deletes the instance and checks the parent alarm for rescheduling.
|
||||
[AOSP DeskClock AlarmStateManager, LineageOS mirror](https://github.com/LineageOS/android_packages_apps_DeskClock/blob/89aae7601d0bc17bf5f6e89f5a0b919a184256ae/src/com/android/deskclock/alarms/AlarmStateManager.java)
|
||||
|
||||
Division of responsibility to replicate: the STATE MACHINE (scheduling transitions, timeout arming, missed/dismiss bookkeeping) can be modeled in Dart/domain layer as long as it only computes *when* to transition — but the ACTUAL transition execution at fire time (starting the FGS, playing audio, holding the wake lock, posting the FSI notification, handling Stop/Snooze taps) MUST be native Kotlin, invoked directly from the BroadcastReceiver, independent of whether the Flutter engine is attached.
|
||||
|
||||
Contrast with Fossify/Simple-Clock's simpler model: their `AlarmReceiver` does NOT use a foreground service at all when screen is off — it posts a high-importance full-screen notification and launches a `ReminderActivity` directly, which owns audio playback itself while visible. This is simpler but riskier (no dedicated FGS-held wake lock across the whole ring duration); AOSP DeskClock's FGS-centric model is the more robust reference for a "wakes reliably from any state" alarm. [Simple-Clock AlarmReceiver](https://github.com/SimpleMobileTools/Simple-Clock/blob/master/app/src/main/kotlin/com/simplemobiletools/clock/receivers/AlarmReceiver.kt)
|
||||
|
||||
## 6. Flutter-specific plugin architecture notes
|
||||
|
||||
6.1 `android_alarm_manager_plus`: creates its own background `FlutterEngine` to run a Dart callback on alarm fire. Documented limitations: does not manage `SCHEDULE_EXACT_ALARM` permission at all (app must handle it); can crash when combined with other plugins that also spin up background engines/services (conflicts with `audio_service`-style plugins); callback reliability degrades when the app process was fully killed. **Verdict: not suitable as the sole mechanism for ringing UI/audio — at most usable for lightweight rescheduling logic, never for owning playback.** [plus_plugins issue #266](https://github.com/fluttercommunity/plus_plugins/issues/266)
|
||||
|
||||
6.2 `alarm` (gdelataillade): purpose-built alarm plugin that keeps ALL ringing responsibility (foreground service, `NotificationOnKillService` safety net, audio, vibration, volume) in native Kotlin, exposing only schedule/cancel/stream-of-events to Dart. This is the correct architectural split for a Flutter alarm app and is the closer analog to what this app's custom `PluriWaveAlarmService`/`PluriWaveAlarmReceiver` should converge toward — provided the alarm audio path is fully decoupled from `audio_service`'s media session. [alarm plugin Android install docs](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md)
|
||||
|
||||
## 7. Notifications
|
||||
|
||||
7.1 `POST_NOTIFICATIONS` runtime permission (Android 13+/API 33) must be requested before posting any notification, including the ringing FSI notification — if denied, the FSI/heads-up path degrades to nothing being shown, so the app needs an in-app fallback warning.
|
||||
7.2 Alarm channel: `IMPORTANCE_HIGH`, `sound = null` (service owns audio), `CATEGORY_ALARM`, `setOngoing(true)` while ringing, action buttons wired to direct broadcast/service `PendingIntent`s (never trampolines — see 3.4).
|
||||
7.3 Android 16 "Notification Cooldown" (gradual volume reduction for bursty same-app notifications) explicitly **does not apply to calls, alarms, priority conversations, or emergency alerts** — but this exemption is presumably keyed off `CATEGORY_ALARM`/correct channel classification, so mis-categorized alarm notifications could incorrectly get cooled down. Classify correctly. [Android 16 notification cooldown](https://www.computerworld.com/article/3609666/android-16-notification-cooldown.html)
|
||||
7.4 Update (not re-post) the ringing notification via a stable notification ID per alarm instance; re-posting can retrigger heads-up/FSI unexpectedly and complicates the "notification removed but service alive" desync bug (Rule 4.3).
|
||||
|
||||
## 8. Failure-mode checklist (must survive)
|
||||
|
||||
- [ ] Process death mid-ring (service killed by LMK/OEM) — state persisted, restart re-validates before resuming (4.2, 4.5).
|
||||
- [ ] Device reboot — `BOOT_COMPLETED`/`LOCKED_BOOT_COMPLETED` reschedules every pending alarm from durable storage (1.5).
|
||||
- [ ] Doze / App Standby — `setAlarmClock()` used, not a type that Doze can defer (1.1); FGS start from exact alarm is exempt from BG-start restrictions (2.2).
|
||||
- [ ] SCHEDULE_EXACT_ALARM revoked by user/system mid-session — `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` handled, fallback scheduling used, user warned (1.4, 1.6).
|
||||
- [ ] Locale/timezone change — `TIME_SET`/`TIMEZONE_CHANGED` recompute all trigger times (1.5).
|
||||
- [ ] App update — `MY_PACKAGE_REPLACED` reschedules (1.5).
|
||||
- [ ] Media/ring volume at 0 — irrelevant if alarm audio correctly uses `STREAM_ALARM`/`USAGE_ALARM` (2.4); verify alarm volume specifically, not device master volume.
|
||||
- [ ] DND enabled — alarm-stream audio bypasses DND by default; verify the app is not accidentally routing through a stream DND does silence (2.4).
|
||||
- [ ] Battery optimization / not in Doze-exemption whitelist — regular (non-`setAlarmClock`) alarms are deferred under Doze; either use `setAlarmClock()` (exempt) or guide the user through `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (1.1, repo already declares `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`).
|
||||
- [ ] Two audio engines racing (native alarm player vs. `audio_service` media session) — single source of truth for "is ringing" and for the player instance; Stop must tear down both if both can ever be active (4.1, 4.4) — **highest-priority item given this app's dual audio-engine manifest**.
|
||||
- [ ] Notification dismissed/swiped but service still alive, or vice versa — coupled stop path (4.3).
|
||||
- [ ] Full-screen intent permission denied/revoked (Android 14+ policy or user revocation) — `canUseFullScreenIntent()` checked, heads-up fallback exists (3.1).
|
||||
- [ ] Notification trampoline blocked (Android 12+) — all action `PendingIntent`s go straight to service/broadcast or directly to an Activity, never chained (3.4).
|
||||
- [ ] Auto-silence/missed-alarm path — bounded ring duration, transition to MISSED, repeating alarms rearm next occurrence (2.5, state machine section 5).
|
||||
|
||||
## Top 10 rules this Flutter app is most likely violating (given dual native-service + audio_service architecture)
|
||||
|
||||
1. Alarm ringing audio possibly not exclusively on `STREAM_ALARM`/`USAGE_ALARM` if it shares code paths with `audio_service`'s media-session player used for radio (Rule 2.4) — needs verification.
|
||||
2. Two independent audio/media engines (`PluriWaveAlarmService` native + `com.ryanheise.audioservice.AudioService`) with no single documented "who owns ringing audio" contract (Rule 4.1, 4.4) — matches the reported incident signature.
|
||||
3. Redundant `SCHEDULE_EXACT_ALARM` + `USE_EXACT_ALARM` declaration without a documented fallback if Play strips `USE_EXACT_ALARM` core-functionality grant (Rule 1.3).
|
||||
4. Unclear whether Stop/Snooze notification actions route directly to service/broadcast `PendingIntent`s vs. any trampoline-like indirection (Rule 3.4) — needs code check.
|
||||
5. Unclear whether the ringing foreground service re-validates "already stopped" state on every `onStartCommand` (protection against `START_STICKY` resurrection) (Rule 4.2).
|
||||
6. Unclear whether notification cancellation and `stopForeground()`/`stopSelf()` are coupled atomically in every stop path (Rule 4.3).
|
||||
7. Unclear whether a durable "currently firing" record exists to recover deterministically from process death mid-ring (Rule 4.5).
|
||||
8. No confirmed bounded auto-silence timeout / missed-alarm transition (Rule 2.5) — an unbounded ring loop is the literal shape of the known incident.
|
||||
9. `ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED` receiver exists in the manifest but its Kotlin handler's actual behavior (reschedule vs. log-only) is unverified (Rule 1.4).
|
||||
10. Full-screen intent fallback path (heads-up notification when FSI is denied/revoked) is unverified — app relies on `USE_FULL_SCREEN_INTENT` but Android 14+ can still revoke it via Play/OEM policy (Rule 3.1).
|
||||
|
||||
Note: items 2–9 require the codebase-focused sdd-explore pass (Kotlin source read) to confirm/refute; this document is the external-research half only.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Alarm Stop Safety Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Dart-side fail-safe orchestration: every surface that can act on a ringing alarm (ringing-screen Stop/Snooze, alarm-list disable/toggle, edit/save, delete) MUST deterministically silence a live ring or surface a visible, retryable failure. Dismiss-by-design (the ringing screen always closes) is preserved but MUST NOT mask a failed native stop.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Mutation-While-Ringing Stop Guard
|
||||
|
||||
`EstadoAlarmas` MUST route any mutation targeting the currently-ringing alarm — disable/toggle (`cambiarActiva`), edit/save (`guardarAlarma`), delete (`eliminarAlarma`) — through one centralized stop-first guard, not per-call-site logic.
|
||||
|
||||
#### Scenario: Toggling the ringing alarm off stops audio
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN alarm X is ringing and active
|
||||
- WHEN `cambiarActiva(X, false)` is called
|
||||
- THEN `detenerSonidoNativo(X)` runs before/alongside `programar`/`cancelar`
|
||||
|
||||
#### Scenario: Editing/saving the ringing alarm stops audio
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN alarm X is ringing
|
||||
- WHEN `guardarAlarma` is called with an edited config for id X
|
||||
- THEN the stop guard fires before the save persists
|
||||
|
||||
#### Scenario: Deleting the ringing alarm stops audio (regression lock)
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN alarm X is ringing
|
||||
- WHEN `eliminarAlarma(X)` is called
|
||||
- THEN `detenerSonidoNativo(X)` runs before `cancelar` (locks in existing correct behavior)
|
||||
|
||||
#### Scenario: Mutating a non-ringing alarm does not trigger the guard
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN alarm Y is ringing, alarm Z is not
|
||||
- WHEN `cambiarActiva(Z, ...)` or `guardarAlarma(Z)` is called
|
||||
- THEN no stop call targets Y
|
||||
|
||||
### Requirement: Stop/Snooze Result Verification
|
||||
|
||||
Stop (`detenerSonidoNativo`) and snooze channel calls MUST return a success/failure/unconfirmed result instead of only throwing or being swallowed. `EstadoAlarmas` MUST record this outcome per alarm.
|
||||
|
||||
#### Scenario: Native stop confirms success
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN the stop channel call resolves with `confirmed=true`
|
||||
- WHEN `_detener()` completes
|
||||
- THEN `EstadoAlarmas` records a confirmed-stop state for that alarm
|
||||
|
||||
#### Scenario: Native stop reports unconfirmed/failure
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN the fake channel is set to fail or return `confirmed=false`
|
||||
- WHEN `_detener()`/`_posponer()` runs
|
||||
- THEN `EstadoAlarmas` records a failed/unconfirmed state, never a confirmed one
|
||||
|
||||
### Requirement: Retryable Force-Stop Affordance
|
||||
|
||||
When a stop/snooze attempt is not confirmed, the app MUST present a persistent, retryable "Force stop" affordance until a confirmed stop is recorded or the alarm is externally cleared. Dismiss-by-design (screen closing) stays independent of this affordance.
|
||||
|
||||
#### Scenario: Failed stop surfaces Force stop
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN `_detener()` receives an unconfirmed/failure result
|
||||
- WHEN the ringing screen finishes its dismiss-by-design close
|
||||
- THEN a persistent retryable Force-stop affordance appears
|
||||
|
||||
#### Scenario: Retrying force-stop attempts stop again
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN a Force-stop affordance is visible after a failed attempt
|
||||
- WHEN the user retries
|
||||
- THEN `detenerSonidoNativo` is invoked again; success clears the affordance, failure keeps it visible
|
||||
|
||||
#### Scenario: Confirmed stop shows no failure UI
|
||||
**Testability**: [dart-testable]
|
||||
|
||||
- GIVEN a confirmed-stop result
|
||||
- WHEN the screen dismisses
|
||||
- THEN no Force-stop affordance appears
|
||||
|
||||
### Requirement: Notification Stop/Snooze Stays Native-Only
|
||||
|
||||
Notification Stop/Snooze actions use `PendingIntent.getService` directly (native-only, Flutter-independent) and remain outside this Dart guard's scope; only Dart-initiated mutations and in-app screens are covered here.
|
||||
|
||||
#### Scenario: Notification action bypasses the Dart guard by design
|
||||
**Testability**: [kotlin-static]
|
||||
|
||||
- GIVEN a fire notification with Stop/Snooze actions
|
||||
- WHEN the user taps either action
|
||||
- THEN it invokes the native service directly, never touching `EstadoAlarmas`' guard
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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
|
||||
@@ -0,0 +1,106 @@
|
||||
# Tasks: Alarm System Overhaul — Fail-Safe Stop/Dismiss
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines (Slice A, code+tests) | ~650–800 |
|
||||
| Estimated changed lines (Slice B, P1 follow-up) | ~80–120 |
|
||||
| 400-line budget risk | High (well above the 400-line reviewer-cognition guard) |
|
||||
| 800-line session budget risk | Medium (near the ceiling; any test-file growth pushes over) |
|
||||
| Chained PRs recommended | No (session delivery is direct commits to `main`, no PRs) |
|
||||
| Suggested split | Slice A = this delivery (Phases 1–7); Slice B = P1 follow-up (Phase 8) |
|
||||
| Delivery strategy | single-pr-default (direct commits to main, no PRs) |
|
||||
| Chain strategy | size-exception |
|
||||
|
||||
Decision needed before apply: Yes
|
||||
Chained PRs recommended: No
|
||||
Chain strategy: size-exception
|
||||
400-line budget risk: High
|
||||
|
||||
Rationale: no-PR direct-commit strategy forecloses PR chaining, but Slice A crosses the 400-line
|
||||
guard on its own. Maintainer must explicitly accept a `size:exception` for Slice A before `sdd-apply`
|
||||
proceeds; Slice B (P1) ships as a separate, smaller follow-up commit set once Slice A lands.
|
||||
|
||||
### Suggested Work Units (sequenced commit batches, not PRs)
|
||||
|
||||
| Unit | Goal | Focused test command | Runtime harness | Rollback boundary |
|
||||
|------|------|-----------------------|------------------|--------------------|
|
||||
| A1 — Native stop core (Ph.1–3) | id-agnostic stop, atomic teardown, notification route fix, channel bridge | N/A (no Kotlin build env; grep-verifiable) | Device QA checklist below | Revert `PluriWaveAlarmService.kt`/`MainActivity.kt` hunks; additive-only |
|
||||
| A2 — Native auto-silence + durable record (Ph.2) | MISSED transition, firing record, boot cleanup | N/A (grep-verifiable) | Device QA: 10-min + reboot scenarios | Revert `AlarmScheduler.kt`/`PluriWaveAlarmReceiver.kt`; orphaned pref keys unread |
|
||||
| A3 — Dart orchestration (Ph.4–6) | mutation guard, force-stop UI, missed bookkeeping | `flutter test test/estado/estado_alarmas_test.dart test/servicios/servicio_alarmas_android_test.dart test/pantallas/pantalla_alarma_sonando_test.dart` | N/A (unit/widget-covered) | Revert Dart hunks; guard is additive to existing stop calls |
|
||||
| A4 — l10n (Ph.7) | 4 new keys, en+es only | `flutter test` + CI ARB guard | N/A | Revert 2 ARB files |
|
||||
| B1 — P1 fallback banners (Phase 8, follow-up) | FSI/exact-alarm/notification-permission warnings | `flutter test test/estado` | Device QA: FSI-denied scenario | Revert diagnostics-banner hunk only |
|
||||
|
||||
## Phase 1: Native Stop Core (Kotlin, static-edit)
|
||||
|
||||
- [x] 1.1 `PluriWaveAlarmService.kt`: add companion `@Volatile var activeRingingId: String?`, set in `startAlarm`, cleared in `stopEverything`. [kotlin-static] → Decision 1. Accept: grep shows `@Volatile` field + both set/clear sites.
|
||||
- [x] 1.2 Add `ACTION_STOP_ACTIVE` const; extract `stopEverything()` from the L410-433 teardown (runnables, player stop/release, `activeAlarmId=null`, companion clear, wakelock release, audio-focus abandon, notification cancel, `clearFiringRecord`+`cancelAutoSilence`, `stopForeground(STOP_FOREGROUND_REMOVE)`, `stopSelf()`). [kotlin-static] → NA-2, Decision 2. Accept: grep shows all 3 teardown calls in `stopEverything()` and every stop path (`ACTION_STOP`, `ACTION_STOP_ACTIVE`, `ACTION_SNOOZE`, `ACTION_MISSED`, `onDestroy`) calls it.
|
||||
- [x] 1.3 `onStartCommand` `ACTION_STOP_ACTIVE` branch → `stopEverything()` id-agnostically; `stopAlarm(id)` keeps id-scoped wrapper (foreign-id mismatch cancels only that id's notification). [kotlin-static] → NA-1a, NA-1b, NA-1c, Decision 1. Accept: branch present, ignores extras id.
|
||||
- [x] 1.4 **(feedback item 1)** Fix `stopPendingIntent(alarmId)`: change dispatched `action` from `ACTION_STOP` to `ACTION_STOP_ACTIVE` so the notification Stop button routes to the id-agnostic stop. [kotlin-static] → SS-4a, NA-1a. Accept: grep confirms `action = ACTION_STOP_ACTIVE` inside `stopPendingIntent`.
|
||||
|
||||
## Phase 2: Native Auto-Silence + Durable Firing Record (Kotlin, static-edit)
|
||||
|
||||
- [x] 2.1 `AlarmScheduler.kt`: add `AUTO_SILENCE_MILLIS` (10 min), `recordFiring(id)`/`clearFiringRecord(id)`/`firingRecordAgeMillis(id)` on `KEY_FIRING_IDS`+`firing_<id>` in `pluriwave_alarm_scheduler` prefs. [kotlin-static] → NA-4a, Decision 4.
|
||||
- [x] 2.2 `armAutoSilence(id)`/`cancelAutoSilence(id)` via `AlarmManager.setExactAndAllowWhileIdle` → `ACTION_MISSED`; arm+record in `onAlarmFired`; cancel in `snooze`, `skipNext`, `cancelAlarm`, `stopEverything`. [kotlin-static] → NA-3a, Decision 3. Accept: grep shows 1 arm site + 4 cancel sites.
|
||||
- [x] 2.3 `onAlarmMissed(id)`: `stopEverything()` if id still rings, post missed notification (reuse pre-notice channel + `AlarmNotificationStrings.missedTitle/missedText`), clear firing record, **no rearm**; one-shot stays disabled. [kotlin-static] → NA-3a, NA-3b, NA-3c. Accept: grep shows no rearm/`programarSiguiente` call inside `onAlarmMissed`.
|
||||
- [x] 2.4 `PluriWaveAlarmReceiver.kt`: `ACTION_MISSED` const + branch → `onAlarmMissed`; `pendingMissedIntent` helper. [kotlin-static] → NA-3a.
|
||||
- [x] 2.5 `startAlarm`: if `firingRecordAgeMillis(id) > AUTO_SILENCE_MILLIS` → abort + `onAlarmMissed` cleanup; else `recordFiring(id)` before `MediaPlayer.start()`; keep `START_NOT_STICKY`. [kotlin-static] → NA-4a, NA-4b. Accept: grep shows `recordFiring` textually precedes `MediaPlayer.start()` and the stale-check branch exists.
|
||||
- [x] 2.6 `cleanupStaleFiringRecords()`; call from `reschedulePersistedAlarms` before rescheduling. [kotlin-static] → NA-5a. Accept: grep shows call precedes the reschedule loop.
|
||||
- [x] 2.7 `AlarmNotificationStrings.kt` `missedTitle`/`missedText` getters+setters; `MainActivity.kt setNotificationStrings` passes them through. [kotlin-static] → NA-3a.
|
||||
|
||||
## Phase 3: Channel Bridge (Kotlin, static-edit)
|
||||
|
||||
- [x] 3.1 `MainActivity.kt`: add `getActiveRingingAlarmId` (sync companion read) and `stopActiveAlarm` (`{stopped, wasRinging, activeAlarmId}`; internal exception → `result.error("STOP_FAILED", msg, null)`). [kotlin-static] → Decision 1, NA-1a, NA-1b. Accept: grep shows both method names in the channel dispatcher + the `error()` call.
|
||||
|
||||
## Phase 4: Dart Orchestration — Stop Result + Mutation Guard (TDD)
|
||||
|
||||
- [x] 4.1 RED — `test/servicios/servicio_alarmas_android_test.dart`: failing test for `ResultadoDetencion{detenido,estabaSonando,alarmaId}` mapping from `stopActiveAlarm`/`alarmaSonandoId()`. [dart-testable] → Decision 5 interface. `flutter test` fails.
|
||||
- [x] 4.2 GREEN — `servicio_alarmas_android.dart`: add `ResultadoDetencion`, `alarmaSonandoId()`, `detenerSonidoActivo()`, `PuertoAlarmasAndroid` additions. `flutter test` green.
|
||||
- [x] 4.3 RED — `test/helpers/fakes_alarmas.dart` + `estado_alarmas_test.dart`: add `fallaDetener`/`alarmaSonandoIdValor`/`detencionesActivas`; failing tests for SS-1a/SS-1b/SS-1c/SS-1d (`cambiarActiva(false)`/`guardarAlarma`/`eliminarAlarma` stop only when target id == ringing id). [dart-testable]
|
||||
- [x] 4.4 GREEN — `estado_alarmas.dart`: add `_detenerSiEstaSonando(id)` guard; wire into `guardarAlarma`, `cambiarActiva(false)`, `eliminarAlarma`. `flutter test` green.
|
||||
- [x] 4.5 RED — failing tests for SS-2a/SS-2b: `finalizarEjecucion` calls `detenerSonidoActivo()` directly; confirmed → no `_error`; unconfirmed/failure → `_error` set. [dart-testable]
|
||||
- [x] 4.6 GREEN — `estado_alarmas.dart`: wire `finalizarEjecucion` accordingly. `flutter test` green.
|
||||
|
||||
## Phase 5: Dart UX — Force-Stop Affordance (TDD)
|
||||
|
||||
- [x] 5.1 RED — failing test for SS-3b: `forzarDetencion()` re-invokes `detenerSonidoActivo()`; success clears failure state, failure keeps it. [dart-testable]
|
||||
- [x] 5.2 GREEN — `estado_alarmas.dart`: add `forzarDetencion()`. `flutter test` green.
|
||||
- [x] 5.3 RED — `test/pantallas/pantalla_alarma_sonando_test.dart`: failing widget test for SS-3a/SS-3c (failure → screen stays up with a persistent `alarmForceStopAction` banner; confirmed → no banner). [dart-testable]
|
||||
- [x] 5.4 GREEN — `pantalla_alarma_sonando.dart`: on `_detener()` failure, stay on screen and render a persistent in-screen banner (not a timed SnackBar) with a force-stop action → `forzarDetencion()`; clears only on a confirmed stop. `flutter test` green.
|
||||
- [x] 5.5 **(review round 3 correction)** `_forzarDetencion()` now respects `_salidaEnCurso` (guard reset on failure) and the ringing screen reconciles an external MISSED transition for its own alarm id (`EstadoAlarmas.ultimaAlarmaPerdidaId`) by auto-dismissing. [dart-testable]
|
||||
|
||||
## Phase 6: Dart — Missed-Event Bookkeeping (TDD)
|
||||
|
||||
- [x] 6.1 RED — failing test: native `missed` event → `completarEjecucion` records it. [dart-testable]
|
||||
- [x] 6.2 GREEN — `estado_alarmas.dart`: wire missed event in `_alRecibirEventoNativo`. `flutter test` green.
|
||||
|
||||
## Phase 7: l10n (corrected policy — en+es only)
|
||||
|
||||
- [x] 7.1 `lib/l10n/app_en.arb`: add `alarmStopFailedMessage`, `alarmForceStopAction`, `alarmMissedNotificationTitle`, `alarmMissedNotificationText({name})` with `@`-metadata. [dart-testable] Accept: `flutter test` (Phase 5 widget test) resolves the new keys.
|
||||
- [x] 7.2 `lib/l10n/app_es.arb`: mirror the 4 keys, placeholder metadata byte-exact with `en`. [dart-testable]
|
||||
- [x] 7.3 **(review round 2 — superseded correction)** The original "do NOT touch the other 11 locales" policy above was itself superseded by the round-2 review fix: the 4 keys were deliberately ADDED, with real translations, to all 13 `lib/l10n/app_*.arb` files instead of falling back to `en`. Accept: CI ARB placeholder-corruption guard passes across all 13 files.
|
||||
- [x] 7.4 Wire missed-alarm strings through setNotificationStrings (gap found in apply verification)
|
||||
- [x] 7.5 **(review round 3 correction)** Native KDoc (`AlarmScheduler.AUTO_SILENCE_MILLIS`, `AlarmNotificationStrings.missedText`) updated to reference all 13 ARB files instead of only `en`/`es`, matching 7.3.
|
||||
|
||||
## Phase 8 — Slice B (P1 follow-up, not part of this delivery)
|
||||
|
||||
- [ ] 8.1 Diagnostics banners bound to `EstadoAlarmas.diagnostico.canUseFullScreenIntent/notificationsEnabled/canScheduleExactAlarms`; new warning strings (en+es only, same policy as Phase 7). [dart-testable] → NA-6b, NA-6c.
|
||||
- [ ] 8.2 RED→GREEN Dart tests for exact-alarm-denied and `POST_NOTIFICATIONS`-denied warnings. [dart-testable] → NA-6b, NA-6c.
|
||||
- [ ] 8.3 Confirm FSI auto-fallback needs no code change (platform degrades `setFullScreenIntent` when `canUseFullScreenIntent()` is false); log as device-QA-only. [kotlin-static]+[device-qa] → NA-6a.
|
||||
|
||||
## On-Device QA Checklist (collected — run on real hardware)
|
||||
|
||||
- [ ] Stop from ringing UI, id match AND id mismatch (NA-1a, NA-1c)
|
||||
- [ ] Stop from lock-screen notification (SS-4a, NA-1a — after Phase 1.4 fix)
|
||||
- [ ] Disable/edit/delete the ringing alarm from the list (SS-1a/b/c)
|
||||
- [ ] Leave a fired alarm untouched 10 minutes → audio stops, missed notification posts, repeating rearms, one-shot stays disabled (NA-3a/b/c)
|
||||
- [ ] Kill the app mid-ring → audio stops
|
||||
- [ ] Reboot mid-ring → boot cleanup runs, no audio resurrection (NA-5a)
|
||||
- [ ] Trigger a concurrent second alarm while one rings (NA-1c)
|
||||
- [ ] Force FSI-denied state → heads-up fallback posts instead of full-screen intent (NA-6a)
|
||||
|
||||
## Open Questions Carried to Apply
|
||||
|
||||
- [ ] Missed-notification channel: reuse pre-notice channel (default assumed above) vs. dedicated low-importance channel — confirm before Phase 2.3 lands.
|
||||
- [ ] `finalizarEjecucion` return contract: `Future<bool>` vs. existing `_error`-field convention (default assumed above: `_error`) — confirm before Phase 4.6 lands.
|
||||
Reference in New Issue
Block a user