diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 786da9a..91ad629 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -64,7 +64,6 @@ class _PantallaAlarmaSonandoState extends State { if (!widget.audioPrearrancado) { unawaited(radio.reproducir(emisora)); } - _iniciarFadeIn(); // S7-R4 boundary: only `reproduciendo` cancels the fallback timer — // `reconectando`/`cargando` do NOT count as playing, so the 12-second @@ -94,7 +93,6 @@ class _PantallaAlarmaSonandoState extends State { _fallbackActivo = true; await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno)); await _fallbackPlayer.play(); - _iniciarFadeIn(); await _confirmarAudioFlutterListo(); if (mounted) setState(() {}); } @@ -131,12 +129,28 @@ class _PantallaAlarmaSonandoState extends State { await _fallbackPlayer.setVolume(volumen.clamp(0.0, 1.0)); } + /// Confirms the native-to-Flutter audio handoff at most once per screen + /// instance, then starts the single audible Dart fade-in ramp (Slice 3: + /// fade-in dedup at handoff). The native ramp + /// (`PluriWaveAlarmService.startFadeIn`) owns audio until this + /// confirmation lands; starting the Dart ramp any earlier would + /// interleave both ramps and produce an audible jump/reset. If the + /// native confirmation channel call fails (dead or never-there native + /// side), the fade-in still starts in the `finally` block below — Dart + /// is the only audible source either way, so the ring must never stay + /// stuck at [_volumenInicialFadeIn] forever. Future _confirmarAudioFlutterListo() async { if (_audioFlutterConfirmado) return; _audioFlutterConfirmado = true; - await context.read().android.confirmarAudioFlutter( - widget.alarma.id, - ); + try { + await context.read().android.confirmarAudioFlutter( + widget.alarma.id, + ); + } catch (e) { + debugPrint('[PluriWave][alarmas] confirmar audio flutter fallo: $e'); + } finally { + _iniciarFadeIn(); + } } /// Restores the ring-scoped `STREAM_MUSIC` override at most once per diff --git a/openspec/changes/alarm-volume-ramp-restore/tasks.md b/openspec/changes/alarm-volume-ramp-restore/tasks.md index 4229819..e49fea6 100644 --- a/openspec/changes/alarm-volume-ramp-restore/tasks.md +++ b/openspec/changes/alarm-volume-ramp-restore/tasks.md @@ -142,11 +142,46 @@ Chain strategy: pending ## Phase 4: Fade-In Dedup at Handoff (Slice 3, strict TDD) -- [ ] 4.1 (RED) In `test/pantallas/pantalla_alarma_sonando_test.dart` (or a new focused fade-in test file), add a widget test asserting the Dart fade-in ramp (observable via `FakeServicioAudio`/fallback player volume changes) does NOT start before `confirmarAudioFlutter` has been invoked on the android port (i.e., before `_confirmarAudioFlutterListo()` runs) — assert `env.android.detenidas` (which `confirmarAudioFlutter` appends to, per `FakePuertoAlarmasAndroid.confirmarAudioFlutter`) is non-empty before any volume-ramp step is observed. Run `flutter test` — confirm it fails (current code starts the ramp at L66 immediately after `radio.reproducir`, before confirmation). -- [ ] 4.2 (RED) Add a test asserting the fade-in DOES start once `_confirmarAudioFlutterListo()` has run (radio path via `estadoStream` emitting `reproduciendo`, or fallback path via `_iniciarFallback`) — the ramp must still function end-to-end after the gate. Run `flutter test` — confirm it fails or is trivially satisfied depending on 4.1's fixture; treat 4.1+4.2 as one RED pair validating gate correctness both ways. -- [ ] 4.3 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, remove the `_iniciarFadeIn()` call at L66 (radio path, currently fires immediately after `radio.reproducir(emisora)`) and the one at L96 (fallback path, currently fires before `_confirmarAudioFlutterListo()` at L97); move the single `_iniciarFadeIn()` invocation INTO `_confirmarAudioFlutterListo()` (L133-139) so it fires exactly once, after the `_audioFlutterConfirmado` guard, for both the radio and fallback paths. Run `flutter test` — confirm 4.1-4.2 pass. -- [ ] 4.4 (REFACTOR) Re-run the full `pantalla_alarma_sonando_test.dart` and `pantalla_alarma_sonando_dismiss_guard_test.dart` suites — confirm no existing fade/dismiss/snooze assertions regressed from moving the ramp start point. -- [ ] 4.5 `flutter analyze` and `dart format .` — confirm clean formatting/lint state for all Slice 3 edits. +- [x] 4.1 (RED) In `test/pantallas/pantalla_alarma_sonando_test.dart` (or a new focused fade-in test file), add a widget test asserting the Dart fade-in ramp (observable via `FakeServicioAudio`/fallback player volume changes) does NOT start before `confirmarAudioFlutter` has been invoked on the android port (i.e., before `_confirmarAudioFlutterListo()` runs) — assert `env.android.detenidas` (which `confirmarAudioFlutter` appends to, per `FakePuertoAlarmasAndroid.confirmarAudioFlutter`) is non-empty before any volume-ramp step is observed. Run `flutter test` — confirm it fails (current code starts the ramp at L66 immediately after `radio.reproducir`, before confirmation). +- [x] 4.2 (RED) Add a test asserting the fade-in DOES start once `_confirmarAudioFlutterListo()` has run (radio path via `estadoStream` emitting `reproduciendo`, or fallback path via `_iniciarFallback`) — the ramp must still function end-to-end after the gate. Run `flutter test` — confirm it fails or is trivially satisfied depending on 4.1's fixture; treat 4.1+4.2 as one RED pair validating gate correctness both ways. + + > Implementation note: 4.1+4.2 were implemented as a single RED pair (one `testWidgets`, two + > assertion stages) in `test/pantallas/pantalla_alarma_sonando_test.dart`, radio path. Rather than + > relying on real `estadoStream` subscription timing (existing `_montarPantalla` pre-emits + > `reproduciendo` BEFORE mount in all prior tests, which — traced via code inspection — means + > `_confirmarAudioFlutterListo()` never actually fires in those fixtures: the broadcast stream drops + > the pre-mount event, and the fallback timer's immediate-cancel branch also short-circuits before + > any listener would see it), added a new opt-out param `audioYaReproduciendo` (default `true`, + > preserves all existing tests byte-for-byte) plus a test-only `Completer`-based gate + > (`FakePuertoAlarmasAndroid.puertaConfirmarAudioFlutter`) on `confirmarAudioFlutter`, so the test + > can deterministically observe the pre-confirm state, release the gate, then observe the + > post-confirm state — independent of stream/timer race conditions. Confirmed RED against the + > pre-4.3 code: the gate-pending assertion failed with `Actual: [0.05, 0.85]` (ramp already fired + > before the gate was ever released), proving the ramp was ungated. + > + > Also added a THIRD test beyond the literal 4.1/4.2 wording, covering the explicit edge case in + > this batch's own dispatch instructions: if `confirmarAudioFlutter` FAILS (dead/never-there native + > channel), the Dart ramp must still start — the ring must never stay stuck at + > `_volumenInicialFadeIn` forever, since Dart is the only audible source once native is gone. Added + > `FakePuertoAlarmasAndroid.fallaConfirmarAudioFlutter` to simulate this. Confirmed RED against + > pre-4.3 code too: the fake's `StateError` propagated UNCAUGHT out of `_confirmarAudioFlutterListo()` + > (no try/catch existed), crashing the test — proving the failure path was unhandled before this + > slice. + +- [x] 4.3 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, remove the `_iniciarFadeIn()` call at L66 (radio path, currently fires immediately after `radio.reproducir(emisora)`) and the one at L96 (fallback path, currently fires before `_confirmarAudioFlutterListo()` at L97); move the single `_iniciarFadeIn()` invocation INTO `_confirmarAudioFlutterListo()` (L133-139) so it fires exactly once, after the `_audioFlutterConfirmado` guard, for both the radio and fallback paths. Run `flutter test` — confirm 4.1-4.2 pass. + + > Implementation note: moved as specified. `_confirmarAudioFlutterListo()` now wraps the native + > channel call in `try { await ... } catch (e) { debugPrint(...); } finally { _iniciarFadeIn(); }` — + > matching the existing `try/catch/finally` idiom already used in `_detener()`/`_posponer()` in the + > same file. The `finally` block is what satisfies the failure-edge case above: whether the native + > confirmation succeeds or throws, the fade-in always starts exactly once (still gated by the + > pre-existing `_audioFlutterConfirmado` guard at the top of the method, unchanged). All 3 new tests + > (4.1+4.2 combined, plus the failure-edge test) pass after this change; `rg '_iniciarFadeIn' + > lib/` confirms exactly one call site remains (inside `_confirmarAudioFlutterListo`) plus the + > function definition itself. + +- [x] 4.4 (REFACTOR) Re-run the full `pantalla_alarma_sonando_test.dart` and `pantalla_alarma_sonando_dismiss_guard_test.dart` suites — confirm no existing fade/dismiss/snooze assertions regressed from moving the ramp start point. (Also included `pantalla_alarma_sonando_scaffold_test.dart` and `servicio_alarmas_android_test.dart` for extra confidence since a shared fake was extended. 22/22 tests green, no regressions — none of the pre-existing tests assert on fade/volume timing, since none of them previously exercised `_confirmarAudioFlutterListo()` at all under the old pre-mount-emit fixture pattern, as traced in the 4.1/4.2 note above.) +- [x] 4.5 `flutter analyze` and `dart format .` — confirm clean formatting/lint state for all Slice 3 edits. (`flutter analyze`: 0 issues. `dart format .` reformatted the 3 Slice 3 files cleanly; it also touched 8 files unrelated to this change — pre-existing formatter-version drift on `main`, confirmed reproducible and out of scope per this batch's file-scope constraint — reverted via `git checkout --` both times it recurred, see Phase 6 6.3 note.) ## Phase 5: Manual/On-Device QA (mandatory human gate — Android 14+ physical or emulator device) @@ -161,6 +196,48 @@ Chain strategy: pending ## Phase 6: Final Static Sweep -- [ ] 6.1 Full-repo `rg 'systemExempted|SYSTEM_EXEMPTED'` across `android/app/src/main` — confirm zero remaining references (fully dropped, not kept alongside `alarm`). -- [ ] 6.2 `flutter test` (full suite) and `flutter analyze` — final clean run before requesting review. -- [ ] 6.3 `dart format .` — confirm no formatting diffs remain uncommitted. +- [x] 6.1 Full-repo `rg 'systemExempted|SYSTEM_EXEMPTED'` across `android/app/src/main` — confirm zero remaining references (fully dropped, not kept alongside `alarm`). + + > **INTERPRETATION FLIP (sdd-apply, 2026-07-11):** this check's original "zero remaining + > references" wording was written under the Phase 1 premise that later proved false. Ran it anyway: + > 3 references found (`AndroidManifest.xml:6` permission, `AndroidManifest.xml:58` + > `foregroundServiceType`, `PluriWaveAlarmService.kt:124` runtime constant) — all `systemExempted`/ + > `SYSTEM_EXEMPTED`, none `alarm`. This is the CORRECT and EXPECTED state, not a failure: per the + > Phase 1 cancellation banner (design.md correction, confirmed via `javap -constants` SDK + > inspection), `FOREGROUND_SERVICE_TYPE_ALARM`/`FOREGROUND_SERVICE_ALARM` are fictional and + > `mediaPlayback|systemExempted` is confirmed the correct, intentional declaration — so + > "fully dropped" is no longer the right target; "present and internally consistent between + > manifest and runtime" is. Manifest (`systemExempted` at L6+L58) and Kotlin runtime + > (`FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` at L124) match each other exactly, same as before this + > entire change — Slice 1 shipped no code (cancelled), so this file pair was never touched by any + > batch of this change. Marking complete because the check RAN and its result was interpreted + > correctly against the corrected design, not because the literal original wording's outcome was + > achieved. + +- [x] 6.2 `flutter test` (full suite) and `flutter analyze` — final clean run before requesting review. + + > Implementation note: the full-suite invocation reproduced the documented intermittent hang (this + > task's own dispatch instructions named `estado_alarmas_ejecuciones_test.dart` and + > `servicio_grabacion_radio_test.dart` as known culprits) — it stalled past `+276` tests for several + > minutes of real time with zero further progress and had to be killed. Fell back exactly as + > instructed: ran all 44 other test files (everything under `test/` except those two) together — + > `01:02 +272: All tests passed!`, zero failures. Then ran the two known-flaky files standalone — + > `+7: All tests passed!`, zero failures, confirming (again) they only misbehave under full-suite + > load, not on their own — same conclusion already recorded in Batch 2's apply-progress for + > `servicio_grabacion_radio_test.dart` (`_fallar must clear activa flag immediately`, timing- + > sensitive). 272 + 7 = 279 tests total across all 46 files in the suite, all green. `flutter + > analyze`: 0 issues. + +- [x] 6.3 `dart format .` — confirm no formatting diffs remain uncommitted. + + > Implementation note: `dart format .` left the 3 files this batch touched + > (`lib/pantallas/pantalla_alarma_sonando.dart`, `test/helpers/fakes_alarmas.dart`, + > `test/pantallas/pantalla_alarma_sonando_test.dart`) untouched on its second run (already clean + > from the 4.5 pass) — confirmed via `git status --short` showing only those 3 files modified. Both + > times `dart format .` ran in this batch (4.5 and 6.3) it ALSO reformatted 8 files this batch never + > edited (`lib/pantallas/pantalla_ajustes.dart`, `lib/servicios/servicio_ecualizador.dart`, and 6 + > test files under `test/estado`, `test/pantallas`, `test/servicios`) — reverted both times via `git + > checkout --` to stay within this batch's file-scope constraint (only + > `pantalla_alarma_sonando.dart` + test files). This is pre-existing formatter-version drift already + > present on `main` before this change started, unrelated to Slice 2 or Slice 3 — worth a + > follow-up `dart format .` cleanup commit on its own, out of scope here. diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart index af09788..7163a90 100644 --- a/test/helpers/fakes_alarmas.dart +++ b/test/helpers/fakes_alarmas.dart @@ -30,6 +30,18 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { /// could not otherwise produce. bool fallaProgramar = false; + /// Test-only gate (Slice 3: fade-in dedup at handoff). When set, + /// [confirmarAudioFlutter] suspends on this completer before resolving, + /// letting a test observe the pre-confirm state deterministically instead + /// of racing real stream/timer scheduling. Complete it to let the call + /// proceed. + Completer? puertaConfirmarAudioFlutter; + + /// Test-only failure switch (Slice 3 edge case): when true, + /// [confirmarAudioFlutter] throws after the gate above (if any) resolves, + /// simulating a dead/never-there native channel at handoff. + bool fallaConfirmarAudioFlutter = false; + /// Simulates a native -> Flutter `alarmFired` MethodChannel event. void emitirEvento(EventoAlarmaAndroid evento) => _eventos.add(evento); @@ -64,6 +76,12 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { @override Future confirmarAudioFlutter(String alarmaId) async { + if (puertaConfirmarAudioFlutter != null) { + await puertaConfirmarAudioFlutter!.future; + } + if (fallaConfirmarAudioFlutter) { + throw StateError('fake confirmarAudioFlutter failure'); + } detenidas.add(alarmaId); } diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index 3b0b73d..ed3ade6 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -31,6 +31,12 @@ class _Entorno { Future<_Entorno> _montarPantalla( WidgetTester tester, { int snoozeMinutos = 5, + // Slice 3 (fade-in dedup): existing callers rely on the radio already + // being "reproduciendo" by mount time, which cancels the fallback timer + // synchronously and leaves nothing to observe mid-handoff. Fade-in-gate + // tests need a live `_estadoSub` subscriber instead, so they set this to + // false and emit `reproduciendo` themselves after the widget mounts. + bool audioYaReproduciendo = true, }) async { tester.view.physicalSize = const Size(1440, 3200); tester.view.devicePixelRatio = 1.0; @@ -38,7 +44,9 @@ Future<_Entorno> _montarPantalla( addTearDown(tester.view.resetDevicePixelRatio); final audio = FakeServicioAudio(); - audio.emitirEstado(EstadoReproduccion.reproduciendo); + if (audioYaReproduciendo) { + audio.emitirEstado(EstadoReproduccion.reproduciendo); + } final radio = EstadoRadio( audio: audio, favoritos: FakeServicioFavoritos(), @@ -158,4 +166,66 @@ void main() { ); }, ); + + group('fade-in dedup en el handoff (Slice 3)', () { + testWidgets('el fade-in de Dart se retiene hasta que el nativo confirma el ' + 'handoff, y arranca justo despues (camino radio)', (tester) async { + final entorno = await _montarPantalla( + tester, + audioYaReproduciendo: false, + ); + entorno.android.puertaConfirmarAudioFlutter = Completer(); + + // Antes de que la radio confirme "reproduciendo", solo debe existir + // el volumen de arranque previo (0.05): el ramp real hacia + // alarma.volumen todavia NO debe haber arrancado. + expect(entorno.android.detenidas, isEmpty); + expect(entorno.audio.volumenesAplicados, [0.05]); + + // La radio confirma que esta reproduciendo -> dispara + // _confirmarAudioFlutterListo(), que queda bloqueado en la puerta + // (todavia no hay confirmacion nativa real). + entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); + await tester.pumpAndSettle(); + expect( + entorno.android.detenidas, + isEmpty, + reason: 'confirmarAudioFlutter sigue bloqueado en la puerta de prueba', + ); + expect( + entorno.audio.volumenesAplicados, + [0.05], + reason: 'el fade-in de Dart no debe arrancar antes del handoff', + ); + + // Se libera la puerta: recien ahi "confirma" el nativo, y solo + // entonces debe arrancar el fade-in de Dart (una unica rampa + // audible a la vez). + entorno.android.puertaConfirmarAudioFlutter!.complete(); + await tester.pumpAndSettle(); + + expect(entorno.android.detenidas, contains('ring1')); + expect(entorno.audio.volumenesAplicados, [0.05, 0.85]); + }); + + testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart ' + 'arranca igual (el nativo esta muerto o nunca corrio)', (tester) async { + final entorno = await _montarPantalla( + tester, + audioYaReproduciendo: false, + ); + entorno.android.fallaConfirmarAudioFlutter = true; + + entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); + await tester.pumpAndSettle(); + + // La confirmacion nativa fallo (el fake lanza antes de registrar en + // `detenidas`), pero el fade-in de Dart debe arrancar de todas + // formas: si el lado nativo esta muerto o nunca corrio, Dart es la + // unica fuente audible, y el ring no debe quedar pegado en + // _volumenInicialFadeIn para siempre. + expect(entorno.android.detenidas, isEmpty); + expect(entorno.audio.volumenesAplicados, [0.05, 0.85]); + }); + }); }