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/.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# 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.)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Exploration: alarm-volume-ramp-restore
|
||||
|
||||
## Current State — dual-track handoff architecture (by design)
|
||||
|
||||
Alarm audio is a deliberate handoff (documented in `app-quality-and-native-alarms/design.md` Decision 1.5, `docs/alarmas-pantalla-apagada.md`):
|
||||
|
||||
1. **Native track**: `PluriWaveAlarmReceiver(ACTION_FIRE)` → `PluriWaveAlarmService.kt` plays via `MediaPlayer` with `AudioAttributes.USAGE_ALARM` (L350-354) — Android ALARM stream, immune to media-volume-0 by OS design. Has a working fade-in (`startFadeIn`, `FADE_IN_START_FRACTION = 0.05f`, 250ms steps).
|
||||
2. **Flutter track**: `app.dart` `_prearrancarAudioAlarma()` starts the regular radio player (`PluriWaveAudioHandler`/just_audio) at volume 0.05; `pantalla_alarma_sonando.dart` `_iniciarFadeIn()` ramps it 0.05 → `alarma.volumen`. Uses `AudioSessionConfiguration.music()` — normal media session, fully subject to device media volume.
|
||||
3. **Handoff**: when the Flutter player reaches `reproduciendo`, `confirmarAudioFlutter` → `MainActivity` → `PluriWaveAlarmService.stop()`. Native audio torn down; Flutter media-stream player is the sole source for the rest of the ring.
|
||||
|
||||
Grep-verified: **zero occurrences of `setStreamVolume`/AudioManager volume-override anywhere in the codebase, ever** — the app has never programmatically overridden device stream volume. The "used to ignore device volume" impression comes from the native track's brief immune window, not a regressed capability.
|
||||
|
||||
## Root Causes
|
||||
|
||||
**A — architectural**: steady-state alarm audio is the Flutter media-stream player within 1-3s of firing. Media volume 0 → silent alarm (symptom 2).
|
||||
|
||||
**B — manifest bug (verified)**: `AndroidManifest.xml:55-58` declares `foregroundServiceType="mediaPlayback|systemExempted"` and NO `FOREGROUND_SERVICE_ALARM` permission — contradicting the approved design (`app-quality-and-native-alarms` design.md Decision 1.1, spec S1-R1 CRITICAL, which mandates `mediaPlayback|alarm` + permission to prevent `ForegroundServiceTypeException` on Android 14+ when starting from a broadcast receiver). `apply-progress.md` marks T-S1-03/04 done with "DEVIATION (see below)" but no Batch-1 deviation section exists. On Android 14+ this plausibly kills the native service start silently — removing even the brief volume-immune window.
|
||||
|
||||
**C — double fade-in**: native Kotlin ramp and Dart ramp run independently and can interleave at handoff → audible jump/reset (likely the real cause of "feels broken", symptom 1).
|
||||
|
||||
## Recommendation (sequenced, one change)
|
||||
|
||||
1. **Manifest fix**: restore `mediaPlayback|alarm` + `FOREGROUND_SERVICE_ALARM` per the already-approved design. Near-zero risk.
|
||||
2. **Ring-scoped volume immunity (option c)**: at ring start, capture current media-stream volume; force it to an audible reference; ramp PLAYER volume 5% → `alarma.volumen`; restore original stream volume on stop/snooze/dismiss. Scoped strictly to the ring — must not touch normal radio listening.
|
||||
3. **Fade-in dedup**: gate so only one ramp drives audible volume across the handoff.
|
||||
|
||||
Deferred (flagged as follow-up, not bundled): option (b) — native-only audio for the whole ring (Flutter screen as pure UI). Architecturally purest, but a large rewrite across `pantalla_alarma_sonando.dart`, `PluriWaveAlarmService.kt`, and the MethodChannel surface.
|
||||
|
||||
## Affected Areas
|
||||
- `AndroidManifest.xml:56-58` — FGS type + missing permission (CRITICAL on API 34+)
|
||||
- `PluriWaveAlarmService.kt` — native fade-in OK for its scope; handoff teardown
|
||||
- `lib/servicios/servicio_audio.dart` — steady-state alarm source, no alarm-stream routing
|
||||
- `lib/pantallas/pantalla_alarma_sonando.dart` — Dart ramp; double-ramp at handoff
|
||||
- `lib/app.dart` — `_prearrancarAudioAlarma()` shrinks the immune window
|
||||
- `app-quality-and-native-alarms/apply-progress.md` — missing Batch-1 deviation note (doc gap)
|
||||
|
||||
## Risks
|
||||
- Kotlin/manifest edits cannot be compiled by agents (`flutter build` forbidden) — mandatory on-device verification by the user (this exact gap is how the manifest regression slipped through)
|
||||
- Any audio-session/volume change must be provably scoped to the ring and reverted — must not regress phone-call ducking (`ServicioAudioSession`, S3-R1) or normal listening
|
||||
- Native and Dart fade-ins duplicate the same algorithm/constants — flag for single-sourcing to prevent drift
|
||||
@@ -0,0 +1,66 @@
|
||||
# Proposal: Alarm Volume Ramp & Device-Volume Immunity
|
||||
|
||||
## Intent
|
||||
|
||||
An alarm must ring regardless of device media volume. Today the alarm hands off within 1-3s from the volume-immune native `USAGE_ALARM` player to the Flutter media-stream player, which is fully governed by media volume — so **media volume 0 = silent alarm** for nearly the whole ring. A manifest bug (`systemExempted` instead of `alarm`, no `FOREGROUND_SERVICE_ALARM`) can also silently kill the native start on Android 14+. Device-volume immunity for the full ring was never actually built; it must be built now.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- **Manifest fix**: `PluriWaveAlarmService` → `foregroundServiceType="mediaPlayback|alarm"` + declare `FOREGROUND_SERVICE_ALARM`. Restores already-approved design (`app-quality-and-native-alarms` D1.1 / S1-R1).
|
||||
- **Ring-scoped media-volume override (Kotlin-owned)**: on ring start capture `STREAM_MUSIC` volume, force an audible reference level; player ramps 5% → `alarma.volumen` (existing Dart ramp); restore original volume on EVERY exit (dismiss, snooze, dispose, service teardown, best-effort on kill).
|
||||
- **Fade-in dedup**: gate native + Dart ramps so only one drives audible volume across handoff.
|
||||
|
||||
### Out of Scope
|
||||
- Full native-only audio for the whole ring (exploration option b) — **future follow-up**.
|
||||
- Single-sourcing the fade-in constant/curve shared by Kotlin+Dart — noted as debt.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- None.
|
||||
|
||||
### Modified Capabilities
|
||||
- `native-alarms`: alarm ring MUST be immune to device media volume for its full duration (not just the pre-handoff window); FGS type/permission corrected; fade-in single-driver across handoff.
|
||||
|
||||
## Approach
|
||||
|
||||
Override lives in **Kotlin** (`MainActivity`/`PluriWaveAlarmService` via a new method on the existing `pluriwave/alarm_scheduler` MethodChannel) because `AudioManager.setStreamVolume` has no Flutter plugin here and MainActivity already owns audio channels. **Dart drives the ring lifecycle**; `pantalla_alarma_sonando.dart` calls `capture+override` at ring start and `restore` from the already-centralized exit points (`_silenciarAudio` → dismiss/snooze; `dispose`). Restore must be **idempotent** and provably scoped: normal radio listening and phone-call ducking (`ServicioAudioSession`, S3-R1) stay untouched — the override only fires while a ring is active. Existing 5%→target Dart ramp is kept; the native ramp is gated to avoid a second audible driver at handoff.
|
||||
|
||||
## Affected Areas
|
||||
|
||||
| Area | Impact | Description |
|
||||
|------|--------|-------------|
|
||||
| `android/.../AndroidManifest.xml:57` | Modified | FGS type `mediaPlayback\|alarm` + `FOREGROUND_SERVICE_ALARM` permission |
|
||||
| `android/.../MainActivity.kt` | Modified | New `alarm_scheduler` methods: capture/override/restore `STREAM_MUSIC` |
|
||||
| `android/.../PluriWaveAlarmService.kt` | Modified | Restore-on-teardown safety net; gate native fade-in vs Dart |
|
||||
| `lib/pantallas/pantalla_alarma_sonando.dart` | Modified | Invoke override at start; restore in `_silenciarAudio`/`dispose` |
|
||||
| `lib/servicios/servicio_alarmas_android.dart` | Modified | Dart wrapper for the new channel methods |
|
||||
| `lib/app.dart` | Modified | Coordinate override with early `_prearrancarAudioAlarma` |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|------|------------|------------|
|
||||
| Kotlin/manifest not compilable by agent | High | On-device verify on Android 14+ is a MANDATORY human gate (CC-R1/R2) |
|
||||
| App killed mid-ring leaves volume overridden | Med | Best-effort restore in service teardown + `onDestroy`; document residual gap |
|
||||
| Override leaks into normal radio playback | Med | Scope strictly to ring; idempotent restore; assert ducking path untouched |
|
||||
| Double fade-in still audible at handoff | Low | Single-driver gate; test the handoff window |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Per-slice and independent. Revert the manifest line to restore prior FGS type. Behind a guard so `restore` always runs even if `override` failed (no worse than today). If regressions appear in normal playback, disable the override call in `pantalla_alarma_sonando.dart` — manifest fix stands alone.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Android 14+ device for the mandatory on-device verification of the manifest fix.
|
||||
- Confirm from user whether `app-quality-and-native-alarms` Slice 1 (manifest) was ever verified on a real device (apply-progress deviation note is missing).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Manifest declares `mediaPlayback|alarm` + `FOREGROUND_SERVICE_ALARM`; native service starts on Android 14+.
|
||||
- [ ] With device media volume at 0, the alarm still rings and ramps 5% → `alarma.volumen`.
|
||||
- [ ] Original media volume is restored after dismiss, snooze, and dispose.
|
||||
- [ ] Normal radio listening and phone-call ducking are unaffected.
|
||||
- [ ] Only one fade-in driver is audible across the native→Flutter handoff.
|
||||
- [ ] No new user-visible strings (no l10n across 13 locales required).
|
||||
@@ -0,0 +1,91 @@
|
||||
# Delta for native-alarms
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Manifest declares alarm-eligible foreground service
|
||||
|
||||
`PluriWaveAlarmService` in `AndroidManifest.xml` MUST use `foregroundServiceType="mediaPlayback|alarm"` and the app MUST declare `FOREGROUND_SERVICE_ALARM`, so the service is eligible to start on Android 14+ (API 34+) from a broadcast receiver.
|
||||
|
||||
#### Scenario: Manifest declares required FGS type and permission
|
||||
**Testability**: static/grep-verifiable
|
||||
|
||||
- GIVEN the built `AndroidManifest.xml`
|
||||
- WHEN the `PluriWaveAlarmService` `<service>` element is inspected
|
||||
- THEN `android:foregroundServiceType` MUST contain `alarm`
|
||||
- AND a `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>` MUST exist
|
||||
|
||||
#### Scenario: Native service starts from broadcast context on Android 14+
|
||||
**Testability**: manual on-device QA (Android 14+; agent cannot install/verify)
|
||||
|
||||
- GIVEN an alarm is scheduled and the app is not foregrounded
|
||||
- WHEN `PluriWaveAlarmReceiver.onReceive(ACTION_FIRE)` starts `PluriWaveAlarmService`
|
||||
- THEN it MUST start without `ForegroundServiceTypeException`
|
||||
- AND the alarm MUST be audible via the native `USAGE_ALARM` player
|
||||
|
||||
### Requirement: Ring-scoped device-volume override
|
||||
|
||||
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.
|
||||
|
||||
#### Scenario: Alarm is audible when device media volume is 0
|
||||
**Testability**: manual on-device QA (native volume APIs)
|
||||
|
||||
- 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
|
||||
|
||||
#### Scenario: Player fade-in ramps within the overridden level
|
||||
**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
|
||||
|
||||
#### Scenario: Dismiss 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 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
|
||||
|
||||
## Non-Functional Notes
|
||||
|
||||
- No new user-visible strings; no l10n work required across the 13 supported locales.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Tasks: Alarm Volume Ramp & Device-Volume Immunity
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | 260-360 (7 files: manifest, 2 Kotlin, 3 Dart, 1 new Dart interface method pair + 3 test files) |
|
||||
| 400-line budget risk | Medium |
|
||||
| Chained PRs recommended | Yes |
|
||||
| Suggested split | PR 1 (Slice 1 manifest+FGS) -> PR 2 (Slice 2 volume override/restore) -> PR 3 (Slice 3 fade-in dedup) |
|
||||
| Delivery strategy | ask-on-risk |
|
||||
| Chain strategy | pending |
|
||||
|
||||
Decision needed before apply: Yes
|
||||
Chained PRs recommended: Yes
|
||||
Chain strategy: pending
|
||||
400-line budget risk: Medium
|
||||
|
||||
### Suggested Work Units
|
||||
|
||||
| Unit | Goal | Likely PR | Notes |
|
||||
|------|------|-----------|-------|
|
||||
| 1 | Fix FGS manifest+runtime type mismatch (Slice 1) | PR 1 | Independent, near-zero risk, own rollback; mandatory Android 14+ device gate |
|
||||
| 2 | Ring-scoped STREAM_MUSIC override + idempotent restore (Slice 2) | PR 2 | Depends on PR 1 landing (FGS must start before override matters); own rollback via guard-disable |
|
||||
| 3 | Dedup fade-in driver at handoff (Slice 3) | PR 3 | Independent of PR 2; can reorder before PR 2 if preferred; own rollback (revert gate) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: FGS Manifest + Runtime Type Fix (Slice 1 — code-inspection + manual QA)
|
||||
|
||||
- [ ] 1.1 Edit `android/app/src/main/AndroidManifest.xml:57` — change `PluriWaveAlarmService` `android:foregroundServiceType` from `"mediaPlayback|systemExempted"` to `"mediaPlayback|alarm"`.
|
||||
- [ ] 1.2 Edit `android/app/src/main/AndroidManifest.xml:6` — replace `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>` with `<uses-permission android:name="android.permission.FOREGROUND_SERVICE_ALARM"/>`.
|
||||
- [ ] 1.3 Edit `PluriWaveAlarmService.kt:118-119` — change `startForeground` type constants from `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` to `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_ALARM`. 1.1-1.3 are ONE atomic unit — a manifest-only or runtime-only edit leaves the API 34+ exception alive; do not split across commits.
|
||||
- [ ] 1.4 Static check: `rg 'foregroundServiceType' android/app/src/main/AndroidManifest.xml` shows `alarm`, not `systemExempted`, on the `PluriWaveAlarmService` line.
|
||||
- [ ] 1.5 Static check: `rg 'FOREGROUND_SERVICE_ALARM|FOREGROUND_SERVICE_SYSTEM_EXEMPTED' android/app/src/main/AndroidManifest.xml` shows `FOREGROUND_SERVICE_ALARM` present and `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` absent.
|
||||
- [ ] 1.6 Static check: `rg 'FOREGROUND_SERVICE_TYPE_ALARM|FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED' android/.../PluriWaveAlarmService.kt` shows `TYPE_ALARM` present and `TYPE_SYSTEM_EXEMPTED` absent — confirms manifest/runtime match (Requirement: Manifest declares alarm-eligible FGS, Scenario "Manifest declares required FGS type and permission").
|
||||
- [ ] 1.7 `flutter analyze` (no `flutter build`) — confirm no lint regressions from these edits (none expected; XML/Kotlin only).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Phase 5: Manual/On-Device QA (mandatory human gate — Android 14+ physical or emulator device)
|
||||
|
||||
- [ ] 5.1 Set device media (`STREAM_MUSIC`) volume to 0. Trigger an alarm. Confirm the alarm rings audibly for its full duration, not just the pre-handoff native window (Scenario "Alarm is audible when device media volume is 0").
|
||||
- [ ] 5.2 Confirm `PluriWaveAlarmService` starts without `ForegroundServiceTypeException` on an Android 14+ (API 34+) device when the alarm fires from a background/killed-app broadcast context (Scenario "Native service starts from broadcast context on Android 14+").
|
||||
- [ ] 5.3 Note the original device media volume before triggering the alarm; dismiss the alarm; confirm media volume is restored to exactly the pre-alarm value (Scenario "Dismiss restores the original captured volume").
|
||||
- [ ] 5.4 Repeat 5.3 for snooze instead of dismiss (Scenario "Snooze restores the original captured volume").
|
||||
- [ ] 5.5 Listen across the native-to-Flutter handoff window; confirm there is no audible double-ramp/volume jump at the moment `confirmarAudioFlutter` fires (Scenario "No double-ramp interleaving at handoff").
|
||||
- [ ] 5.6 With no alarm ringing, play/pause normal radio repeatedly and confirm device volume controls behave exactly as before this change (no override side effects) — Scenario "Normal radio playback never triggers the override".
|
||||
- [ ] 5.7 Optional/best-effort: force-kill the app mid-ring and confirm the residual-volume-override gap is no worse than documented (Scenario "App killed mid-ring — best-effort restore only"; known accepted gap, not a blocking QA failure).
|
||||
- [ ] 5.8 Record QA sign-off (device model, Android version, pass/fail per scenario) before `sdd-verify`/merge.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user