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.
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 thePuertoAlarmasAndroidinterface:confirmarAudioFlutter(L145),forzarVolumenMediaParaAlarma+ doc (L147-152),restaurarVolumenMedia+ doc (L154-157); delete the matchingServicioAlarmasAndroidoverrides (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 theaudioPrearrancadoctor 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 —_estadoAlarmashas no remaining caller once 1.3/1.5 below land, since_detener/_posponeralreadycontext.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); reduceinitStatetosuper.initState()only (drop the_estadoAlarmascapture + post-frame_iniciarAlarmacall, L52-57 — native already started the ring before this screen ever mounts, nothing to bootstrap); dropfinal radio = context.read<EstadoRadio>();+await _silenciarAudio(radio);from_detener(L259,261) and_posponer(L280,286) per D9; reducedispose()tosuper.dispose();only (L356-364 — no player/timer/subscription left to tear down); drop now-unused importspackage:just_audio/just_audio.dart(L5),../estado/estado_radio.dart(L9),../servicios/servicio_audio.dart(L13,EstadoReproduccionhad no other caller). Keep verbatim:_salidaEnCursoguard,PopScope/build(),_dismissScreen,_opcionesSnooze,_hora, layout. - 1.4 Same file — rewrite the status
Text(L427-434) per D8: replace the_fallbackActivo ? ... : _radioIntentada ? ... : ...tri-state withalarma.emisora != null ? localizedStationName(l10n, alarma.emisora!.nombre) : l10n.alarmRingingNotificationTitle. Grounding:display_names.dart(imported already, L10) exportslocalizedStationName(verified, same call shape already used inservicio_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_volumenInicialFadeInAlarmasconst (L92) and_prearrancarAudioAlarmamethod (L376-391); remove its call siteawait _prearrancarAudioAlarma(alarma);(L356) and theaudioPrearrancado: alarma.emisora != null,arg (L363) from thePantallaAlarmaSonando(...)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 fieldsvolumenForzado(L20-22),volumenRestaurado(L24-26),puertaConfirmarAudioFlutter(L33-38),fallaConfirmarAudioFlutter(L40-43) + their doc comments; delete overridesconfirmarAudioFlutter(L77-86),forzarVolumenMediaParaAlarma(L88-91),restaurarVolumenMedia(L93-96). Keepprogramadas/canceladas/detenidas/ocultadasuntouched (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 testsforzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing con fraction(L110-122) andrestaurarVolumenMedia invoca restoreMediaVolume sin argumentos(L124-136). Keep bothprogramartests +solicitarExencionBateriatest. - 1.8
test/pantallas/pantalla_alarma_sonando_scaffold_test.dart— dropaudioPrearrancado: 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 theaudioYaReproduciendoparam + itsifgate (L40, L47-50), makeaudio.emitirEstado(EstadoReproduccion.reproduciendo)unconditional (matches the other two sibling helpers, already unconditional); dropaudioPrearrancado: true,(L109). Delete groupsrampa anclada y override diferido al primer audio(L172-222, 2 tests) andhandoff con audio prearrancado ya reproduciendo (regresion)(L224-246, 1 test) entirely — fade-ramp/handoff-specific, compile-dead once 1.2/1.3 land. Delete testsalir a mitad de rampa restaura la ganancia del reproductor...(L273-291) and grouprestore de volumen de medios con dispose como unico llamador(L294-308) entirely. - 1.10 Same file — trim survivors:
posponer 5 min ...(L150-170) drop theentorno.audio.pausasassertion (L160), keepsnoozeHasta/screen-dismissed/ocultadas contains id/programadas.last.snoozeHasta;el boton atras del sistema se comporta como Detener...(L249-271) drop theentorno.audio.pausasassertion (L261-265), keepfindsNothing+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— dropaudioPrearrancado: true,from_montarComoRaiz(L64) and_montarConHistorial(L111); delete groupPantallaAlarmaSonando media-volume override restore (Slice 2)(L360-429, 3 tests) entirely. Rewrite the decoupling regression (L431-445, groupEstadoRadio reproduccion normal nunca dispara el override de volumen...): grounding correction vs. design —_buildEnv()(L120-157, same file) already seeds oneandroid.programarcall viaestadoAlarmas.guardarAlarma(...)->EstadoAlarmas.guardarAlarma(lib/estado/estado_alarmas.dart:110) callsandroid.programar(guardada), soprogramadasis NOT empty at test start; design's "programadas/detenidas/ocultadas all empty" only holds fordetenidas/ocultadas. Correct rewrite: captureprogramadas.lengthBEFOREradio.reproducir(emisora)+radio.audio.pausar(), assert it is UNCHANGED after, plusdetenidas/ocultadasstay 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 flaggedunused_fieldafter 1.10's trim (no surviving test readsentorno.audioonceaudio.pausasis dropped everywhere in this file), remove the field + its constructor/return plumbing then; keepestadoAlarmas/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.0fand pure fncomputeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Floatexactly per design D1 (fadeMs<=0 -> ceiling.coerceIn(0f,1f); elsefraction=(elapsedMs/fadeMs).coerceIn(0f,1f),gainDb=fraction*FADE_RANGE_DB-FADE_RANGE_DB,curve=10^(gainDb/20), return(ceiling*curve).coerceIn(0f,1f)). DeleteinitialVolume()(L309-314),startFadeIn()(L316-343),fadeInRunnablefield (L42) — replaced by 2.3-2.5. - 2.3 Add fields
fadeAnchorElapsedMs: Long+fadeLoopRunnable: Runnable?; instartAlarm()setfadeAnchorElapsedMs = SystemClock.elapsedRealtime()right afteractiveAlarmId = alarmId(L92), replacing the deletedflutterOwnsRing = falsereset (L93-97, see 2.8) — captured BEFOREstartForeground/startAudioper D2. - 2.4 In
startAudio()(L148, before the 3-stage fallback chain begins): start the single ring-anchored fade loop —mainHandler.postDelayedeveryFADE_TICK_MILLIS, readsplayerfresh each tick (survives the 3-stage swap per D1), guarded byactiveAlarmId == alarmId, stops rescheduling onceelapsed >= fadeInSegundos*1000L. ReplaceinitialVolume(volume, fadeInSegundos)atstartStationAudio(L209) andstartFallbackAudio(L267) withcomputeFadeVolume(SystemClock.elapsedRealtime()-fadeAnchorElapsedMs, fadeInSegundos*1000L, volume). - 2.5 In both
setOnPreparedListenercallbacks (startStationAudioL220-229,startFallbackAudioL274-279): replaceit.start(); startFadeIn(alarmId, it, volume, fadeInSegundos)with recomputingcomputeFadeVolume(...)at prepare-time andit.setVolume(current, current)BEFOREit.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%). RenamecancelFadeIn()(L345-348) ->cancelFadeLoop(), cancellingfadeLoopRunnableviamainHandler.removeCallbacks; keep its call site instopAlarm()(L382). - 2.6 Add
requestAlarmAudioFocus()(called once, top ofstartAudio(), before the fallback chain) andabandonAlarmAudioFocus()(called instopAlarm()'s full-teardown branch, next toreleaseWakeLock()L391) per D3: O+ usesAudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT).setAudioAttributes(alarmAudioAttributes()).setOnAudioFocusChangeListener(noop), stored to abandon the exact request; below O uses the deprecated 3-argrequestAudioFocus(noop, STREAM_ALARM, AUDIOFOCUS_GAIN_TRANSIENT). ReusealarmAudioAttributes()(L355-359) unchanged (D10 —USAGE_ALARMis load-bearing). - 2.7 Channel silence + migration (D4): rename
CHANNEL_ID(L554)"pluriwave_alarm_fire_v2"->"pluriwave_alarm_fire_v3"; addLEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"; renameKEY_CHANNELS_MIGRATED_V2(L558) ->KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"; inensureChannel()(L623-647) delete thesetSound(Settings.System.DEFAULT_ALARM_ALERT_URI, ...)block (L638-644) entirely (nosetSoundcall = silent), keepenableVibration(true)(D5) +IMPORTANCE_HIGH(kept for FSI); inmigrateLegacyChannels()(L652-660) add a 3rdrunCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) }alongside the existing native+fire deletes, guarded by the renamedKEY_CHANNELS_MIGRATED_V3flag (deleteNotificationChannelis a safe no-op when absent — correct on both fresh installs and v2 devices). - 2.8 Kill the handoff surface (D7): delete companion
flutterOwnsRingfield + its doc comment (L567-589); delete the backstopif (!flutterOwnsRing) { runCatching { MainActivity.restoreMediaVolumeBestEffort() } }instopAlarm()(L392-404) —abandonAlarmAudioFocus()(2.6) takes its place, unconditionally; delete the matching backstop+guard inonDestroy()(L541-549), keep thestopAlarm(activeAlarmId)call (L538). Rewrite the class-level doc comment (L25-35) — it currently describes the OLD split-ownership/confirmFlutterAudiomodel; replace with sole-ownership description.
MainActivity.kt
- 2.9 Delete cases
"confirmFlutterAudio"(L156-173),"overrideMediaVolumeForRing"(L228-233),"restoreMediaVolume"(L234-238) from thealarmMethodChannelhandler; deleteoverrideMediaVolumeForRing()private fn + its section-header comment block (L310-348),restoreMediaVolume()private fn (L350-375); delete companion fieldsmediaVolumeOverridden+ doc (L917-924),capturedMediaVolume(L926-927), fnrestoreMediaVolumeBestEffort()+ doc (L946-972). Keep theAudioManagerimport (L11) — still required bygetActiveAudioDevice()/audio-devices channel (unrelated feature; confirmed no other overlap via repo-wide grep). 2.8 and 2.9 are mutually coupled (deletingflutterOwnsRingand 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 newLEGACY_CHANNEL_FIRE_V2constant 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— persdd-init/pluriwavecache and confirmed precedent ineq-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 analyzerepo-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_ALARMplayer 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_ALARMat 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_v3present 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 audioshows 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.