feat(audio): audio session integration and runtime robustness

- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
This commit is contained in:
2026-06-11 16:25:09 +02:00
parent f3e9487215
commit 079e19f0ee
21 changed files with 1059 additions and 151 deletions
@@ -182,30 +182,30 @@ Chain strategy: N/A (local apply)
### S3a pre-work: write failing tests
- [ ] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **~20 lines.**
- [ ] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`:
- [x] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **DONE — two distinct channels, simulated `alarmFired` via `handlePlatformMessage`, both directions asserted.**
- [x] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`:
- Test A: `recalcularTodas` does NOT call `SharedPreferences.setString` when schedule unchanged (S3-R5-A).
- Test B: `recalcularTodas` calls `SharedPreferences.setString` exactly once when changed (S3-R5-B).
- Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **~50 lines.**
- [ ] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **~20 lines.**
- [ ] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **~20 lines.**
- Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **DONE — `_PrefsEspia implements SharedPreferences` (counts setString/getString); Test C also asserts the mutations hydrate the cache at most once.**
- [x] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **DONE — plus a cap test (250 fresh entries → ≤ 200).**
- [x] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **DONE — counter subclass of `EstadoRadio`; 10 notifyListeners rebuilds → 1 call; locale es→en → 2nd call.**
### S3a implementation
- [ ] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. Add deprecated static shim for `estado_radio.dart:74` call site (one-release compat). Rewire `EstadoRadio.configurarLocalizaciones` to call the instance. **Reqs:** S3-R2. **~40 lines.**
- [ ] **T-S3a-06** [GREEN] Edit `lib/widgets/mini_reproductor.dart` (line 23): convert to `StatefulWidget` if not already; move `configurarLocalizaciones(l10n)` call to `didChangeDependencies`, guarded by a cached `Locale` comparison so it only runs on locale change. **Reqs:** S3-R3. **~25 lines.**
- [ ] **T-S3a-07** [GREEN] Edit `lib/main.dart`: resolve `SharedPreferences.getInstance()` ONCE before `runApp`; pass the instance through to providers / service constructors. **Reqs:** S3-R4. **~10 lines.**
- [ ] **T-S3a-08** [GREEN] Audit and edit `lib/servicios/servicio_ecualizador.dart`, `lib/servicios/servicio_grabacion_radio.dart`, and any remaining service calling `SharedPreferences.getInstance()` inline (~25 sites): replace with injected `prefs` parameter. Use `_resolverPrefs` fallback in `servicio_alarmas.dart:399-400` as temporary compat net during migration. **Reqs:** S3-R4. **~30 lines total across files.**
- [ ] **T-S3a-09** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 316-323): add dirty-check in `recalcularTodas` — serialize new config; compare to loaded serialized; skip `_guardar` if identical. Return loaded config unchanged when clean. **Reqs:** S3-R5. **~20 lines.**
- [ ] **T-S3a-10** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 81-108): introduce in-memory `ConfiguracionAlarmas?` cache and a `Future`-chain mutex (mirror `_colaCambioFuente` pattern from `servicio_audio.dart:125`). All mutations: `await _lock` → read cache → mutate → persist → update cache → release. Remove `cargar()` calls before each mutation. **Reqs:** S3-R7. **~50 lines.**
- [ ] **T-S3a-11** [GREEN] Edit `lib/estado/estado_alarmas.dart` (line 32): replace unbounded `Set<String> _ejecucionesEmitidas` with a bounded structure (cap ~200 entries); add pruning of entries with millis suffix older than 24 h on each `_vigilarAlarmasVencidas` pass (lines 326-348). **Reqs:** S3-R6. **~25 lines.**
- [x] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. ~~Add deprecated static shim~~ **DEVIATION:** Dart forbids a static and an instance member with the same name; the ONLY call site (`estado_radio.dart:74`) was rewired in this same change, so no shim exists. `configurarLocalizaciones` was added to the `PuertoAlarmasAndroid` INTERFACE; `EstadoAlarmas.configurarLocalizaciones` forwards to its bridge; `app.dart` configures it once per locale change. **Reqs:** S3-R2. **DONE.**
- [x] **T-S3a-06** [GREEN] `MiniReproductor` converted to `StatefulWidget`; `configurarLocalizaciones` moved to `didChangeDependencies` guarded by cached `Locale`. Alarm-bridge l10n hoisted to `app.dart` `_PaginaPrincipalState.didChangeDependencies` (design 3.3 alternative), also locale-guarded and placed BEFORE the early-return. **Reqs:** S3-R3. **DONE.**
- [x] **T-S3a-07** [GREEN] `main.dart` resolves `SharedPreferences.getInstance()` ONCE before `runApp`; `PluriWaveApp(prefs:)` injects it into `EstadoRadio`, `EstadoAlarmas` (→ default `ServicioAlarmas(prefs:)`), and `EstadoIdioma`. **Reqs:** S3-R4. **DONE.**
- [x] **T-S3a-08** [GREEN] All inline `getInstance()` sites migrated to injected-with-fallback `_resolverPrefs()`: `estado_radio.dart` (10 sites), `servicio_ecualizador.dart` (6), `servicio_grabacion_radio.dart` (4), `servicio_contenido_app.dart` (3). `rg 'SharedPreferences.getInstance()'` in lib/ now shows ONLY main.dart plus one fallback expression per class. **Reqs:** S3-R4. **DONE.**
- [x] **T-S3a-09** [GREEN] Dirty-guard in `recalcularTodas` (servicio_alarmas.dart:189-207): serializes the recalculated config and compares against the cached raw; skips `_guardar` and returns the loaded config when identical. **Reqs:** S3-R5. **DONE.**
- [x] **T-S3a-10** [GREEN] In-memory `ConfiguracionAlarmas? _cache` + `_cacheRaw` + `_enCola` Future-chain writer queue (mirrors `_colaCambioFuente`). ALL mutations (`guardarAlarma`, `eliminarAlarma`, `guardarVacaciones`, `recalcularTodas`, `sincronizarEjecucionesNativas`, `saltarProxima`, `posponerEjecucionHasta`, `completarEjecucion`) run queued over `_configActual()` (cache-or-hydrate). **DEVIATION (intentional):** public `cargar()` still re-reads from prefs (cache reset inside the queue) because `EstadoRadio.importarConfig` writes the raw alarms key DIRECTLY to prefs — a fully cached `cargar()` would make imports invisible until restart. **Reqs:** S3-R7. **DONE.**
- [x] **T-S3a-11** [GREEN] `_ejecucionesEmitidas` bounded: `maxEjecucionesEmitidas = 200` cap with oldest-millis eviction + 24 h retention prune (`_depurarEjecucionesEmitidas`), run on every add (`_registrarEjecucionEmitida`) and at the start of each `_vigilarAlarmasVencidas` pass. `@visibleForTesting` length getter. **Reqs:** S3-R6. **DONE.**
### S3a verification
- [ ] **T-S3a-12** Run `flutter test test/servicios/servicio_alarmas_android_instance_test.dart test/servicios/servicio_alarmas_cache_test.dart test/estado/estado_alarmas_ejecuciones_test.dart test/widgets/mini_reproductor_configurar_test.dart`.
- [ ] **T-S3a-13** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S3a-14** Run `flutter analyze`zero errors.
- [ ] **T-S3a-15** Run `dart format` on all edited Dart files.
- [x] **T-S3a-12** Run `flutter test` on the four new S3a files — green (RED captured first: 1 passed / 6 failed across the batch).
- [x] **T-S3a-13** Run `flutter test` (full suite) — 89/89 passing (77 baseline + 12 new), no regressions.
- [x] **T-S3a-14** Run `flutter analyze``No issues found!`.
- [x] **T-S3a-15** Run `dart format` on all edited Dart files (19 files, 5 reflowed).
### S3a Definition of Done
- `flutter test` green.
@@ -221,23 +221,23 @@ Chain strategy: N/A (local apply)
### S3b pre-work: write failing tests
- [ ] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`:
- [x] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`:
- Test A: interruption `begin/pause` event sets `_intencionReproducir` to false and pauses playback. (S3-R1)
- Test B: interruption `end/shouldResume` resumes playback. (S3-R1)
- Test C: becoming-noisy event pauses playback. (S3-R1)
**~30 lines.**
**DONE — 5 tests (also: end without prior interruption-pause does NOT resume; duck begin/end attenuates and restores) against a fake `ObjetivoAudioInterrumpible`.**
### S3b implementation
- [ ] **T-S3b-02** [GREEN] Create `lib/servicios/servicio_audio_session.dart`: `ServicioAudioSession` wrapper around `package:audio_session`. In `configurar()`: `AudioSession.instance` → configure with `AudioSessionConfiguration.music()` adjusted (playback category, `androidWillPauseWhenDucked: true`). Subscribe to `interruptionEventStream` (pause/duck/resume) and `becomingNoisyEventStream` (pause). On interrupt begin: call `handler.pause()` + set `handler._intencionReproducir = false`. On end with `shouldResume`: call `handler.play()` + set `handler._intencionReproducir = true`. **Reqs:** S3-R1. **~60 lines.**
- [ ] **T-S3b-03** [GREEN] Edit `lib/servicios/servicio_audio.dart` `PluriWaveAudioHandler`: expose `_intencionReproducir` flag (bool, default false). Set true in `play()`/`reproducir()`/`reanudar()`; set false in `pause()`/`detener()`. This is the seam S7 will read. Wire `ServicioAudioSession.configurar()` call from `main.dart` or `PluriWaveAudioHandler` init. **Reqs:** S3-R1. **~20 lines.**
- [x] **T-S3b-02** [GREEN] `lib/servicios/servicio_audio_session.dart` created: `ServicioAudioSession` configures `AudioSessionConfiguration.music().copyWith(androidWillPauseWhenDucked: true)`, subscribes to `interruptionEventStream` + `becomingNoisyEventStream`. Pause-type begin → pause (remembers `_pausadoPorInterrupcion`); end/pause-type → resume ONLY if we paused; end/unknown → never resume; duck begin/end → `setAtenuado(true/false)`; noisy → hard pause, clears the resume flag. Also defines the `ObjetivoAudioInterrumpible` interface (test seam). **Reqs:** S3-R1. **DONE.**
- [x] **T-S3b-03** [GREEN] `PluriWaveAudioHandler implements ObjetivoAudioInterrumpible`: `_intencionReproducir` set true in `play()`/`playMediaItem()` (covers `reproducir`/`reanudar`), false in `pause()`/`stop()` (covers `detener`; interruption pauses route through `pausar()``pause()`). Duck = `setAtenuado` scaling effective volume by 0.3 (`_volumenEfectivo`, respected by `setVolumen` and `_recrearPlayer`). `ServicioAudioSession.configurar()` wired in `main.dart` after `registrarHandler`. This is the seam S7 reads. **Reqs:** S3-R1. **DONE.**
### S3b verification
- [ ] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart`.
- [ ] **T-S3b-05** Run `flutter test` (full suite) — no regressions.
- [ ] **T-S3b-06** Run `flutter analyze`zero errors.
- [ ] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart`.
- [x] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart` — 5/5 green (RED captured first: load failure, file missing).
- [x] **T-S3b-05** Run `flutter test` (full suite) — 89/89, no regressions.
- [x] **T-S3b-06** Run `flutter analyze``No issues found!`.
- [x] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart` — applied (included in the 19-file format pass).
### S3b Definition of Done
- `flutter test` green.