Files
pluriwave/openspec/changes/alarm-volume-ramp-restore/design.md
T
FreeTLab 747738d20a
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
docs(openspec): add SDD artifact trails for bt-device-identity and alarm-volume-ramp-restore
In-progress artifact sets from the current SDD cycles: exploration,
proposal, spec, design, tasks, and verify reports as produced so far.
Also drops a leftover working copy of eq-device-disconnect-revert
whose contents were already committed under changes/archive/.
2026-07-11 00:56:22 +02:00

71 lines
7.3 KiB
Markdown

# Design: Alarm Volume Ramp & Device-Volume Immunity
## Technical Approach
Three independent, rollback-isolated slices realizing proposal #2302. Kotlin owns the manifest fix and a new ring-scoped `STREAM_MUSIC` override (no Flutter volume plugin exists; `MainActivity` already owns the audio channels). Dart drives lifecycle: it invokes override at ring start and restore from the already-centralized exit points. The existing 5%->`alarma.volumen` Dart player ramp is kept; only the audible fade-in *driver* is deduped at handoff. Normal radio playback and `ServicioAudioSession` ducking (S3-R1) are never touched — the override fires only while a ring is active. Kotlin is code-inspection + mandatory on-device QA (flutter build forbidden); Dart follows strict TDD.
## Architecture Decisions
| Decision | Choice | Rejected | Rationale |
|---|---|---|---|
| Manifest FGS combo | `mediaPlayback\|alarm` + add `FOREGROUND_SERVICE_ALARM`; drop `systemExempted`/`FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | Keep `...\|alarm\|systemExempted` | `alarm` is the correct type for an AlarmManager-broadcast-started FGS on API 34+. `systemExempted` is reserved (Play-policy narrow use) and adds nothing once `alarm` is present; matches already-approved D1.1/S1-R1. |
| Runtime `startForeground` type | Change `PluriWaveAlarmService.kt:114-120` to `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_ALARM` | Manifest-only edit | **Load-bearing**: the runtime type is hard-coded and must match the manifest, else `ForegroundServiceTypeException` persists. This is the real fix; the manifest alone would still crash. |
| Override ownership & state | New channel methods `overrideMediaVolumeForRing(fraction)` / `restoreMediaVolume()` on `pluriwave/alarm_scheduler`; captured original volume in a `@Volatile` field on `MainActivity` (companion) | Kotlin object singleton; SharedPreferences | Survives across method calls, not process death (documented residual). `MainActivity` already holds the channel + `AudioManager`. |
| Stream reference level | Force `STREAM_MUSIC` to a FIXED audible reference = `getStreamMaxVolume(STREAM_MUSIC)`; `alarma.volumen` stays the *player* volume via existing Dart ramp | Map `alarma.volumen` to stream volume | Player ramp already governs perceived loudness (5%->target). Stream must only guarantee audibility at volume 0; max is the safe immunity floor. `fraction` arg reserved for future tuning, default 1.0. |
| `setStreamVolume` flags | flag `0` (no `FLAG_SHOW_UI`) | `FLAG_SHOW_UI` | No volume-slider flash during a ring. |
| Restore idempotence | `@Volatile var mediaVolumeOverridden` + saved level; restore is guarded no-op when not overridden; both override and restore are once-guards | Unconditional restore | Prevents clobbering user volume on double-restore; safe from any exit path. |
| Restore call sites (Dart) | Invoke restore inside `_silenciarAudio()` (covers dismiss `_detener` + snooze `_posponer`, both already funnel here) and again in `dispose()` | New per-path calls | Reuses the existing single teardown seam; idempotent guard tolerates the double call. |
| Native backstop | `PluriWaveAlarmService.stopAlarm()` / `onDestroy()` call `MainActivity.restoreMediaVolumeBestEffort()` when engine alive | No backstop | Best-effort recovery if app is killed mid-ring; still leaves the documented process-death gap. |
| Override trigger point | Dart calls override in `_prearrancarAudioAlarma()` (app.dart) — the earliest point the Flutter player starts, before the ring screen pushes | Ring-screen `initState` | Override must precede the media-player becoming audible to avoid a volume-0 gap at handoff. |
| Fade-in dedup gate | Native ramp owns audio until handoff; Dart player pre-starts at 0.05 but its ramp START defers to the existing `confirmarAudioFlutter` success (`_confirmarAudioFlutterListo`, already fired on `reproduciendo`/fallback) | Kill native ramp early; shared timer | Exactly one audible ramp at any instant, reusing the existing handoff protocol seam. No new IPC. |
## Data Flow
ring fires -> PluriWaveAlarmService (USAGE_ALARM, native ramp) --immune--> audible
| |
app.dart _prearrancarAudioAlarma ---> overrideMediaVolumeForRing() [capture+max STREAM_MUSIC]
| |
PantallaAlarmaSonando: player pre-starts @0.05, Dart ramp START gated ------- v
| reproduciendo
└── _confirmarAudioFlutterListo -> confirmFlutterAudio -> service.stop() (native ramp ends)
-> Dart ramp begins 0.05->alarma.volumen (sole driver)
exit (dismiss/snooze/dispose) -> _silenciarAudio + dispose -> restoreMediaVolume() [idempotent]
(backstop) service.stopAlarm/onDestroy -> restoreMediaVolumeBestEffort()
## File Changes
| File | Action | Description |
|---|---|---|
| `android/.../AndroidManifest.xml` | Modify | L57 FGS type -> `mediaPlayback\|alarm`; add `FOREGROUND_SERVICE_ALARM` at L3-16; drop `systemExempted` type + `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` perm |
| `android/.../PluriWaveAlarmService.kt` | Modify | L114-120 runtime type -> `MEDIA_PLAYBACK or ALARM`; add best-effort restore in `stopAlarm`/`onDestroy` |
| `android/.../MainActivity.kt` | Modify | Add `overrideMediaVolumeForRing`/`restoreMediaVolume` channel cases; `@Volatile` capture state; `AudioManager` `STREAM_MUSIC` capture/set(max, flag 0)/restore |
| `lib/servicios/servicio_alarmas_android.dart` | Modify | Add `forzarVolumenMediaParaAlarma(double)` + `restaurarVolumenMedia()` to `PuertoAlarmasAndroid` + impl |
| `lib/app.dart` | Modify | Call override in `_prearrancarAudioAlarma` |
| `lib/pantallas/pantalla_alarma_sonando.dart` | Modify | Call restore in `_silenciarAudio`+`dispose`; gate Dart ramp START on `_confirmarAudioFlutterListo` |
## Interfaces / Contracts
```dart
// PuertoAlarmasAndroid additions
Future<void> forzarVolumenMediaParaAlarma(double fraccion); // -> overrideMediaVolumeForRing
Future<void> restaurarVolumenMedia(); // -> restoreMediaVolume (idempotent)
```
## Testing Strategy
| Layer | What | Approach |
|---|---|---|
| Unit (Dart, TDD) | New wrapper methods emit correct channel calls/args | `servicio_alarmas_android_test.dart` mock-channel pattern |
| Widget (Dart, TDD) | Restore invoked on dismiss/snooze/dispose exactly once; Dart ramp START deferred until confirm | Extend `FakePuertoAlarmasAndroid` (record override/restore call lists); reuse `dismiss_guard_test` harness |
| Kotlin | Manifest+runtime type match; capture/set/restore; idempotence | Code inspection only |
| Manual QA (mandatory human gate) | media vol 0 -> alarm audible; vol restored after dismiss/snooze; Android 14+ service starts | On-device checklist (CC-R1/R2) |
## Migration / Rollout
No data migration. Per-slice independent rollback: revert manifest+runtime type together; disable the override call in `_prearrancarAudioAlarma` to neutralize Slice 2 (restore guard makes it no-worse-than-today); fade-in gate reverts alone.
## Open Questions
- [ ] Confirm with user whether app-quality-and-native-alarms Slice 1 manifest was ever verified on a real Android 14+ device (apply-progress Batch 1 deviation note missing).
- [ ] `fraction` param default 1.0 (max) accepted, or expose per-alarm later? (Deferred; not blocking.)