Files
pluriwave/openspec/changes/archive/2026-07-12-native-alarm-ring/tasks.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

22 KiB

Tasks: Native Alarm Ring

Change: native-alarm-ring Reads: spec.md (delta), design.md (11 ADRs, 2-unit split). Grounding: every file:line below was re-verified against live code this phase (not copied blind from design) — see per-task notes for the handful of spots where live code required a correction to design's phrasing.

Review Workload Forecast

Field Value
Estimated changed lines WU1 (Dart) ~550-650; WU2 (Kotlin) ~300-390; total ~870-1050
400-line budget risk High (WU1 alone, atomicity-locked exception below); Medium-High (WU2, likely near/under 400 alone)
Chained PRs recommended Yes
Suggested split PR 1 (Dart pure-UI + port) -> PR 2 (Kotlin native rebuild)
Delivery strategy stacked-to-main work-unit commits (resolved by orchestrator)
Chain strategy stacked-to-main

Decision needed before apply: No Chained PRs recommended: Yes Chain strategy: stacked-to-main 400-line budget risk: High

The 2-unit split (Dart -> Kotlin) IS the chaining answer, already locked by design Sec.8 with an explicit reversal-justification (Dart-first, not the proposal's original 3-unit/Kotlin-first order). WU1 alone is likely to exceed 400 changed lines; this is a documented, pre-approved exception, not a fresh decision — design Sec.8 proves WU1 cannot be split further: removing audioPrearrancado + the 3 port methods breaks compilation of every test referencing them, and Dart test compilation is whole-suite, so lib and tests must land in the SAME commit for a green PR. No further split is possible without shipping a broken intermediate state. No user decision needed before sdd-apply.

Suggested Work Units

Unit Goal Likely PR Notes
1 Dart pure-UI ring screen + reduced Android port + rewritten tests PR 1 Base: main. Atomic (design Sec.8) — lib+tests in one commit. Intermediate state (new Dart + OLD native) is shippable: no double audio, no notImplemented.
2 Kotlin native audio rebuild (PluriWaveAlarmService) + MainActivity surface deletion PR 2 Base: PR-1 branch. On-device QA gate (Phase 3). No JVM/instrumented test harness exists for this module (confirmed: no android/app/src/test* or src/androidTest* dirs) — code-inspection + static rg checks only.

Strict TDD Mode is active project-wide, but WU1's RED/GREEN staging collapses into one atomic boundary by construction (see Phase 1 header note) — this is design's own justified exception, not a process skip.


Phase 1 — Work Unit 1: Dart pure-UI ring screen + port reduction (PR 1, base: main)

Atomicity note (Strict TDD applicability): classic per-file RED-then-GREEN is impossible here — deleting the 3 PuertoAlarmasAndroid methods and audioPrearrancado breaks compilation of every test still referencing them, and flutter test compiles the whole suite as one unit (design Sec.7-8). The "RED" state is therefore "the suite won't compile," which never gets its own commit; 1.1 fixes the known-good baseline, 1.2-1.11 make the atomic edit (production + tests together), 1.12 is the RED->GREEN collapse point. The strongest guarantee for "screen never touches audio" becomes COMPILE-TIME: the fake has no override methods left, so no screen code can call them.

  • 1.1 [Baseline] flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart — confirm all green BEFORE any edit (reference state the atomic rewrite must return to).

Production edits

  • 1.2 lib/servicios/servicio_alarmas_android.dart — delete from the PuertoAlarmasAndroid interface: confirmarAudioFlutter (L145), forzarVolumenMediaParaAlarma + doc (L147-152), restaurarVolumenMedia + doc (L154-157); delete the matching ServicioAlarmasAndroid overrides (L294-295, L297-299, L301-303). Satisfies Requirement "Ring screen is pure UI" / Scenario "Reduced Android port surface" (static/compile-verifiable).
  • 1.3 lib/pantallas/pantalla_alarma_sonando.dart — remove the audioPrearrancado ctor param + field (L24,28); delete fields _volumenInicialFadeIn/_fadeStep/_fallbackPlayer/_estadoSub/_fallbackTimer/_fadeInTimer/_fallbackActivo/_radioIntentada/_audioFlutterConfirmado/_volumenMediaForzado/_volumenMediaRestaurado/_fadeInArrancado/_estadoAlarmas (L35-50, incl. the "Captured while mounted" doc comment — _estadoAlarmas has no remaining caller once 1.3/1.5 below land, since _detener/_posponer already context.read<EstadoAlarmas>() inline); delete methods _iniciarAlarma (L59-119), _iniciarFallback (L121-131), _forzarVolumenMediaUnaVez (L133-148), _iniciarFadeIn (L150-188), _aplicarVolumenGlobal (L190-195), _confirmarAudioFlutterListo (L197-218), _restaurarVolumenMediaUnaVez (L220-235), _liberarAudioLocal (L237-248), _silenciarAudio (L306-332), _assetFallback (L470-474, top-level fn); reduce initState to super.initState() only (drop the _estadoAlarmas capture + post-frame _iniciarAlarma call, L52-57 — native already started the ring before this screen ever mounts, nothing to bootstrap); drop final radio = context.read<EstadoRadio>(); + await _silenciarAudio(radio); from _detener (L259,261) and _posponer (L280,286) per D9; reduce dispose() to super.dispose(); only (L356-364 — no player/timer/subscription left to tear down); drop now-unused imports package:just_audio/just_audio.dart (L5), ../estado/estado_radio.dart (L9), ../servicios/servicio_audio.dart (L13, EstadoReproduccion had no other caller). Keep verbatim: _salidaEnCurso guard, PopScope/build(), _dismissScreen, _opcionesSnooze, _hora, layout.
  • 1.4 Same file — rewrite the status Text (L427-434) per D8: replace the _fallbackActivo ? ... : _radioIntentada ? ... : ... tri-state with alarma.emisora != null ? localizedStationName(l10n, alarma.emisora!.nombre) : l10n.alarmRingingNotificationTitle. Grounding: display_names.dart (imported already, L10) exports localizedStationName (verified, same call shape already used in servicio_alarmas_android.dart:265,270); alarmRingingNotificationTitle ("PluriWave alarm"/"Alarma PluriWave") is the deliberately NEUTRAL existing key — design Sec.4 D8 states all 3 old tri-state keys (alarmRingingFallbackActive/TryingStation/PreparingFallback) become unused, so none of them should be reused even for the no-station case. No new l10n key (spec Non-Functional Notes; 13 arb files confirmed, none touched).
  • 1.5 lib/app.dart — delete _volumenInicialFadeInAlarmas const (L92) and _prearrancarAudioAlarma method (L376-391); remove its call site await _prearrancarAudioAlarma(alarma); (L356) and the audioPrearrancado: alarma.emisora != null, arg (L363) from the PantallaAlarmaSonando(...) push inside _mostrarAlarmaSonando. Keep SKIP/POSTPONE/PRE_NOTICE routing + duplicate-delivery guard (_alarmaSonandoActiva/_alarmaSonandoId) untouched — confirmed no other symbol overlap via repo-wide grep.
  • 1.6 test/helpers/fakes_alarmas.dart — delete fields volumenForzado (L20-22), volumenRestaurado (L24-26), puertaConfirmarAudioFlutter (L33-38), fallaConfirmarAudioFlutter (L40-43) + their doc comments; delete overrides confirmarAudioFlutter (L77-86), forzarVolumenMediaParaAlarma (L88-91), restaurarVolumenMedia (L93-96). Keep programadas/canceladas/detenidas/ocultadas untouched (still part of the interface). This is the compile-time enforcement for "Ring screen is pure UI."

Test edits (same unit as production — compile-coupled)

  • 1.7 test/servicios/servicio_alarmas_android_test.dart — delete tests forzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing con fraction (L110-122) and restaurarVolumenMedia invoca restoreMediaVolume sin argumentos (L124-136). Keep both programar tests + solicitarExencionBateria test.
  • 1.8 test/pantallas/pantalla_alarma_sonando_scaffold_test.dart — drop audioPrearrancado: true, (L94) from _montarPantalla's push call. No other change (both scaffold/animation tests already assert no audio).
  • 1.9 test/pantallas/pantalla_alarma_sonando_test.dart — in _montarPantalla: remove the audioYaReproduciendo param + its if gate (L40, L47-50), make audio.emitirEstado(EstadoReproduccion.reproduciendo) unconditional (matches the other two sibling helpers, already unconditional); drop audioPrearrancado: true, (L109). Delete groups rampa anclada y override diferido al primer audio (L172-222, 2 tests) and handoff con audio prearrancado ya reproduciendo (regresion) (L224-246, 1 test) entirely — fade-ramp/handoff-specific, compile-dead once 1.2/1.3 land. Delete test salir a mitad de rampa restaura la ganancia del reproductor... (L273-291) and group restore de volumen de medios con dispose como unico llamador (L294-308) entirely.
  • 1.10 Same file — trim survivors: posponer 5 min ... (L150-170) drop the entorno.audio.pausas assertion (L160), keep snoozeHasta/screen-dismissed/ocultadas contains id/programadas.last.snoozeHasta; el boton atras del sistema se comporta como Detener... (L249-271) drop the entorno.audio.pausas assertion (L261-265), keep findsNothing + ocultadas contains id. Both descriptions currently claim the screen "detiene el audio"/"para la radio" — reword to drop that now-false claim (e.g. "posponer 5 min pospone y cierra (S2-R1-B)"; "el boton atras del sistema se comporta como Detener: finaliza y cierra").
  • 1.11 test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart — drop audioPrearrancado: true, from _montarComoRaiz (L64) and _montarConHistorial (L111); delete group PantallaAlarmaSonando media-volume override restore (Slice 2) (L360-429, 3 tests) entirely. Rewrite the decoupling regression (L431-445, group EstadoRadio reproduccion normal nunca dispara el override de volumen...): grounding correction vs. design_buildEnv() (L120-157, same file) already seeds one android.programar call via estadoAlarmas.guardarAlarma(...) -> EstadoAlarmas.guardarAlarma (lib/estado/estado_alarmas.dart:110) calls android.programar(guardada), so programadas is NOT empty at test start; design's "programadas/detenidas/ocultadas all empty" only holds for detenidas/ocultadas. Correct rewrite: capture programadas.length BEFORE radio.reproducir(emisora) + radio.audio.pausar(), assert it is UNCHANGED after, plus detenidas/ocultadas stay empty. Rename group/test description to drop "el override de volumen" framing (concept no longer exists) in favor of "nunca toca el puerto de alarmas (guardia de regresion, decoupling)".

Verification

  • 1.12 [RED->GREEN collapse] flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart — all green. rg -n "confirmarAudioFlutter|forzarVolumenMediaParaAlarma|restaurarVolumenMedia|audioPrearrancado" lib test -> 0 matches (repo-wide grep already confirmed these symbols exist ONLY in the 6 files touched by 1.2-1.11 plus the Kotlin files touched in Phase 2).
  • 1.13 flutter analyze — must be 0 issues. If _Entorno.audio (pantalla_alarma_sonando_test.dart) is flagged unused_field after 1.10's trim (no surviving test reads entorno.audio once audio.pausas is dropped everywhere in this file), remove the field + its constructor/return plumbing then; keep estadoAlarmas/android (both still read).
  • 1.14 [Commit] Work-unit commit (stacked-to-main, base: main): "Dart pure-UI ring screen + reduced Android alarm port." WU1's size exceeds 400 lines by construction (atomicity, see forecast above) — do not re-litigate the split.

Phase 2 — Work Unit 2: Kotlin native audio rebuild (PR 2, base: PR-1 branch)

No test harness exists for this module (confirmed: no android/app/src/test* or src/androidTest* directories in this project). Verification per task is code-inspection + static rg checks; the real behavioral gate is the on-device QA checklist in Phase 3 (proposal Risk table: "Kotlin not agent-compilable -> on-device QA is the gate").

PluriWaveAlarmService.kt

  • 2.1 Add imports android.os.SystemClock, android.media.AudioManager, android.media.AudioFocusRequest (none of the 3 currently imported — confirmed against current import block L1-23).
  • 2.2 Add companion consts FADE_TICK_MILLIS = 50L, FADE_RANGE_DB = 40.0f and pure fn computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float exactly per design D1 (fadeMs<=0 -> ceiling.coerceIn(0f,1f); else fraction=(elapsedMs/fadeMs).coerceIn(0f,1f), gainDb=fraction*FADE_RANGE_DB-FADE_RANGE_DB, curve=10^(gainDb/20), return (ceiling*curve).coerceIn(0f,1f)). Delete initialVolume() (L309-314), startFadeIn() (L316-343), fadeInRunnable field (L42) — replaced by 2.3-2.5.
  • 2.3 Add fields fadeAnchorElapsedMs: Long + fadeLoopRunnable: Runnable?; in startAlarm() set fadeAnchorElapsedMs = SystemClock.elapsedRealtime() right after activeAlarmId = alarmId (L92), replacing the deleted flutterOwnsRing = false reset (L93-97, see 2.8) — captured BEFORE startForeground/startAudio per D2.
  • 2.4 In startAudio() (L148, before the 3-stage fallback chain begins): start the single ring-anchored fade loop — mainHandler.postDelayed every FADE_TICK_MILLIS, reads player fresh each tick (survives the 3-stage swap per D1), guarded by activeAlarmId == alarmId, stops rescheduling once elapsed >= fadeInSegundos*1000L. Replace initialVolume(volume, fadeInSegundos) at startStationAudio (L209) and startFallbackAudio (L267) with computeFadeVolume(SystemClock.elapsedRealtime()-fadeAnchorElapsedMs, fadeInSegundos*1000L, volume).
  • 2.5 In both setOnPreparedListener callbacks (startStationAudio L220-229, startFallbackAudio L274-279): replace it.start(); startFadeIn(alarmId, it, volume, fadeInSegundos) with recomputing computeFadeVolume(...) at prepare-time and it.setVolume(current, current) BEFORE it.start() (Requirement "No-fade path starts pop-free"; D2 — a source joining late enters click-free at the elapsed level instead of restarting at 1%). Rename cancelFadeIn() (L345-348) -> cancelFadeLoop(), cancelling fadeLoopRunnable via mainHandler.removeCallbacks; keep its call site in stopAlarm() (L382).
  • 2.6 Add requestAlarmAudioFocus() (called once, top of startAudio(), before the fallback chain) and abandonAlarmAudioFocus() (called in stopAlarm()'s full-teardown branch, next to releaseWakeLock() L391) per D3: O+ uses AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT).setAudioAttributes(alarmAudioAttributes()).setOnAudioFocusChangeListener(noop), stored to abandon the exact request; below O uses the deprecated 3-arg requestAudioFocus(noop, STREAM_ALARM, AUDIOFOCUS_GAIN_TRANSIENT). Reuse alarmAudioAttributes() (L355-359) unchanged (D10 — USAGE_ALARM is load-bearing).
  • 2.7 Channel silence + migration (D4): rename CHANNEL_ID (L554) "pluriwave_alarm_fire_v2" -> "pluriwave_alarm_fire_v3"; add LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"; rename KEY_CHANNELS_MIGRATED_V2 (L558) -> KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"; in ensureChannel() (L623-647) delete the setSound(Settings.System.DEFAULT_ALARM_ALERT_URI, ...) block (L638-644) entirely (no setSound call = silent), keep enableVibration(true) (D5) + IMPORTANCE_HIGH (kept for FSI); in migrateLegacyChannels() (L652-660) add a 3rd runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) } alongside the existing native+fire deletes, guarded by the renamed KEY_CHANNELS_MIGRATED_V3 flag (deleteNotificationChannel is a safe no-op when absent — correct on both fresh installs and v2 devices).
  • 2.8 Kill the handoff surface (D7): delete companion flutterOwnsRing field + its doc comment (L567-589); delete the backstop if (!flutterOwnsRing) { runCatching { MainActivity.restoreMediaVolumeBestEffort() } } in stopAlarm() (L392-404) — abandonAlarmAudioFocus() (2.6) takes its place, unconditionally; delete the matching backstop+guard in onDestroy() (L541-549), keep the stopAlarm(activeAlarmId) call (L538). Rewrite the class-level doc comment (L25-35) — it currently describes the OLD split-ownership/confirmFlutterAudio model; replace with sole-ownership description.

