Files
pluriwave/openspec/changes/archive/2026-07-12-native-alarm-ring/design.md
T
FreeTLab 41b95fed44
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
docs(openspec): archive native-alarm-ring and update the native-alarms spec
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.
2026-07-12 12:36:22 +02:00

324 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |