feat(alarm): make the ring immune to device media volume
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m39s

The alarm's steady-state audio runs on the Flutter media-stream
player after the native handoff, so device volume 0 silenced it
entirely. The ring now forces STREAM_MUSIC to an audible reference:
Dart requests the override before pre-starting alarm audio (fallback
WAV included), Kotlin captures the current volume once and restores
it idempotently on every exit path (dismiss, snooze, dispose), with
a native best-effort backstop in service teardown.

The backstop is handoff-aware via PluriWaveAlarmService.flutterOwnsRing:
confirmFlutterAudio marks the handoff before triggering the native
stop, so the backstop cannot restore the volume mid-ring right as the
Flutter player takes over (that would re-silence the alarm at volume
0). The flag resets at every ring start; Flutter process death after
handoff remains a documented best-effort gap.

The alarm's perceived loudness keeps ramping 5% to the configured
volume through the player as before; normal radio playback and call
ducking never touch the override.

Work unit 2/3 of alarm-volume-ramp-restore (ring volume override).
This commit is contained in:
2026-07-11 09:15:37 +02:00
parent 251d3fd3cd
commit acd903d9a8
9 changed files with 440 additions and 21 deletions
@@ -159,6 +159,14 @@ class MainActivity : AudioServiceActivity() {
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
// Mark the handoff BEFORE triggering the native stop so
// PluriWaveAlarmService.stopAlarm() -- reached via the
// identical ACTION_STOP path a real dismiss uses -- can
// tell "Flutter took over the ring" apart from a true
// ring exit and skip its volume-restore backstop
// accordingly (Requirement: Ring-scoped device-volume
// override, restore only when the ring ends).
PluriWaveAlarmService.flutterOwnsRing = true
PluriWaveAlarmService.stop(this, id)
result.success(null)
}
@@ -217,6 +225,17 @@ class MainActivity : AudioServiceActivity() {
}
result.success(null)
}
"overrideMediaVolumeForRing" -> {
val fraction = call.argument<Number>("fraction")?.toFloat() ?: 1.0f
Log.d(tag, "alarm.channel overrideMediaVolumeForRing fraction=$fraction")
overrideMediaVolumeForRing()
result.success(null)
}
"restoreMediaVolume" -> {
Log.d(tag, "alarm.channel restoreMediaVolume")
restoreMediaVolume()
result.success(null)
}
else -> result.notImplemented()
}
}
@@ -288,6 +307,64 @@ class MainActivity : AudioServiceActivity() {
)
}
// -------------------------------------------------------------------------
// Ring-scoped media-volume override (Requirement: Ring-scoped device-volume
// override). Forces STREAM_MUSIC to an audible reference level while an
// alarm rings so the Flutter media-stream player is never silenced by a
// device media volume of 0, then restores the captured value on exit.
// -------------------------------------------------------------------------
/**
* Captures the current STREAM_MUSIC volume and raises it to the fixed
* audible reference level (device max) so the alarm cannot be silenced
* by device volume 0. Idempotent: a second call while already overridden
* is a no-op so the originally captured value is never clobbered.
*/
private fun overrideMediaVolumeForRing() {
if (mediaVolumeOverridden) {
Log.d(tag, "alarm.channel overrideMediaVolumeForRing skipped (already overridden)")
return
}
try {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, max, 0)
capturedMediaVolume = current
mediaVolumeOverridden = true
Log.d(tag, "alarm.channel overrideMediaVolumeForRing captured=$current max=$max")
} catch (error: Throwable) {
Log.e(tag, "alarm.channel overrideMediaVolumeForRing failed", error)
}
}
/**
* Restores STREAM_MUSIC to the value captured by
* [overrideMediaVolumeForRing]. Idempotent: a no-op when no override is
* active, so double-exit paths (e.g. dismiss's `_silenciarAudio` +
* `dispose`, or the native backstop firing after Dart already restored)
* never throw and never re-apply a stale value.
*/
private fun restoreMediaVolume() {
if (!mediaVolumeOverridden) {
Log.d(tag, "alarm.channel restoreMediaVolume skipped (not overridden)")
return
}
val target = capturedMediaVolume
try {
if (target != null) {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
}
Log.d(tag, "alarm.channel restoreMediaVolume restored=$target")
} catch (error: Throwable) {
Log.e(tag, "alarm.channel restoreMediaVolume failed", error)
} finally {
mediaVolumeOverridden = false
capturedMediaVolume = null
}
}
private fun requestExactAlarmPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
@@ -828,6 +905,18 @@ class MainActivity : AudioServiceActivity() {
@Volatile
private var activeInstance: MainActivity? = null
/**
* Ring-scoped STREAM_MUSIC override state (Requirement: Ring-scoped
* device-volume override). @Volatile, not persisted: does NOT survive
* process death — documented residual gap, best-effort restore only
* via [restoreMediaVolumeBestEffort].
*/
@Volatile
private var mediaVolumeOverridden: Boolean = false
@Volatile
private var capturedMediaVolume: Int? = null
/**
* Bridge for components without an activity (PluriWaveAlarmService):
* forwards alarm events through the existing alarmFired MethodChannel
@@ -844,5 +933,33 @@ class MainActivity : AudioServiceActivity() {
activity.alarmMethodChannel?.invokeMethod("alarmFired", payload)
}
}
/**
* Best-effort backstop for PluriWaveAlarmService teardown paths
* (stopAlarm/onDestroy): restores the ring-scoped media volume
* override when the Flutter engine/activity is alive. No-op and
* never throws when the activity is dead (Decision: Native backstop).
*
* Callers guard this: PluriWaveAlarmService.stopAlarm()/onDestroy()
* only invoke it when PluriWaveAlarmService.flutterOwnsRing is
* false, since stopAlarm() also runs at the native-to-Flutter
* handoff (confirmFlutterAudio) and this method must never restore
* mid-ring -- see [PluriWaveAlarmService.flutterOwnsRing]. The
* idempotent guard in restoreMediaVolume() remains a secondary
* safety net for legitimate double-calls at a true ring end (e.g.
* stopAlarm() then onDestroy() for the same exit).
*/
fun restoreMediaVolumeBestEffort() {
val activity = activeInstance
if (activity == null) {
Log.d(STATIC_TAG, "alarm.channel restoreMediaVolumeBestEffort skipped (engine dead)")
return
}
try {
activity.restoreMediaVolume()
} catch (error: Throwable) {
Log.e(STATIC_TAG, "alarm.channel restoreMediaVolumeBestEffort failed", error)
}
}
}
}
@@ -90,6 +90,11 @@ class PluriWaveAlarmService : Service() {
return
}
activeAlarmId = alarmId
// Reset for this new ring: flutterOwnsRing must never carry a stale
// `true` forward from a PREVIOUS ring's confirmFlutterAudio handoff,
// or this ring's own backstop restore would be wrongly suppressed
// (Requirement: Ring-scoped device-volume override).
flutterOwnsRing = false
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
@@ -366,6 +371,19 @@ class PluriWaveAlarmService : Service() {
player = null
activeAlarmId = null
releaseWakeLock()
// Best-effort backstop restore (Requirement: Ring-scoped device-volume
// override; Scenario "App killed mid-ring"). Skipped when Flutter has
// taken over the ring (flutterOwnsRing == true): stopAlarm() also runs
// at the native-to-Flutter handoff (confirmFlutterAudio), which is NOT
// a ring exit, and restoring here would silence the Flutter-driven
// remainder of the ring. From handoff onward Dart owns restore via
// _silenciarAudio()/dispose() plus the idempotent restoreMediaVolume()
// guard. Native-only exits (fire-notification STOP, real snooze,
// teardown before any handoff) keep this backstop, since
// flutterOwnsRing is still false for those.
if (!flutterOwnsRing) {
runCatching { MainActivity.restoreMediaVolumeBestEffort() }
}
if (alarmId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
@@ -500,6 +518,16 @@ class PluriWaveAlarmService : Service() {
override fun onDestroy() {
stopAlarm(activeAlarmId)
// Defensive-in-depth: covers any onDestroy path that could ever
// bypass stopAlarm() directly (e.g. abrupt service teardown).
// Idempotent, so redundant with the call already inside stopAlarm().
// Same flutterOwnsRing guard as stopAlarm() -- skip when Flutter has
// taken over the ring, so an onDestroy() racing in after handoff
// (e.g. the system reclaiming the now-idle service) cannot restore
// mid-ring either.
if (!flutterOwnsRing) {
runCatching { MainActivity.restoreMediaVolumeBestEffort() }
}
super.onDestroy()
}
@@ -518,6 +546,30 @@ class PluriWaveAlarmService : Service() {
private const val FADE_IN_STEP_MILLIS = 250L
private const val FADE_IN_START_FRACTION = 0.05f
/**
* Set by [MainActivity]'s `confirmFlutterAudio` handler immediately
* BEFORE it triggers the native stop, to distinguish "Flutter took
* over the ring" (native-to-Flutter handoff, not a ring exit) from a
* true ring exit (dismiss/snooze/service teardown). [stopAlarm] and
* [onDestroy] read this to skip the best-effort volume-restore
* backstop during handoff -- restoring here would silence the
* Flutter-driven remainder of the ring (Requirement: Ring-scoped
* device-volume override, restore only on true ring end). From
* handoff onward Dart owns restore via `_silenciarAudio()`/
* `dispose()` and the idempotent `restoreMediaVolume()` guard on
* [MainActivity]. Reset to `false` at the start of every new ring in
* [startAlarm] so a stale `true` left over from a PREVIOUS ring can
* never suppress the CURRENT ring's backstop.
*
* Accepted residual gap: if the Flutter process dies AFTER handoff
* (flag already `true`) but BEFORE Dart's own restore runs, no
* restorer fires at all -- same class of accepted gap as the
* pre-handoff process-death residual already documented on
* [MainActivity.restoreMediaVolumeBestEffort].
*/
@Volatile
var flutterOwnsRing: Boolean = false
fun start(context: Context, source: Intent) {
ensureChannel(context)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
+8
View File
@@ -363,6 +363,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
// Must run FIRST, before any early return: the override needs to be in
// effect for the whole ring, including fallback-WAV-only alarms that
// never reach the station-playback branch below (Requirement:
// Ring-scoped device-volume override).
await context.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(
1.0,
);
if (!mounted) return;
final emisora = alarma.emisora;
if (emisora == null) return;
final radio = context.read<EstadoRadio>();
@@ -40,6 +40,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
bool _fallbackActivo = false;
bool _radioIntentada = false;
bool _audioFlutterConfirmado = false;
bool _volumenMediaRestaurado = false;
@override
void initState() {
@@ -138,6 +139,23 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
);
}
/// Restores the ring-scoped `STREAM_MUSIC` override at most once per
/// screen instance (Requirement: Ring-scoped device-volume override,
/// Scenario "Restore is idempotent across double-exit paths"). Both
/// `_silenciarAudio` (dismiss/snooze) and `dispose` call this; the guard
/// here plus the idempotent Kotlin-side restore together keep any
/// exit-path ordering safe. Failures never propagate — a broken restore
/// must not block dismiss/snooze.
Future<void> _restaurarVolumenMediaUnaVez() async {
if (_volumenMediaRestaurado) return;
_volumenMediaRestaurado = true;
try {
await context.read<EstadoAlarmas>().android.restaurarVolumenMedia();
} catch (e) {
debugPrint('[PluriWave][alarmas] restaurar volumen media fallo: $e');
}
}
/// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart
/// fallback player and fade timer MUST die before the alarm is re-programmed
/// natively, otherwise the local fallback keeps looping after snooze.
@@ -210,6 +228,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} catch (e) {
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
}
await _restaurarVolumenMediaUnaVez();
}
/// Dismisses the alarm screen safely in both live-app and dead-app states.
@@ -240,6 +259,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_fadeInTimer?.cancel();
_estadoSub?.cancel();
_fallbackPlayer.dispose();
unawaited(_restaurarVolumenMediaUnaVez());
super.dispose();
}
@@ -143,6 +143,19 @@ abstract class PuertoAlarmasAndroid {
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
Future<void> confirmarAudioFlutter(String alarmaId);
/// Forces `STREAM_MUSIC` to the fixed audible reference level for the
/// duration of an alarm ring so the alarm cannot be silenced by a device
/// media volume of 0 (Requirement: Ring-scoped device-volume override).
/// [fraccion] is reserved for future tuning; the current native
/// implementation always targets the device max regardless of its value.
Future<void> forzarVolumenMediaParaAlarma(double fraccion);
/// Restores `STREAM_MUSIC` to the value captured by
/// [forzarVolumenMediaParaAlarma]. Idempotent: safe to call even when no
/// override is active or it was already restored.
Future<void> restaurarVolumenMedia();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
@@ -281,6 +294,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
Future<void> confirmarAudioFlutter(String alarmaId) =>
_logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId});
@override
Future<void> forzarVolumenMediaParaAlarma(double fraccion) =>
_logAndInvokeVoid('overrideMediaVolumeForRing', {'fraction': fraccion});
@override
Future<void> restaurarVolumenMedia() =>
_logAndInvokeVoid('restoreMediaVolume', {});
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
@@ -47,30 +47,98 @@ Chain strategy: pending
## Phase 2: Ring-Scoped Volume Override — Kotlin Channel Methods (Slice 2, code-inspection only)
- [ ] 2.1 In `MainActivity.kt`, add `@Volatile` companion-scoped state: `mediaVolumeOverridden: Boolean` and `capturedMediaVolume: Int?` to track ring-scoped override without surviving process death (documented residual gap).
- [ ] 2.2 In `MainActivity.kt`'s `alarm_scheduler` `when (call.method)` block (near L79-218), add `"overrideMediaVolumeForRing"` case: capture current `AudioManager.STREAM_MUSIC` volume into `capturedMediaVolume` (only if not already overridden — idempotent guard), then `setStreamVolume(STREAM_MUSIC, getStreamMaxVolume(STREAM_MUSIC), 0)` (flag `0` = no `FLAG_SHOW_UI`, no slider flash). Set `mediaVolumeOverridden = true`. `fraction` arg accepted but unused (reserved, default `1.0` = max reference level, per design).
- [ ] 2.3 In the same `when` block, add `"restoreMediaVolume"` case: no-op if `mediaVolumeOverridden == false` (idempotent guard); otherwise `setStreamVolume(STREAM_MUSIC, capturedMediaVolume, 0)`, then clear `mediaVolumeOverridden = false` and `capturedMediaVolume = null`.
- [ ] 2.4 Add a public `restoreMediaVolumeBestEffort()` method on `MainActivity` (or companion) that `PluriWaveAlarmService` can call as a backstop when the engine is alive.
- [ ] 2.5 In `PluriWaveAlarmService.kt`'s `stopAlarm()` (L356-381) and `onDestroy()` (L501-504), call the best-effort restore before/alongside existing teardown, guarded so it never throws if the engine/activity is unavailable.
- [ ] 2.6 Static check: `rg 'overrideMediaVolumeForRing|restoreMediaVolume' android/.../MainActivity.kt` shows both channel cases present.
- [ ] 2.7 Static check: `rg 'mediaVolumeOverridden' android/.../MainActivity.kt` shows the guard read in BOTH the override and restore branches (idempotence, Requirement: Ring-scoped device-volume override, Scenario "Restore is idempotent across double-exit paths").
- [ ] 2.8 Static check: `rg 'restoreMediaVolumeBestEffort' android/.../PluriWaveAlarmService.kt` shows it called from both `stopAlarm` and `onDestroy`.
- [ ] 2.9 `flutter analyze` — confirm no Kotlin/lint regressions.
- [x] 2.1 In `MainActivity.kt`, add `@Volatile` companion-scoped state: `mediaVolumeOverridden: Boolean` and `capturedMediaVolume: Int?` to track ring-scoped override without surviving process death (documented residual gap).
- [x] 2.2 In `MainActivity.kt`'s `alarm_scheduler` `when (call.method)` block (near L79-218), add `"overrideMediaVolumeForRing"` case: capture current `AudioManager.STREAM_MUSIC` volume into `capturedMediaVolume` (only if not already overridden — idempotent guard), then `setStreamVolume(STREAM_MUSIC, getStreamMaxVolume(STREAM_MUSIC), 0)` (flag `0` = no `FLAG_SHOW_UI`, no slider flash). Set `mediaVolumeOverridden = true`. `fraction` arg accepted but unused (reserved, default `1.0` = max reference level, per design).
- [x] 2.3 In the same `when` block, add `"restoreMediaVolume"` case: no-op if `mediaVolumeOverridden == false` (idempotent guard); otherwise `setStreamVolume(STREAM_MUSIC, capturedMediaVolume, 0)`, then clear `mediaVolumeOverridden = false` and `capturedMediaVolume = null`.
- [x] 2.4 Add a public `restoreMediaVolumeBestEffort()` method on `MainActivity` (or companion) that `PluriWaveAlarmService` can call as a backstop when the engine is alive.
- [x] 2.5 In `PluriWaveAlarmService.kt`'s `stopAlarm()` (L356-381) and `onDestroy()` (L501-504), call the best-effort restore before/alongside existing teardown, guarded so it never throws if the engine/activity is unavailable.
> **RISK NOTE (sdd-apply, 2026-07-11):** `stopAlarm()` also fires at the native-to-Flutter handoff
> (`confirmFlutterAudio` channel case -> `PluriWaveAlarmService.stop()` -> `stopAlarm()`), not only
> at a true dismiss/snooze ring exit — `stopAlarm()`'s caller has no way to distinguish "handoff"
> from "real exit" (both `stopNativeAlarmSound` and `confirmFlutterAudio` call the identical
> `PluriWaveAlarmService.stop(this, id)`). Implemented exactly as specified (design #2310 + this
> task both call for wiring both `stopAlarm()`/`onDestroy()`), but this means the best-effort
> restore backstop COULD fire mid-ring at the handoff moment, restoring the original (possibly
> zero) device volume right as the Flutter player takes over — which would silence the
> Flutter-driven remainder of the ring and contradict Scenario "Alarm is audible when device media
> volume is 0". Not verifiable without an emulator (Kotlin is code-inspection-only, `flutter
> build`/gradle forbidden this phase). Phase 5 QA 5.1 is the exact scenario that will surface this
> if it manifests — treat as the primary manual QA risk for this change, and flag to the
> human/design owner before merge.
> **FIX (sdd-apply, 2026-07-11):** Risk #1 resolved. Added a `@Volatile` companion flag
> `PluriWaveAlarmService.flutterOwnsRing` (default `false`, declared alongside the service's other
> companion constants). `MainActivity.kt`'s `confirmFlutterAudio` handler sets it to `true`
> immediately BEFORE calling `PluriWaveAlarmService.stop(this, id)` (the handoff trigger) — the
> flag is now visible by the time the resulting `ACTION_STOP` intent reaches `stopAlarm()`.
> `stopAlarm()` and `onDestroy()` now wrap the `restoreMediaVolumeBestEffort()` call in
> `if (!flutterOwnsRing)`, so the backstop no longer fires at the handoff moment — restore
> ownership passes cleanly to Dart's `_silenciarAudio()`/`dispose()` path (already wired, Phase 3)
> for the remainder of the ring. Native-only exits (fire-notification STOP, real snooze, teardown
> before any handoff) are unaffected — `flutterOwnsRing` stays `false` there, so the backstop
> still fires exactly as before. The flag resets to `false` at the top of `startAlarm()`
> (immediately after the `activeAlarmId` re-entrancy guard passes) so a stale `true` left over
> from a PREVIOUS ring's handoff can never suppress the CURRENT ring's backstop. New accepted
> residual, documented inline on the flag: if the Flutter process dies AFTER handoff (flag already
> `true`) but BEFORE Dart's own restore runs, no restorer fires — same class of gap as the
> pre-handoff process-death residual already documented on `restoreMediaVolumeBestEffort()`.
> Verified via `rg 'flutterOwnsRing'` (flag declared in `PluriWaveAlarmService`'s companion; set
> in `MainActivity.kt`'s `confirmFlutterAudio` case; read-guarded in both `stopAlarm()` and
> `onDestroy()`; reset in `startAlarm()`) and `flutter analyze` (0 issues, Dart untouched, no
> `flutter build`/gradle run). Only `PluriWaveAlarmService.kt` and `MainActivity.kt` changed for
> this fix. Phase 5 QA 5.1 remains the recommended on-device confirmation — code inspection cannot
> fully substitute for a real handoff-timing test.
- [x] 2.6 Static check: `rg 'overrideMediaVolumeForRing|restoreMediaVolume' android/.../MainActivity.kt` shows both channel cases present.
- [x] 2.7 Static check: `rg 'mediaVolumeOverridden' android/.../MainActivity.kt` shows the guard read in BOTH the override and restore branches (idempotence, Requirement: Ring-scoped device-volume override, Scenario "Restore is idempotent across double-exit paths").
- [x] 2.8 Static check: `rg 'restoreMediaVolumeBestEffort' android/.../PluriWaveAlarmService.kt` shows it called from both `stopAlarm` and `onDestroy`.
- [x] 2.9 `flutter analyze` — confirm no Kotlin/lint regressions. (0 issues.)
## Phase 3: Ring-Scoped Volume Override — Dart Port + Wiring (Slice 2, strict TDD)
- [ ] 3.1 (RED) In `test/servicios/servicio_alarmas_android_test.dart`, add a test asserting `ServicioAlarmasAndroid.forzarVolumenMediaParaAlarma(1.0)` invokes channel method `overrideMediaVolumeForRing` with `{'fraction': 1.0}`, using the existing mock-channel pattern (`MethodChannel('pluriwave/alarm_scheduler')` + `llamadas` list). Run `flutter test` — confirm it fails (method does not exist).
- [ ] 3.2 (RED) In the same file, add a test asserting `ServicioAlarmasAndroid.restaurarVolumenMedia()` invokes channel method `restoreMediaVolume` with no args. Run `flutter test` — confirm it fails.
- [ ] 3.3 (GREEN) Add `Future<void> forzarVolumenMediaParaAlarma(double fraccion)` and `Future<void> restaurarVolumenMedia()` to `PuertoAlarmasAndroid` (abstract, `lib/servicios/servicio_alarmas_android.dart`) and implement both on `ServicioAlarmasAndroid` using the existing `_logAndInvokeVoid` helper pattern. Run `flutter test` — confirm 3.1-3.2 pass.
- [ ] 3.4 (GREEN) Extend `test/helpers/fakes_alarmas.dart`'s `FakePuertoAlarmasAndroid`: implement the two new abstract methods, recording calls into new lists `volumenForzado: List<double>` and `volumenRestaurado: int` (call count) so widget tests can assert invocation order/count.
- [ ] 3.5 (RED) In `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` (or a new focused test file), add a widget test asserting `_silenciarAudio` -> restore is called exactly once on `_detener()` (dismiss) using `env.android.volumenRestaurado`. Run `flutter test` — confirm it fails.
- [ ] 3.6 (RED) Add the equivalent test for `_posponer()` (snooze) — restore called exactly once. Run `flutter test` — confirm it fails.
- [ ] 3.7 (RED) Add a test asserting restore is called at most once total even when both `_silenciarAudio()` (inside `_detener`) and `dispose()` run in sequence (idempotence at the Dart call-site level — the widget always calls restore in `dispose()` too, per design; assert the FAKE'S restore counter, not double-invocation of the real guard, since idempotence itself lives in Kotlin). Run `flutter test` — confirm it fails.
- [ ] 3.8 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, call `context.read<EstadoAlarmas>().android.restaurarVolumenMedia()` inside `_silenciarAudio()` (L202-213, alongside `_liberarAudioLocal()`/`radio.audio.pausar()`, wrapped in its own try/catch so a failure never blocks dismiss/snooze) AND inside `dispose()` (L238-244). Run `flutter test` — confirm 3.5-3.7 pass.
- [ ] 3.9 (RED) In `test/pantallas` (widget test, or a lighter unit-style test on `app.dart`'s ring-start seam if testable in isolation), add a test asserting `forzarVolumenMediaParaAlarma` is invoked when an alarm ring starts, at the TOP of `_prearrancarAudioAlarma` in `lib/app.dart` (BEFORE the `if (emisora == null) return;` early exit at L367) — the override must apply even when the alarm uses the fallback WAV path, not only the station path. Run `flutter test` — confirm it fails.
- [ ] 3.10 (GREEN) In `lib/app.dart`, call `context.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(1.0)` as the FIRST statement inside `_prearrancarAudioAlarma` (L365), before the `emisora == null` early return. Run `flutter test` — confirm 3.9 passes.
- [ ] 3.11 (RED) Add a test asserting the override/restore channel methods are NEVER invoked during normal radio playback with no alarm ringing (Requirement: Ring-scoped device-volume override, Scenario "Normal radio playback never triggers the override") — assert `env.android.volumenForzado` stays empty across a plain play/pause cycle on `EstadoRadio` outside any alarm flow. Run `flutter test` — confirm it fails or passes vacuously (should already pass since no other code path calls these methods yet — treat as a REGRESSION GUARD, not a RED/GREEN pair, if 3.3-3.10 are already in place).
- [ ] 3.12 (REFACTOR) Run `flutter test` for the full suite plus `flutter analyze` — confirm no regressions in existing alarm/radio tests.
- [x] 3.1 (RED) In `test/servicios/servicio_alarmas_android_test.dart`, add a test asserting `ServicioAlarmasAndroid.forzarVolumenMediaParaAlarma(1.0)` invokes channel method `overrideMediaVolumeForRing` with `{'fraction': 1.0}`, using the existing mock-channel pattern (`MethodChannel('pluriwave/alarm_scheduler')` + `llamadas` list). Run `flutter test` — confirm it fails (method does not exist).
- [x] 3.2 (RED) In the same file, add a test asserting `ServicioAlarmasAndroid.restaurarVolumenMedia()` invokes channel method `restoreMediaVolume` with no args. Run `flutter test` — confirm it fails.
- [x] 3.3 (GREEN) Add `Future<void> forzarVolumenMediaParaAlarma(double fraccion)` and `Future<void> restaurarVolumenMedia()` to `PuertoAlarmasAndroid` (abstract, `lib/servicios/servicio_alarmas_android.dart`) and implement both on `ServicioAlarmasAndroid` using the existing `_logAndInvokeVoid` helper pattern. Run `flutter test` — confirm 3.1-3.2 pass.
- [x] 3.4 (GREEN) Extend `test/helpers/fakes_alarmas.dart`'s `FakePuertoAlarmasAndroid`: implement the two new abstract methods, recording calls into new lists `volumenForzado: List<double>` and `volumenRestaurado: int` (call count) so widget tests can assert invocation order/count.
- [x] 3.5 (RED) In `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart` (or a new focused test file), add a widget test asserting `_silenciarAudio` -> restore is called exactly once on `_detener()` (dismiss) using `env.android.volumenRestaurado`. Run `flutter test` — confirm it fails.
- [x] 3.6 (RED) Add the equivalent test for `_posponer()` (snooze) — restore called exactly once. Run `flutter test` — confirm it fails.
- [x] 3.7 (RED) Add a test asserting restore is called at most once total even when both `_silenciarAudio()` (inside `_detener`) and `dispose()` run in sequence (idempotence at the Dart call-site level — the widget always calls restore in `dispose()` too, per design; assert the FAKE'S restore counter, not double-invocation of the real guard, since idempotence itself lives in Kotlin). Run `flutter test` — confirm it fails.
- [x] 3.8 (GREEN) In `lib/pantallas/pantalla_alarma_sonando.dart`, call `context.read<EstadoAlarmas>().android.restaurarVolumenMedia()` inside `_silenciarAudio()` (L202-213, alongside `_liberarAudioLocal()`/`radio.audio.pausar()`, wrapped in its own try/catch so a failure never blocks dismiss/snooze) AND inside `dispose()` (L238-244). Run `flutter test` — confirm 3.5-3.7 pass.
> Implementation note: added a Dart-side `_volumenMediaRestaurado` guard (mirroring the existing
> `_audioFlutterConfirmado` idiom) so the channel call fires at most once per screen instance
> regardless of which of the two call sites runs first — this is what makes 3.7's "at most once"
> assertion literally true at the Dart layer, on top of the Kotlin-side idempotent guard.
- [x] 3.9 (RED) In `test/pantallas` (widget test, or a lighter unit-style test on `app.dart`'s ring-start seam if testable in isolation), add a test asserting `forzarVolumenMediaParaAlarma` is invoked when an alarm ring starts, at the TOP of `_prearrancarAudioAlarma` in `lib/app.dart` (BEFORE the `if (emisora == null) return;` early exit at L367) — the override must apply even when the alarm uses the fallback WAV path, not only the station path. Run `flutter test` — confirm it fails.
> **DEVIATION (sdd-apply, 2026-07-11):** not testable in isolation, so no `flutter test` RED/GREEN
> pair exists for this task — confirmed via the escape hatch this task's own wording allows.
> `_prearrancarAudioAlarma` and `_PaginaPrincipal` are private to `app.dart`; the only public entry
> point (`PluriWaveApp`) hardcodes real, non-injectable `EstadoRadio`/`EstadoAlarmas` instances
> (`ServicioDispositivoAudioReal()`, `EstadoAlarmas(prefs: prefs)` — no fake-injection seam), and no
> existing test in the suite renders `PluriWaveApp`/`_PaginaPrincipal` for this exact reason (every
> alarm widget test bypasses it, constructing `EstadoRadio`/`EstadoAlarmas` directly with fakes).
> Building a real widget test would require an unscoped DI refactor to `app.dart`'s constructor,
> which is not listed in design #2310's File Changes (only "call override in
> `_prearrancarAudioAlarma`" — a one-line-style modify). Verified via source inspection instead:
> `forzarVolumenMediaParaAlarma(1.0)` is confirmed the first statement inside
> `_prearrancarAudioAlarma`, before `final emisora = alarma.emisora;` and the early return (see
> `lib/app.dart:365-374`). Recommend a follow-up task if genuine automated coverage of this exact
> seam is required (would need a testable DI seam on `PluriWaveApp`).
- [x] 3.10 (GREEN) In `lib/app.dart`, call `context.read<EstadoAlarmas>().android.forzarVolumenMediaParaAlarma(1.0)` as the FIRST statement inside `_prearrancarAudioAlarma` (L365), before the `emisora == null` early return. Run `flutter test` — confirm 3.9 passes.
> Implementation note: this introduced an `await` before the pre-existing `context.read<EstadoRadio>()`
> a few lines below, which `flutter analyze` correctly flagged as `use_build_context_synchronously`.
> Fixed with `if (!mounted) return;` right after the new await, matching the same guard pattern
> already used elsewhere in this file (e.g. `_abrirAlarmaSonando`) — not just a lint silencer, this
> also prevents reading providers / starting playback on an unmounted widget if the app is
> backgrounded mid-call.
- [x] 3.11 (RED) Add a test asserting the override/restore channel methods are NEVER invoked during normal radio playback with no alarm ringing (Requirement: Ring-scoped device-volume override, Scenario "Normal radio playback never triggers the override") — assert `env.android.volumenForzado` stays empty across a plain play/pause cycle on `EstadoRadio` outside any alarm flow. Run `flutter test` — confirm it fails or passes vacuously (should already pass since no other code path calls these methods yet — treat as a REGRESSION GUARD, not a RED/GREEN pair, if 3.3-3.10 are already in place). (Confirmed green on first run, as anticipated — regression guard, not RED/GREEN.)
- [x] 3.12 (REFACTOR) Run `flutter test` for the full suite plus `flutter analyze` — confirm no regressions in existing alarm/radio tests. (Full suite: 1 unrelated failure in `servicio_grabacion_radio_test.dart` — timing-sensitive, reproduces only under full-suite load, passes standalone; confirmed pre-existing, not a Slice 2 regression. All alarm/radio/Slice-2 suites green; `flutter analyze` 0 issues.)
## Phase 4: Fade-In Dedup at Handoff (Slice 3, strict TDD)
+18
View File
@@ -17,6 +17,14 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
bool ignoraOptimizacionBateria = true;
int solicitudesExencionBateria = 0;
/// Records each [forzarVolumenMediaParaAlarma] call (Slice 2: ring-scoped
/// device-volume override).
final volumenForzado = <double>[];
/// Counts [restaurarVolumenMedia] calls (Slice 2). A plain counter, not a
/// list: widget tests only need to assert how many times restore ran.
int volumenRestaurado = 0;
/// Test-only failure switch (Design D7): when true, [programar] throws
/// instead of scheduling, enabling failure-path coverage that the fake
/// could not otherwise produce.
@@ -59,6 +67,16 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
detenidas.add(alarmaId);
}
@override
Future<void> forzarVolumenMediaParaAlarma(double fraccion) async {
volumenForzado.add(fraccion);
}
@override
Future<void> restaurarVolumenMedia() async {
volumenRestaurado++;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
DiagnosticoAlarmasAndroid(
@@ -356,4 +356,91 @@ void main() {
},
);
});
group('PantallaAlarmaSonando media-volume override restore (Slice 2)', () {
testWidgets('detener: restaura el volumen de medios exactamente una vez', (
tester,
) async {
final env = await _buildEnv();
addTearDown(env.dispose);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
expect(env.android.volumenRestaurado, 1);
});
testWidgets('posponer: restaura el volumen de medios exactamente una vez', (
tester,
) async {
final env = await _buildEnv();
addTearDown(env.dispose);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
await tester.pumpAndSettle();
expect(env.android.volumenRestaurado, 1);
});
testWidgets(
'detener: _silenciarAudio y dispose en secuencia no duplican la '
'restauracion (idempotencia en el call-site Dart)',
(tester) async {
final env = await _buildEnv();
addTearDown(env.dispose);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
// Proves dispose() really ran too (both call sites fired) — the
// idempotence guard must still cap the counter at 1.
expect(find.byType(PantallaAlarmaSonando), findsNothing);
expect(
env.android.volumenRestaurado,
1,
reason:
'restaurarVolumenMedia debe invocarse a lo sumo una vez por '
'pantalla, aunque _silenciarAudio (dentro de _detener) y '
'dispose() ambos lo llamen',
);
},
);
});
group('EstadoRadio reproduccion normal nunca dispara el override de volumen '
'(Slice 2, guardia de regresion)', () {
test('ciclo de reproducir/pausar fuera de una alarma no toca el canal '
'de volumen de medios', () async {
final env = await _buildEnv();
addTearDown(env.dispose);
final emisora = env.estadoAlarmas.alarmas.single.emisora!;
await env.radio.reproducir(emisora);
await env.radio.audio.pausar();
expect(env.android.volumenForzado, isEmpty);
expect(env.android.volumenRestaurado, 0);
});
});
}
@@ -106,4 +106,32 @@ void main() {
);
},
);
test(
'forzarVolumenMediaParaAlarma invoca overrideMediaVolumeForRing con fraction',
() async {
final servicio = ServicioAlarmasAndroid(channel: channel);
await servicio.forzarVolumenMediaParaAlarma(1.0);
final llamada = llamadas.singleWhere(
(c) => c.method == 'overrideMediaVolumeForRing',
);
expect(llamada.arguments, {'fraction': 1.0});
},
);
test(
'restaurarVolumenMedia invoca restoreMediaVolume sin argumentos',
() async {
final servicio = ServicioAlarmasAndroid(channel: channel);
await servicio.restaurarVolumenMedia();
final llamada = llamadas.singleWhere(
(c) => c.method == 'restoreMediaVolume',
);
expect(llamada.arguments, <String, Object?>{});
},
);
}