# 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 15–30 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 |