docs(openspec): archive native-alarm-ring and update the native-alarms spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s

Close the SDD cycle for the ring architecture replacement: verified
with one critical (channel silence by omission) fixed and re-checked
before archive, delta merged into the main native-alarms spec (2
requirements removed, 5 added), artifacts archived byte-for-byte.
Phase 3 on-device QA (9 items) remains the mandatory human gate.
This commit is contained in:
2026-07-12 12:36:22 +02:00
parent a788eabfcb
commit 41b95fed44
9 changed files with 1336 additions and 55 deletions
@@ -0,0 +1,323 @@
# Design: Native Alarm Ring
Reads: `proposal.md` (#2388), `explore.md` (#2387). This is the HOW at the architectural
level. Task breakdown lives in `tasks.md` (next phase).
## 1. Context and constraints
The ring failed 5 on-device iterations under a split-ownership design (Dart ramp → hand off to
native → override `STREAM_MUSIC` → restore). Root cause: two audio owners (Dart `just_audio`
on `STREAM_MUSIC` and the native `MediaPlayer` on `STREAM_ALARM`) fighting the alarm FGS.
The rebuild collapses ownership to ONE: `PluriWaveAlarmService` is the sole ring-audio owner
for the entire ring. Flutter becomes display-only. This is AOSP DeskClock's `AsyncRingtonePlayer`
shape. The current native service is ~80% there; the change mostly DELETES accidental complexity.
Hard constraints (from exploration, unchanged by this design):
- Scheduling stack, receiver `ACTION_FIRE` ordering, wake-lock/FGS-before-audio ordering,
snooze truth-ownership (5 entry points), pre-notice + snooze-countdown chains, id-scoped stop.
- No system stream volume is ever written (`setStreamVolume` forbidden on every stream).
- Kotlin is not agent-compilable → on-device QA is the acceptance gate.
Verified facts (read in full this phase):
- Ring channel `pluriwave_alarm_fire_v2` is referenced ONLY by `PluriWaveAlarmService`
(`buildNotification` L425, companion L554, `ensureChannel` L632). Pre-notice + snooze-countdown
use a SEPARATE channel `pluriwave_alarm_pre_notice` (`PluriWaveAlarmReceiver.CHANNEL_ID`, built
in `AlarmScheduler.ensurePreNoticeChannel`). The channel bump is fully scoped to the ring.
- `PluriWaveBootReceiver` only calls `reschedulePersistedAlarms()` — no FGS from boot (A15+ safe).
- The screen-exit stop path is intact: `finalizarEjecucion(id)``ocultarNotificacionAlarma(id)`
→ channel `dismissAlarmNotification``PluriWaveAlarmService.stop(ctx,id)` + `dismissFireNotification(id)`.
In `stopAlarm`, `id == activeAlarmId` takes the FULL-teardown branch; a different id only cancels
that id's notification. Id-scoped stop for the ACTIVE id is CONFIRMED working.
- `minSdk = flutter.minSdkVersion` → the pre-O audio-focus branch is potentially reachable; the
focus API must be version-guarded.
## 2. Architecture approach
Single-owner native audio, thin Dart UI, no cross-boundary audio protocol.
```
ACTION_FIRE ─→ PluriWaveAlarmReceiver ─→ PluriWaveAlarmService.startAlarm
│ wake lock → startForeground(silent v3) BEFORE audio
│ request AUDIOFOCUS_GAIN_TRANSIENT (USAGE_ALARM)
│ capture fade anchor (elapsedRealtime)
startAudio ─→ 3-stage fallback (station → fallback station → WAV)
│ each source: setVolume(computeFadeVolume(...)) BEFORE start
│ ONE ring-anchored 50 ms fade Handler loop (dB curve)
plays until ACTION_STOP / ACTION_SNOOZE / onDestroy
│ cancel fade → player.stop/release → abandon focus
│ → release wake lock → cancel notif → stopForeground
MainActivity ─ alarmFired ─→ EstadoAlarmas ─→ app.dart pushes PantallaAlarmaSonando (DISPLAY ONLY)
│ Stop / Snooze / back
finalizarEjecucion / posponerAlarma ─→ ocultarNotificacionAlarma
PluriWaveAlarmService.stop(activeId) (native stop = the ONLY audio stop)
```
Layering / boundaries:
- Native (Kotlin): owns ALL ring audio, fade, focus, notification. No Dart audio dependency.
- Bridge (MethodChannel `pluriwave/alarm_scheduler`): loses `confirmFlutterAudio`,
`overrideMediaVolumeForRing`, `restoreMediaVolume`. Keeps scheduling / dismiss / snooze / diagnostics.
- Dart port (`PuertoAlarmasAndroid`): loses the 3 handoff/override methods; screen is pure UI.
- Audio-focus mechanism (not app code) handles the user's normal radio: `AUDIOFOCUS_GAIN_TRANSIENT`
transiently pauses `STREAM_MUSIC` playback; abandoning on stop lets it resume. The screen never
touches `EstadoRadio` — the OS focus policy replaces the old manual pause/restore.
## 3. Component map and data flow
| Component | Change | Detail |
|-----------|--------|--------|
| `PluriWaveAlarmService.kt` | Rebuild | dB-curve fade (pure fn + single 50 ms loop, ring-anchored), manual focus, silent v3 channel, delete `flutterOwnsRing` + both backstop sites; keep wake lock / FGS-before-audio / 3-stage fallback / id-scoped stop verbatim |
| `MainActivity.kt` | Delete surface | remove `confirmFlutterAudio` / `overrideMediaVolumeForRing` / `restoreMediaVolume` handlers, private `overrideMediaVolumeForRing` / `restoreMediaVolume`, companion `mediaVolumeOverridden` / `capturedMediaVolume` / `restoreMediaVolumeBestEffort` |
| `pantalla_alarma_sonando.dart` | Rewrite → pure UI | delete all audio orchestration; keep single-exit guard, Stop/Snooze/back → EstadoAlarmas, PopScope, `_dismissScreen`, snooze options, layout |
| `app.dart` | Delete pre-start | remove `_prearrancarAudioAlarma` + call, `audioPrearrancado` arg, `_volumenInicialFadeInAlarmas`; keep SKIP/POSTPONE/PRE_NOTICE routing + duplicate-delivery guard |
| `servicio_alarmas_android.dart` | Reduce port | drop `confirmarAudioFlutter` / `forzarVolumenMediaParaAlarma` / `restaurarVolumenMedia` from interface + impl |
| `test/helpers/fakes_alarmas.dart` | Reduce fake | drop the 3 overrides + `volumenForzado` / `volumenRestaurado` / `puertaConfirmarAudioFlutter` / `fallaConfirmarAudioFlutter` |
| 4 test files | Rewrite/trim | see §7 |
## 4. Decisions (ADR)
### D1 — Fade as a pure function + one ring-anchored 50 ms Handler loop
**Decision.** Extract the DeskClock curve to a pure, side-effect-free function so the math is
inspectable and JVM-testable later:
```
private const val FADE_TICK_MILLIS = 50L
private const val FADE_RANGE_DB = 40.0f // DeskClock: -40 dB → 0 dB ≈ 1% → 100% amplitude
fun computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float {
if (fadeMs <= 0L) return ceiling.coerceIn(0f, 1f)
val fraction = (elapsedMs.toFloat() / fadeMs.toFloat()).coerceIn(0f, 1f)
val gainDb = fraction * FADE_RANGE_DB - FADE_RANGE_DB // -40..0 dB
val curve = Math.pow(10.0, (gainDb / 20.0)).toFloat() // 0.01..1.0
return (ceiling * curve).coerceIn(0f, 1f)
}
```
A SINGLE `Handler` loop (50 ms tick) drives the whole ring, reading the current `player` field
each tick so it survives the 3-stage source swap. `ceiling = volume` (the per-alarm scalar, already
wired via `EXTRA_VOLUME`). At `fraction=0``ceiling*0.01`; at `fraction=1``ceiling`.
**Rejected.** (a) Per-source fade restarts (current shape) — a source swap would restart the
crescendo from 1% after the user already waited. (b) Linear-amplitude ramp (current `startFadeIn`)
— inaudible for the first half, the exact defect being fixed. (c) 250 ms tick — audibly stepped at
low gain where the ear is most sensitive; 50 ms is the DeskClock cadence.
**Replaces.** `startFadeIn`, `initialVolume`, `FADE_IN_STEP_MILLIS`, `FADE_IN_START_FRACTION`.
### D2 — Anchor the fade at RING start, not at audio start
**Decision.** Capture `fadeAnchorElapsedMs = SystemClock.elapsedRealtime()` once in `startAlarm`
(before audio). Every tick and every pre-start `setVolume` computes `elapsed = now - anchor`.
**Why.** Matches the on-device-proven behavior just shipped (commit 2e64740, "anchor the fade at
alarm time"): a slow-buffering station or a 15 s stage timeout must NOT freeze/restart the ring at
1%. When a source finally prepares, it joins the ramp at the elapsed level (a fresh source starting
at, e.g., 50% via `setVolume` before `start()` is click-free, not a pop).
**Rejected.** Pure DeskClock "anchor at crescendo/audio start" — reintroduces the long-buffer
freeze the app already fixed; the 3-stage fallback makes per-source anchoring user-hostile.
**Trade-off.** After a 15 s station timeout the WAV enters at a higher fade level; acceptable and
preferable to silence — the user has already waited in silence.
### D3 — Manual audio focus: `AUDIOFOCUS_GAIN_TRANSIENT`, version-guarded, no ducking listener
**Decision.** Request focus ONCE in `startAudio` (before the fallback chain, so it covers whichever
source plays); abandon in `stopAlarm`'s full-teardown branch (next to `releaseWakeLock`, NOT in the
mismatched-id early return). Use the modern API on O+ and the deprecated stream API below:
```
// O+ : AudioFocusRequest.Builder(AUDIOFOCUS_GAIN_TRANSIENT)
// .setAudioAttributes(alarmAudioAttributes()) // USAGE_ALARM
// .setOnAudioFocusChangeListener { } // no-op: an alarm does not duck/stop
// .build() → store to abandon the exact request
// < O : requestAudioFocus(noopListener, AudioManager.STREAM_ALARM, AUDIOFOCUS_GAIN_TRANSIENT)
```
**Why.** ExoPlayer's auto-focus throws for non-media usages; DeskClock requests focus manually.
`TRANSIENT` (not `GAIN`) so the user's music/radio auto-resumes when we abandon. The listener is a
no-op because an alarm must keep ringing through focus changes.
**Rejected.** (a) No focus request — the user's `STREAM_MUSIC` radio keeps playing under the alarm
(the exact double-audio symptom, now solved by the OS instead of manual Dart pause). (b) Permanent
`GAIN` — music would not resume after dismiss.
**Note.** If `minSdk >= 26` the pre-O branch is dead code and may be dropped; the guard is kept
for correctness regardless.
### D4 — Silent channel `pluriwave_alarm_fire_v3` + single `channels_migrated_v3` migration
**Decision.** New channel id `pluriwave_alarm_fire_v3`, `IMPORTANCE_HIGH` (kept — required for FSI),
`setSound(null, null)` (silent: the player is the only audio). Replace the `channels_migrated_v2`
guard with ONE `channels_migrated_v3` guard that deletes all three obsolete ids
(`pluriwave_alarm_native`, `pluriwave_alarm_fire`, `pluriwave_alarm_fire_v2`) — `deleteNotificationChannel`
is a safe no-op when a channel is absent, so this is correct on both fresh installs and v2 devices.
Update the `CHANNEL_ID` constant and its single use in `buildNotification`.
**Why.** Android locks channel sound at creation; editing `_v2` (which set `DEFAULT_ALARM_ALERT_URI`)
is a no-op. A new id is the only way to make the fire notification silent. The v2 migration already
proved this pattern.
**Rejected.** Keeping both `_v2` and `_v3` migration guards — redundant; a folded `_v3` guard is
simpler and equally safe.
**Scope.** Only the ring channel resets user-visible settings; the pre-notice channel is untouched.
### D5 — Keep channel vibration ON (minimal change) rather than an explicit Vibrator
**Decision.** `enableVibration(true)` on `_v3`, as `_v2` had.
**Why.** DeskClock vibrates via a separate `Vibrator`, but adding one here expands surface for no
functional gain; the channel-level vibration already works. Minimal change wins.
**Rejected.** Explicit `Vibrator` with a pattern — more code, new permission/coordination surface,
out of proportion to the ring-audio goal. Can be a follow-up if per-alarm vibration control is wanted.
### D6 — Keep MediaPlayer; reject ExoPlayer/Media3 now
**Decision.** MediaPlayer stays for all three sources.
**Why.** It is already pop-free and already streams stations successfully. ExoPlayer has an
unresolved start-pop (#2752, ~200 ms at full gain) and would need muted-preroll mitigation.
**Rejected.** ExoPlayer/Media3 for ICY metadata + reconnect — real benefits, but not worth the pop
risk for the ring; revisit only if station reconnect proves insufficient on device.
### D7 — Kill the handoff surface entirely
**Decision.** Delete `flutterOwnsRing` (field, reset in `startAlarm`, both guard reads in
`stopAlarm`/`onDestroy`, and the companion doc); delete `MainActivity.restoreMediaVolumeBestEffort`
and its two call sites; delete the `confirmFlutterAudio`/`overrideMediaVolumeForRing`/`restoreMediaVolume`
handlers + private methods + companion state. On the Dart side delete the matching port methods,
service impls, and fake members.
**Why.** With one owner there is no handoff to signal and no stream to restore — "restore" is a
no-op by construction because no system volume is ever written. Every guard that existed to keep the
two owners from silencing each other becomes dead.
**Verification (this phase).** Grepped every reference; the only callers are the sites listed above.
Nothing else in the codebase reads/writes these symbols.
### D8 — Ring-state visibility: NO new channel; status from static alarm config
**Decision.** Do NOT add a native→Dart ring-state event. The screen's status line (currently the
tri-state `_fallbackActivo`/`_radioIntentada` text) is re-sourced from `widget.alarma` static config
(e.g. the configured station name when `alarma.emisora != null`, else a neutral ringing label). The
`Text` widget stays in place so the visual layout is unchanged.
**Why.** In native-only, Dart cannot know which source is playing without a new channel — which
would contradict "kill the handoff, minimal surface." The system notification already shows the
station name for "what's playing." Evidence: the only screen state that depended on live playback
was that one status line; everything else (time, name, buttons) is already static.
**Rejected.** (a) New ring-state EventChannel — reintroduces a cross-boundary protocol for a cosmetic
label. (b) Removing the line entirely — would alter the layout the proposal says to keep.
**Consequence.** l10n keys `alarmRingingFallbackActive` / `alarmRingingTryingStation` /
`alarmRingingPreparingFallback` become unused; leaving them is harmless (arb cleanup out of scope).
### D9 — Screen exit path unchanged; native stop is the only audio stop
**Decision.** Keep `_detener``finalizarEjecucion(id)` and `_posponer``posponerAlarma`, both of
which already route the native stop via `ocultarNotificacionAlarma`. `_detener`/`_posponer` no longer
touch `EstadoRadio` (no Flutter audio to stop; focus abandon handles the user's radio). Keep the
single-exit `_salidaEnCurso` guard, `PopScope(canPop:false)` back=Stop, `_dismissScreen`
(canPop→pop / else SystemNavigator.pop), snooze options, failure SnackBar (with the pre-captured
`ScaffoldMessenger`).
**Verification (this phase).** Traced `finalizarEjecucion`/`posponerAlarma``ocultarNotificacionAlarma`
`dismissAlarmNotification``PluriWaveAlarmService.stop(activeId)` → full-teardown branch. Confirmed
the active id stops; a different id only cancels its own notification.
### D10 — Keep `USAGE_ALARM + CONTENT_TYPE_MUSIC`
**Decision.** Leave `alarmAudioAttributes()` unchanged and reuse it for the focus request.
**Why.** `USAGE_ALARM` is the load-bearing part — it routes to `STREAM_ALARM`, making the ring
audible at media-volume 0 and uninterruptible. `CONTENT_TYPE` is secondary; MUSIC suits a station
and already works. Changing to `SONIFICATION` is optional and not worth the churn.
### D11 — Defer receiver wake-lock + OEM hardening to `oem-reliability-guidance`
**Decision.** Do NOT add a receiver-level `PARTIAL_WAKE_LOCK` or OEM autostart guidance in this
change. Verified `PluriWaveBootReceiver` already reschedules only (no boot FGS), so nothing to change
there.
**Why.** The proposal scopes this change to the AUDIO rebuild; delivery/OEM hardening is an explicit
downstream follow-up. Keeping them separate keeps this diff focused and revertible.
## 5. Integration points and do-not-touch verification
- Notification chains are INDEPENDENT: pre-notice + snooze-countdown live on `pluriwave_alarm_pre_notice`
via `AlarmScheduler.ensurePreNoticeChannel`; `dismissFireNotification` only cancels the fire
notification by id; `cancelAlarm` cancels pending intents + both notifications but never touches the
ring player. The `_v2→_v3` bump cannot affect them.
- Snooze truth-ownership unchanged: native `ACTION_SNOOZE` still re-arms via `AlarmScheduler.snooze`
and reports back through `notifyAlarmEvent`; `EstadoAlarmas._alRecibirEventoNativo` still records it.
Killing the handoff does not touch any of these paths.
- Wake-lock / `startForeground`-before-audio ordering, 3-stage fallback with 15 s timeouts, and the
id-scoped `stopAlarm` mismatch branch are preserved verbatim.
## 6. Data flow — teardown detail
```
Stop / Snooze / system-back ─→ _detener / _posponer (single-exit guard)
│ finalizarEjecucion(id) | posponerAlarma(alarma, min)
EstadoAlarmas ─→ android.ocultarNotificacionAlarma(id) ─ MethodChannel dismissAlarmNotification
PluriWaveAlarmService.stop(id) ─→ stopAlarm(id==active) ─→ cancel fade loop → player.stop()/release()
→ abandon audio focus → release wake lock → cancel fire notif → stopForeground → stopSelf
```
No stream restore, no Dart player teardown — there is nothing to undo.
## 7. Test design
Dart `flutter test` compiles the WHOLE test library as one unit: any file referencing a removed
symbol breaks the entire suite. This drives both the rewrite list and the work-unit split (§8).
Dies (compile-forced by port/param removal):
- `servicio_alarmas_android_test.dart`: DELETE the two tests `forzarVolumenMediaParaAlarma invoca
overrideMediaVolumeForRing` (L110-122) and `restaurarVolumenMedia invoca restoreMediaVolume`
(L124-136). Keep the scheduleAlarm-payload and battery tests.
- `pantalla_alarma_sonando_test.dart`: DELETE groups `rampa anclada y override diferido al primer
audio` (L172-222) and `handoff con audio prearrancado ya reproduciendo (regresion)` (L224-246);
DELETE `salir a mitad de rampa restaura la ganancia del reproductor` (L273-291) and `restore de
volumen de medios con dispose como unico llamador` (L294-308). Remove the `audioPrearrancado` arg
and the `audioYaReproduciendo` gate from `_montarPantalla`.
- `pantalla_alarma_sonando_dismiss_guard_test.dart`: DELETE group `media-volume override restore
(Slice 2)` (L360-429). Remove `audioPrearrancado:true` from both mount helpers.
Rewritten as pure-UI (buttons → EstadoAlarmas, navigation, SnackBar; NO audio asserts):
- `pantalla_alarma_sonando_test.dart` KEEPS: snooze-options rendering (L126-148); `posponer 5 min`
(L150-170) trimmed to assert `snoozeHasta` + `ocultadas contains id` + `programadas.last.snoozeHasta`
+ screen dismissed (drop `audio.pausas`); system-back = Stop (L248-271) trimmed to assert dismissed
+ `ocultadas contains id` (drop `audio.pausas`, drop `volumenForzado`).
- `pantalla_alarma_sonando_dismiss_guard_test.dart` KEEPS: dismiss-guard canPop/SystemNavigator group
(L183-297) and snooze-failure SnackBar group (L299-358), unchanged except mount-helper signature.
- `pantalla_alarma_sonando_scaffold_test.dart`: unchanged asserts; only drop the `audioPrearrancado`
arg. Both scaffold/animation tests survive.
Decoupling proof (reframed, KEEP): the `EstadoRadio ... nunca dispara el override de volumen`
regression (L431-445) can no longer assert `volumenForzado`/`volumenRestaurado` (removed). Reframe it
as: a normal `reproducir`/`pausar` cycle leaves the alarm port untouched (`programadas` / `detenidas`
/ `ocultadas` all empty). The strongest decoupling guarantee is now COMPILE-TIME: the fake no longer
HAS override methods, so no screen code can call them.
Fade math: `computeFadeVolume` is documented as a pure Kotlin function for inspection. No JVM harness
is added (Kotlin has none today). On-device QA checklist is the acceptance gate:
1. Audible exponential curve (not linear/late) at a configured fade of 1530 s.
2. Volume ceiling correct: "50%" ≈ 50% of the device ALARM knob at full ramp.
3. No start pop on station, fallback station, and WAV.
4. No second audio source (native only; Dart plays nothing).
5. Dismiss / snooze / system-back all stop the ring and abandon focus (user's music resumes).
6. Media volume 0 still rings (USAGE_ALARM); ALARM stream 0 is silent (accepted).
7. Screen-off FSI delivery + ring after HyperOS Autostart enabled (POCO X7 Pro).
## 8. Work-unit split — 2 units, Dart-first (stacked-to-main)
The proposal suggested 3 units (Kotlin / Dart / tests). This design consolidates to **2**, and
reverses the order, with justification.
- **WU1 — Dart + fakes + tests (ONE unit).** `pantalla_alarma_sonando.dart`, `app.dart`,
`servicio_alarmas_android.dart` (port+impl), `fakes_alarmas.dart`, and all 4 test files.
*Why atomic:* removing `audioPrearrancado` and the 3 port methods breaks compilation of every test
still referencing them; Dart test compilation is whole-suite, so the surviving tests cannot even
run until the tests are rewritten in the SAME unit. Splitting "Dart lib" from "tests" is impossible
for a green PR.
- **WU2 — Kotlin.** `PluriWaveAlarmService.kt` rebuild (dB curve, manual focus, silent v3 channel,
delete `flutterOwnsRing` + backstops) and `MainActivity.kt` surface deletion. On-device QA gate.
**Order = Dart-first, and it matters.** Each stacked PR must be shippable:
- Dart-first intermediate (new pure-UI Dart + OLD native): the OLD native already plays the full ring
via its 3-stage fallback until `stopAlarm`; new Dart calls none of the deleted handlers, so there is
NO double audio and NO `notImplemented` exception. The ring works (old linear fade + the pre-existing
channel-sound overlap; native on `STREAM_ALARM` so media-0 still rings). This is not worse than what
is shipped at 2e64740 — it is better (the failed handoff is gone). SHIPPABLE.
- Kotlin-first intermediate (OLD Dart + new native) would be BROKEN: WU2 deletes the `MainActivity`
handlers while old Dart still calls `confirmFlutterAudio`/override/restore → `confirmFlutterAudio`
no longer stops the native, and old Dart simultaneously starts its `_fallbackPlayer`/radio ramp →
DOUBLE AUDIO on device. Rejected.
Therefore: ship WU1 (Dart) first, WU2 (Kotlin) second. After WU2, no Dart code references the deleted
native handlers, so their removal is clean.
## 9. Risks and assumptions
| Risk / assumption | Severity | Mitigation |
|-------------------|----------|------------|
| Kotlin not agent-compilable | High | On-device QA checklist (§7) is the gate; fade math isolated in a pure fn for inspection |
| Focus request pauses user's radio but resume UX differs per OEM | Medium | `TRANSIENT` is the standard; verify resume on device (checklist #5) |
| `_v3` channel bump resets ring-channel settings for users | Low (expected) | Release note; pre-notice channel untouched; revert recreates `_v2` harmlessly |
| Dart-first intermediate shows old fade / channel-sound overlap briefly | Low | It is ≤ current shipped behavior; only exists between WU1 and WU2 |
| Anchor-at-ring-start makes WAV enter loud after a 15 s station timeout | Low | Intended (§D2); preferable to silence |
| Assumption: OS focus policy replaces manual radio pause on all target devices | Medium | Validate on POCO X7 Pro; fallback is a follow-up, not a blocker |