docs(openspec): archive native-alarm-ring and update the native-alarms spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s

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.
This commit is contained in:
2026-07-12 12:36:22 +02:00
parent a788eabfcb
commit 41b95fed44
9 changed files with 1336 additions and 55 deletions
+86 -55
View File
@@ -2,7 +2,7 @@
## Purpose
Native (Kotlin) foreground-service behavior for ringing alarms: reliable start from a broadcast context on Android 14+ via the correct foreground-service-type declaration, ring-scoped device-volume immunity so an alarm is audible even when device media volume is 0, and a single audible fade-in driver across the native-to-Flutter playback handoff.
Native (Kotlin) foreground-service behavior for ringing alarms: reliable start from a broadcast context on Android 14+ via the correct foreground-service-type declaration, and sole native ring-audio ownership — `PluriWaveAlarmService` is the only audio source for the entire ring, on `STREAM_ALARM` via its own `MediaPlayer`, with an exponential dB fade-in ceiling, manually requested/abandoned transient audio focus, and a silent notification channel. System volumes are never written, so nothing ever needs restoring; the Flutter ringing screen is pure UI that only drives Stop/Snooze through `EstadoAlarmas`.
## Requirements
@@ -26,69 +26,100 @@ Native (Kotlin) foreground-service behavior for ringing alarms: reliable start f
- THEN it MUST start without `ForegroundServiceTypeException`
- AND the alarm MUST be audible via the native `USAGE_ALARM` player
### Requirement: Ring-scoped device-volume override
### Requirement: Sole native ring-audio ownership
While an alarm is ringing, the system MUST override `STREAM_MUSIC` to an audible reference level so the alarm is not silenced by device volume 0, and MUST restore the original captured volume when the ring ends through any exit path.
`PluriWaveAlarmService` MUST be the only audio source for the whole ring, on `STREAM_ALARM` via its own `MediaPlayer`. No Dart player or second audible source MUST play.
#### Scenario: Alarm is audible when device media volume is 0
**Testability**: manual on-device QA (native volume APIs)
#### Scenario: Media volume 0 does not silence the ring
**Testability**: on-device QA (audio routing) + flutter test with fakes
- GIVEN device `STREAM_MUSIC` volume is 0
- WHEN an alarm fires and the override captures/raises `STREAM_MUSIC` to an audible level
- THEN the alarm MUST be audible for the full ring duration, not only the pre-handoff window
- WHEN an alarm fires
- THEN it MUST be audible via the service's `STREAM_ALARM` player
- AND no Dart audio path (`EstadoRadio`) MUST run
#### Scenario: Player fade-in ramps within the overridden level
#### Scenario: Fire notification posts with no sound
**Testability**: code-inspection + on-device QA
- GIVEN channel `pluriwave_alarm_fire_v3`
- WHEN the ringing notification posts
- THEN it MUST carry no sound; the player MUST be the only audible source
#### Scenario: Three-stage fallback preserved, single fade anchor
**Testability**: code-inspection + on-device QA
- GIVEN the primary station fails within its timeout
- WHEN the service advances station -> fallback station -> WAV
- THEN whichever stage starts MUST get the same fade curve, anchored at ring start
### Requirement: Exponential dB fade-in ceiling
With `fadeInSegundos > 0`: `gainDb = fraction*40-40`, `curve = 10^(gainDb/20)` on a 50ms tick, `setVolume(s * curve)`, `s` = per-alarm `volumen` as ceiling. With `fadeInSegundos == 0`: start directly at `s`.
#### Scenario: Fade rises exponentially to the ceiling
**Testability**: code-inspection (curve formula)
- GIVEN `fadeInSegundos > 0`, `volumen = s`
- WHEN the ring starts
- THEN volume at `t=0` MUST equal `s * 10^(-40/20)` (~1% of `s`), rising every 50ms
- AND volume at `t=fadeInSegundos` MUST equal exactly `s`
#### Scenario: No-fade path starts pop-free
**Testability**: code-inspection + on-device QA
- GIVEN `fadeInSegundos == 0`
- WHEN the ring starts
- THEN `setVolume(s)` MUST be applied before `start()`, with no pop
### Requirement: Manual transient focus; no system volume writes
The service MUST request `AUDIOFOCUS_GAIN_TRANSIENT` on `STREAM_ALARM` and abandon it at ring end. `setStreamVolume` MUST NOT be called on any stream, ever.
#### Scenario: Focus requested at start, abandoned at end
**Testability**: code-inspection + on-device QA (`dumpsys audio`)
- GIVEN the ring is about to start
- WHEN `startAlarm` runs
- THEN `AUDIOFOCUS_GAIN_TRANSIENT` MUST be requested on `STREAM_ALARM`
- AND WHEN the ring ends THEN focus MUST be abandoned
#### Scenario: Dismiss, snooze, and back tear down with nothing to restore
**Testability**: on-device QA + flutter test with fakes
- GIVEN an alarm is ringing
- WHEN the user dismisses, snoozes, or triggers back
- THEN the player MUST stop, focus MUST be abandoned, and foreground removed
- AND no `setStreamVolume` call MUST have occurred for this ring
### Requirement: Notification channel migration v2 -> v3
The service MUST create `pluriwave_alarm_fire_v3` (no sound) and delete `pluriwave_alarm_fire_v2` exactly once, guarded by a migration flag (same pattern as v1->v2).
#### Scenario: v2 deleted exactly once on upgrade
**Testability**: code-inspection (migration guard) + on-device QA
- GIVEN a device with `pluriwave_alarm_fire_v2` already created
- WHEN the service next ensures its channel
- THEN `_v3` MUST be created and `_v2` deleted; a re-run MUST be a no-op
### Requirement: Ring screen is pure UI
`PantallaAlarmaSonando` MUST only call `EstadoAlarmas.finalizarEjecucion`/`posponerAlarma` from Stop/Snooze/back. It MUST NOT control any audio player or volume/handoff channel. `PuertoAlarmasAndroid` MUST NOT expose `confirmarAudioFlutter`, `forzarVolumenMediaParaAlarma`, or `restaurarVolumenMedia`.
#### Scenario: Buttons and back call only EstadoAlarmas
**Testability**: flutter test with fakes
- GIVEN the override has set an audible `STREAM_MUSIC` reference level
- WHEN the ring's fade-in timer runs
- THEN player volume MUST ramp 5% -> `alarma.volumen` over `alarma.fadeInSegundos`, unchanged from today
- GIVEN the ringing screen is displayed
- WHEN Stop, a Snooze option, or back is triggered
- THEN only `finalizarEjecucion`/`posponerAlarma` MUST be called
- AND zero calls MUST reach `EstadoRadio.audio` or a removed channel method
#### Scenario: Dismiss restores the original captured volume
**Testability**: flutter test with fakes (Dart->channel call) + manual on-device QA (native restore)
#### Scenario: Reduced Android port surface
**Testability**: static/compile-verifiable (Dart analyzer) + flutter test
- GIVEN the override captured `STREAM_MUSIC` at volume `V`
- WHEN the user dismisses the alarm (`_detener()` -> `_silenciarAudio()`)
- THEN `STREAM_MUSIC` MUST be restored to exactly `V`
#### Scenario: Snooze restores the original captured volume
**Testability**: flutter test with fakes (Dart->channel call) + manual on-device QA (native restore)
- GIVEN the override captured `STREAM_MUSIC` at volume `V`
- WHEN the user snoozes the alarm (`_posponer()` -> `_silenciarAudio()`)
- THEN `STREAM_MUSIC` MUST be restored to exactly `V`
#### Scenario: Restore is idempotent across double-exit paths
**Testability**: flutter test with fakes (single/no-op restore call assertion) + manual on-device QA
- GIVEN restore already ran once (`_silenciarAudio()` inside `_detener()`)
- WHEN a second exit path also runs restore (e.g. `dispose()` firing after)
- THEN the second call MUST NOT throw, MUST NOT re-apply a stale value, and MUST leave `STREAM_MUSIC` unchanged
#### Scenario: Normal radio playback never triggers the override
**Testability**: flutter test with fakes (channel never invoked outside a ring) + manual on-device QA
- GIVEN the user is listening to radio with no alarm ringing
- WHEN playback starts, plays, or stops normally
- THEN the override MUST NOT be invoked; `STREAM_MUSIC` stays fully governed by device controls
#### Scenario: App killed mid-ring — best-effort restore only
**Testability**: manual on-device QA; accepted residual gap, not required in automated coverage
- GIVEN an alarm is ringing and the override is active
- WHEN the app process is killed before any exit path runs
- THEN the system SHOULD best-effort restore from service teardown/`onDestroy`, but a residual overridden-volume state MAY occur and is an accepted known gap
### Requirement: Single fade-in driver across native-to-Flutter handoff
During handoff from the native `USAGE_ALARM` player to the Flutter media-stream player, only one fade-in ramp MUST drive audible volume at any instant.
#### Scenario: No double-ramp interleaving at handoff
**Testability**: manual on-device QA (timing-sensitive, cross-process); flutter test with fakes can assert the Dart ramp only runs when native ownership is inactive, but cannot observe native `MediaPlayer.setVolume()` timing
- GIVEN the native fade-in is ramping the `USAGE_ALARM` player
- WHEN the Flutter player becomes ready and handoff occurs (`confirmarAudioFlutter`)
- THEN the native fade-in MUST stop driving audible volume once Flutter takes over; both ramps MUST NOT drive audible volume simultaneously
- GIVEN `PuertoAlarmasAndroid` and its implementations/fakes
- WHEN inspected after this change
- THEN `confirmarAudioFlutter`, `forzarVolumenMediaParaAlarma`, `restaurarVolumenMedia` MUST NOT exist
## Non-Functional Notes