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.
56 lines
7.3 KiB
Markdown
56 lines
7.3 KiB
Markdown
# Exploration: native-alarm-ring
|
||
|
||
Consolidates three research briefs (2026-07-12): reliable-delivery research (AOSP DeskClock + MIUI/HyperOS), audio-architecture research (DeskClock AsyncRingtonePlayer source study + ExoPlayer/just_audio specifics), and the full code map of the current alarm business (@ `2e64740`). Supersedes the retired media-stream/handoff design after 5 consecutive on-device failures.
|
||
|
||
## Verdict
|
||
|
||
**The scheduling stack is CORRECT and untouched** (setAlarmClock + receiver + FGS = AOSP DeskClock's exact pattern; verified healthy on the user's device via dumpsys: whitelisted, EXEMPTED bucket, exact Alarm-clock entries). **The ring audio architecture is wrong and gets rebuilt native-only.**
|
||
|
||
## Target architecture (AOSP DeskClock pattern)
|
||
|
||
ONE player, owned by `PluriWaveAlarmService`, on `USAGE_ALARM`, for the ENTIRE ring. Flutter is pure UI (stop/snooze buttons → existing EstadoAlarmas methods). No handoff, no media-stream override, no Dart ramp, no restore — the design makes "restore" a no-op because system volumes are NEVER written.
|
||
|
||
### Audio spec (from DeskClock AsyncRingtonePlayer source, verbatim mechanism)
|
||
- AudioAttributes: `USAGE_ALARM` + `CONTENT_TYPE_SONIFICATION` (music content for station is fine); audio focus requested MANUALLY: `AUDIOFOCUS_GAIN_TRANSIENT` on `STREAM_ALARM` (ExoPlayer auto-focus THROWS for non-media usages — must pass handleAudioFocus=false)
|
||
- Per-alarm volume = PLAYER scalar `s ∈ (0,1]` via `setVolume(s)`; effective loudness = device STREAM_ALARM knob × s. NEVER call setStreamVolume on ANY stream (neither MUSIC nor ALARM)
|
||
- Fade (fadeInSegundos > 0): exponential-in-amplitude / linear-in-dB curve, 50ms tick:
|
||
`fractionDone = clamp(elapsed/fadeMs, 0, 1); gainDb = fractionDone*RANGE - RANGE; curve = 10^(gainDb/20); setVolume(s * curve)` with `RANGE = 40.0` dB (DeskClock constant: -40dB→0dB ≈ 1%→100% amplitude). Anchored at ring start.
|
||
- No-fade path: `setVolume(s)` BEFORE `start()` — click-free on MediaPlayer
|
||
- Pop avoidance: `setVolume(0,0)` (or `s`) BEFORE prepare/play. ExoPlayer has a documented unresolved start-pop (#2752, ~200ms at full gain) — MediaPlayer does not. DECISION for design: station playback via ExoPlayer/Media3 (ICY metadata + robust HTTP reconnect, pop risk accepted/mitigated by muted preroll) vs MediaPlayer for everything (current service already uses MediaPlayer for stations successfully — simplest: KEEP MediaPlayer, it already works and is pop-free; ExoPlayer only if ICY/reconnect proves necessary). Bundled WAV fallback stays MediaPlayer.
|
||
- The alarm must NOT touch audio_service/just_audio/media-session surfaces (wrong semantics, fights the alarm FGS)
|
||
|
||
### Current native service is 80% there
|
||
`PluriWaveAlarmService.startAlarm` already: wake lock → startForeground BEFORE audio → 3-stage source fallback (station → fallback station → WAV, 15s timeouts) → MediaPlayer on USAGE_ALARM → linear fade (`startFadeIn`, 250ms steps, 5% start). Gaps vs target: (1) fade curve is linear-amplitude, replace with dB curve @50ms; (2) per-alarm `volume` must become the ceiling scalar of the curve (already passed via intent extras incl. EXTRA_FADE_IN_SECONDS — wiring verified end to end); (3) the handoff teardown (`confirmFlutterAudio` → stop) must DIE: service plays until stop/snooze; (4) manual audio focus request/abandon; (5) notification channel currently plays DEFAULT_ALARM_ALERT_URI on top of the player (channel has sound, notification not silent) — must be silenced so the only audio is the player; (6) ring-state event to Dart so the screen can reflect state (optional, screen is display-only).
|
||
|
||
### What gets DELETED (from the code map)
|
||
- Dart ring-audio orchestration: `_prearrancarAudioAlarma` (app.dart:376-391), `_iniciarFadeIn`/`_aplicarVolumenGlobal`/`_forzarVolumenMediaUnaVez`/`_confirmarAudioFlutterListo`/`_silenciarAudio` audio parts + `_fallbackPlayer` (pantalla_alarma_sonando.dart)
|
||
- Channel surface: `confirmarAudioFlutter`, `forzarVolumenMediaParaAlarma`, `restaurarVolumenMedia` (PuertoAlarmasAndroid + ServicioAlarmasAndroid + MainActivity handlers + fakes)
|
||
- Kotlin: `overrideMediaVolumeForRing`/`restoreMediaVolume`/`restoreMediaVolumeBestEffort` + companion state (MainActivity), `flutterOwnsRing` flag + backstops (PluriWaveAlarmService)
|
||
- Tests that die: ramp/handoff/override groups in pantalla_alarma_sonando_test.dart (172-291), media-volume-restore group in dismiss_guard_test (360-429)
|
||
|
||
### What must NOT change (do-not-break list from the map)
|
||
- Entire scheduling stack: AlarmScheduler.kt scheduling/persistence/boot, ServicioAlarmas/ServicioProgramacionAlarmas, EstadoAlarmas CRUD/snooze-truth-ownership/skip/vacations, PluriWaveBootReceiver
|
||
- Receiver ACTION_FIRE ordering (re-arm → service → activity); wake-lock/startForeground-before-audio ordering
|
||
- Snooze flows (5 entry points, truth-ownership directions documented in map §1), pre-notice + snooze-countdown notification chains, AlarmNotificationStrings/NotificationBrand
|
||
- Radio/audio_service/EQ/sleep-timer/recording/BT — only their INCIDENTAL use by the ring goes away. Regression guard test (EstadoRadio never touches override channel) becomes the decoupling proof — keep/extend
|
||
- Dismiss/snooze navigation semantics (canPop/SystemNavigator, SnackBar-on-failure) — rewrite setup, keep assertions
|
||
- Ring screen keeps: buttons → EstadoAlarmas.finalizarEjecucion/posponerAlarma (native stop happens via detener/ocultar as today), PopScope back=Stop, single-exit guard
|
||
|
||
### Delivery hardening (same change or fast-follow — design decides)
|
||
- Explicit PARTIAL_WAKE_LOCK at top of PluriWaveAlarmReceiver.onReceive (DeskClock pattern; currently only the service takes one)
|
||
- Do NOT start mediaPlayback FGS from BOOT_COMPLETED on A15+ (boot receiver only reschedules — verify current code complies)
|
||
- Runtime status surface: canScheduleExactAlarms / isIgnoringBatteryOptimizations / canUseFullScreenIntent(API34+) — EstadoAlarmas.cargarDiagnostico exists; extend + in-app guidance screen incl. MIUI autostart deep link (`miui.intent.action.OP_AUTO_START`, launch-only, not queryable) → SEPARATE follow-up change (oem-reliability-guidance)
|
||
|
||
## Product semantics (communicated to user)
|
||
- "Volumen 50%" = 50% relative to the device ALARM stream knob (like Google Clock). Media volume 0 → rings regardless (free). Alarm stream 0 → silent like the system clock (accepted; optional override NOT adopted)
|
||
- EQ does not apply during the ring (native player has no EQ pipeline); normal radio unaffected
|
||
- Dismiss restores nothing because nothing was changed
|
||
|
||
## User device facts (POCO X7 Pro "rodin", HyperOS, v0.1.93 tested)
|
||
- Standard Android layers all green (deviceidle whitelist ✓, standby bucket 5=EXEMPTED ✓, RUN_ANY_IN_BACKGROUND allow ✓, alarms registered exact with Alarm-clock sections ✓)
|
||
- Remaining delivery suspect: HyperOS Autostart (Ajustes → Aplicaciones → Administrar aplicaciones → PluriWave → Inicio automático) + MIUI battery saver "Sin restricciones" — user enabling now (Fase 1)
|
||
|
||
## Testability
|
||
- Dart: scheduling tests all survive; ring-screen tests rewritten as pure-UI (buttons → EstadoAlarmas calls, navigation, SnackBar); channel-shape tests for the REDUCED PuertoAlarmasAndroid surface
|
||
- Kotlin: no test harness (unchanged) — the dB-curve math could be extracted to a pure function; on-device QA is the real gate: fade audible curve, volume ceiling, no pop, dismiss/snooze/back, screen-off delivery post-Autostart
|