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.
23 KiB
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_FIREordering, 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 (
setStreamVolumeforbidden 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_v2is referenced ONLY byPluriWaveAlarmService(buildNotificationL425, companion L554,ensureChannelL632). Pre-notice + snooze-countdown use a SEPARATE channelpluriwave_alarm_pre_notice(PluriWaveAlarmReceiver.CHANNEL_ID, built inAlarmScheduler.ensurePreNoticeChannel). The channel bump is fully scoped to the ring. PluriWaveBootReceiveronly callsreschedulePersistedAlarms()— no FGS from boot (A15+ safe).- The screen-exit stop path is intact:
finalizarEjecucion(id)→ocultarNotificacionAlarma(id)→ channeldismissAlarmNotification→PluriWaveAlarmService.stop(ctx,id)+dismissFireNotification(id). InstopAlarm,id == activeAlarmIdtakes 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): losesconfirmFlutterAudio,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_TRANSIENTtransiently pausesSTREAM_MUSICplayback; abandoning on stop lets it resume. The screen never touchesEstadoRadio— 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_noticeviaAlarmScheduler.ensurePreNoticeChannel;dismissFireNotificationonly cancels the fire notification by id;cancelAlarmcancels pending intents + both notifications but never touches the ring player. The_v2→_v3bump cannot affect them. - Snooze truth-ownership unchanged: native
ACTION_SNOOZEstill re-arms viaAlarmScheduler.snoozeand reports back throughnotifyAlarmEvent;EstadoAlarmas._alRecibirEventoNativostill 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-scopedstopAlarmmismatch 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 testsforzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing(L110-122) andrestaurarVolumenMedia invoca restoreMediaVolume(L124-136). Keep the scheduleAlarm-payload and battery tests.pantalla_alarma_sonando_test.dart: DELETE groupsrampa anclada y override diferido al primer audio(L172-222) andhandoff con audio prearrancado ya reproduciendo (regresion)(L224-246); DELETEsalir a mitad de rampa restaura la ganancia del reproductor(L273-291) andrestore de volumen de medios con dispose como unico llamador(L294-308). Remove theaudioPrearrancadoarg and theaudioYaReproduciendogate from_montarPantalla.pantalla_alarma_sonando_dismiss_guard_test.dart: DELETE groupmedia-volume override restore (Slice 2)(L360-429). RemoveaudioPrearrancado:truefrom both mount helpers.
Rewritten as pure-UI (buttons → EstadoAlarmas, navigation, SnackBar; NO audio asserts):
pantalla_alarma_sonando_test.dartKEEPS: snooze-options rendering (L126-148);posponer 5 min(L150-170) trimmed to assertsnoozeHasta+ocultadas contains id+programadas.last.snoozeHasta- screen dismissed (drop
audio.pausas); system-back = Stop (L248-271) trimmed to assert dismissed ocultadas contains id(dropaudio.pausas, dropvolumenForzado).
- screen dismissed (drop
pantalla_alarma_sonando_dismiss_guard_test.dartKEEPS: 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 theaudioPrearrancadoarg. 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:
- Audible exponential curve (not linear/late) at a configured fade of 15–30 s.
- Volume ceiling correct: "50%" ≈ 50% of the device ALARM knob at full ramp.
- No start pop on station, fallback station, and WAV.
- No second audio source (native only; Dart plays nothing).
- Dismiss / snooze / system-back all stop the ring and abandon focus (user's music resumes).
- Media volume 0 still rings (USAGE_ALARM); ALARM stream 0 is silent (accepted).
- 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: removingaudioPrearrancadoand 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.ktrebuild (dB curve, manual focus, silent v3 channel, deleteflutterOwnsRing+ backstops) andMainActivity.ktsurface 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 NOnotImplementedexception. The ring works (old linear fade + the pre-existing channel-sound overlap; native onSTREAM_ALARMso media-0 still rings). This is not worse than what is shipped at2e64740— it is better (the failed handoff is gone). SHIPPABLE. - Kotlin-first intermediate (OLD Dart + new native) would be BROKEN: WU2 deletes the
MainActivityhandlers while old Dart still callsconfirmFlutterAudio/override/restore →confirmFlutterAudiono 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 |