Files
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

7.3 KiB
Raw Permalink Blame History

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