MainActivity.kt

  • 2.9 Delete cases "confirmFlutterAudio" (L156-173), "overrideMediaVolumeForRing" (L228-233), "restoreMediaVolume" (L234-238) from the alarmMethodChannel handler; delete overrideMediaVolumeForRing() private fn + its section-header comment block (L310-348), restoreMediaVolume() private fn (L350-375); delete companion fields mediaVolumeOverridden + doc (L917-924), capturedMediaVolume (L926-927), fn restoreMediaVolumeBestEffort() + doc (L946-972). Keep the AudioManager import (L11) — still required by getActiveAudioDevice()/audio-devices channel (unrelated feature; confirmed no other overlap via repo-wide grep). 2.8 and 2.9 are mutually coupled (deleting flutterOwnsRing and its only setter/reader must land together) — order between them doesn't matter, both must complete before 2.10.

Verification

  • 2.10 [Static, code-inspection] rg -n "flutterOwnsRing|confirmFlutterAudio|overrideMediaVolumeForRing|restoreMediaVolume|restoreMediaVolumeBestEffort|startFadeIn|initialVolume\(" android/ -> 0 matches. rg -n "setStreamVolume" android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt -> 0 matches (Requirement "no system volume writes, ever"). rg -n "pluriwave_alarm_fire_v2" android/ -> exactly 1 match, the new LEGACY_CHANNEL_FIRE_V2 constant value.
  • 2.11 flutter analyze — must stay 0 (Dart side untouched by WU2; sanity re-check only).
  • 2.12 [Commit] Work-unit commit (stacked-to-main, base: PR-1 branch): "Kotlin native ring audio rebuild — dB fade, manual focus, silent channel v3."

