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

23 KiB
Raw Blame History

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 dismissAlarmNotificationPluriWaveAlarmService.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=0ceiling*0.01; at fraction=1ceiling. 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 _detenerfinalizarEjecucion(id) and _posponerposponerAlarma, 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/posponerAlarmaocultarNotificacionAlarmadismissAlarmNotificationPluriWaveAlarmService.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