Phase 3 — Final sweep + on-device QA gate (closes the change, spans both units)

  • 3.1 Attempt full flutter test. Known intermittent hang, pre-existing/unrelated to this change (implicated: test/estado/estado_alarmas_ejecuciones_test.dart, test/servicios/servicio_grabacion_radio_test.dart — per sdd-init/pluriwave cache and confirmed precedent in eq-audiofocus-reapply/persistence-corruption-guard). If it hangs, fall back to the targeted list: flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart — must be green. Do not block delivery on the hang.
  • 3.2 flutter analyze repo-wide — 0 issues.

On-device QA checklist — MANDATORY human gate, leave UNCHECKED (real device, e.g. POCO X7 Pro)

  • 3.3 Audible exponential dB fade (not linear/late) at a configured fade of 15-30s (Requirement "Exponential dB fade-in ceiling").
  • 3.4 Volume ceiling correct: configured "N%" is approx N% of the device ALARM stream at full ramp.
  • 3.5 No start pop on any of the 3 sources (primary station, fallback station, bundled WAV).
  • 3.6 No second audible source: Dart plays nothing; the native STREAM_ALARM player is the only audible source; the fire notification (pluriwave_alarm_fire_v3) posts with no sound (Scenario "Fire notification posts with no sound").
  • 3.7 Dismiss, snooze, and system-back all stop the ring, abandon audio focus, and the user's other-app audio (music/podcast/another radio app) resumes.
  • 3.8 Media volume (STREAM_MUSIC) at 0 still rings audibly; STREAM_ALARM at 0 is silently accepted (documented, not a regression).
  • 3.9 Screen-off full-screen-intent delivery + ring fires reliably from a killed app on POCO X7 Pro after HyperOS Autostart is enabled.
  • 3.10 Real upgrade-path channel migration: install a build with the v2 channel, upgrade to this build, fire an alarm — notification posts silently, and Ajustes > Notificaciones shows pluriwave_alarm_fire_v3 present with v2 gone (Scenario "v2 deleted exactly once on upgrade"; added post-verify per WARNING-1).
  • 3.11 Focus hygiene: during the ring adb shell dumpsys audio shows a TRANSIENT focus holder for the app on STREAM_ALARM; after dismiss the entry is gone (added post-verify per WARNING-1).

Requirement Traceability

Spec Requirement Satisfied by
Sole native ring-audio ownership 1.3, 1.5, 2.4-2.6, 3.6
Exponential dB fade-in ceiling 2.2, 2.4-2.5, 3.3-3.4
Manual transient focus; no system volume writes 2.6, 2.8, 2.10, 3.7
Notification channel migration v2->v3 2.7, 2.10, 3.6
Ring screen is pure UI 1.2-1.3, 1.6, 1.12
REMOVED: Ring-scoped device-volume override 1.2-1.3, 1.6, 2.6, 2.8-2.9, 2.10 (regression-proof by deletion + compile)
REMOVED: Single fade-in driver across handoff 1.3, 2.2-2.5

Dependency Summary

Phase 1 -> Phase 2 (stacked-to-main: PR 2 bases on the PR 1 branch) -> Phase 3 (closes the change, spans both units). Within Phase 1: 1.2 (port interface) before 1.6 (fake implements it); 1.3-1.5 independent files, can proceed in any order; 1.7-1.11 (test rewrites) depend on 1.2/1.3/1.6 existing; 1.12-1.14 sequential. Within Phase 2: 2.1-2.3 sequential (imports/consts/anchor before use); 2.4-2.6 sequential (loop+focus depend on 2.2's consts and 2.3's anchor); 2.7 is independent, can run in parallel with 2.1-2.6; 2.8/2.9 mutually coupled (see 2.9's note), both required before 2.10-2.12.