docs(openspec): archive alarm-volume-ramp-restore and promote native-alarms spec
Close the SDD cycle: verified pass with warnings (0 critical), slice 1 cancelled with SDK evidence, slices 2-3 shipped, post-verify dispose fix landed. The native-alarms capability spec is promoted to openspec/specs/ with the corrected FGS requirement. Phase 5 on-device QA remains the pending human gate.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
# Archive Report: Alarm Volume Ramp & Device-Volume Immunity
|
||||
|
||||
**Change**: alarm-volume-ramp-restore
|
||||
**Archived**: 2026-07-11
|
||||
**Status**: ARCHIVED AND CLOSED (Phase 5 human QA gate pending — see below)
|
||||
**Archive Path**: `openspec/changes/archive/2026-07-11-alarm-volume-ramp-restore/`
|
||||
**Spec Merged To**: `openspec/specs/native-alarms/spec.md` (new capability domain — no prior main spec existed)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The **alarm-volume-ramp-restore** change is archived with a **PASS WITH WARNINGS** verification verdict. Slices 2 (ring-scoped `STREAM_MUSIC` override + idempotent restore) and 3 (single fade-in driver across the native→Flutter handoff) are code-complete, fully test-covered where automatable, and static-analysis-clean (29/29 automatable tasks done, `flutter analyze` 0 issues). Slice 1 (FGS manifest/runtime-type fix) was **cancelled by design** after `sdd-apply` proved via `javap -constants` against the local Android SDK that the target constants (`FOREGROUND_SERVICE_TYPE_ALARM`, `android.permission.FOREGROUND_SERVICE_ALARM`) do not exist — the existing `mediaPlayback|systemExempted` declaration was confirmed correct, and Root Cause B from the original exploration was withdrawn. Post-verify, the orchestrator closed WARNING-1 (a genuine, evidence-backed defect in the `dispose()` restore safety net) via commit `79f6f8e`. Phase 5 (8 mandatory human on-device QA tasks) remains the only outstanding work — it cannot be completed by any agent and is required before this change can be considered fully verified in production.
|
||||
|
||||
---
|
||||
|
||||
## Artifact Traceability
|
||||
|
||||
| Artifact | Engram Observation ID | Location | Notes |
|
||||
|----------|----------------------|----------|-------|
|
||||
| Explore | (embedded, no separate obs) | `openspec/changes/.../explore.md` | Root causes A (architectural steady-state), B (manifest bug — later withdrawn), C (double fade-in) |
|
||||
| Proposal | #2302 | sdd/alarm-volume-ramp-restore/proposal | Scope (3 slices), approach, risks, rollback plan |
|
||||
| Spec | #2308 | sdd/alarm-volume-ramp-restore/spec; on-disk delta merged to `openspec/specs/native-alarms/spec.md` | 3 requirements, 10 scenarios; delta carries a 2026-07-11 correction banner re-scoping the FGS requirement after Slice 1 cancellation |
|
||||
| Design | #2310 | sdd/alarm-volume-ramp-restore/design | 9 architecture decisions, data flow, file changes, testing strategy; carries the Slice 1 cancellation correction banner |
|
||||
| Tasks | #2316 | sdd/alarm-volume-ramp-restore/tasks | 44 checkboxes: 29 complete (Phases 2/3/4/6), 7 N/A (Phase 1, cancelled by design), 8 pending (Phase 5, human QA) |
|
||||
| Apply-Progress | #2332 | sdd/alarm-volume-ramp-restore/apply-progress | Batches 1 (blocked/reverted), 2 (Slice 2), 2b (flutterOwnsRing risk fix), 3 (Slice 3 + final sweep) — all merged evidence |
|
||||
| Discovery | #2335 | (RESOLVED) PluriWaveAlarmService.stopAlarm() handoff-vs-exit ambiguity | Risk found in Batch 2, resolved in Batch 2b via the `flutterOwnsRing` flag |
|
||||
| Verify-Report | #2342 | sdd/alarm-volume-ramp-restore/verify-report | PASS WITH WARNINGS; 0 CRITICAL, 2 WARNING, 4 SUGGESTION; 6/10 scenarios COMPLIANT, 1 PARTIAL, 2 DEFERRED (manual QA), 1 ACCEPTED GAP |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Completion
|
||||
|
||||
### Phase 1: FGS Manifest + Runtime Type Fix — CANCELLED (By Design, Not a Defect)
|
||||
|
||||
| Phase | Description | Tasks | Status | Rationale |
|
||||
|-------|-------------|-------|--------|-----------|
|
||||
| 1 | Manifest `foregroundServiceType` + Kotlin runtime type fix (`alarm` variant) | 1.1–1.7 (7) | 0/7, CANCELLED | `sdd-apply` verified via `javap -constants` against local android-34/35/36 platform `android.jar`s, plus a full `FOREGROUND_SERVICE_TYPE_*` sweep of `api-versions.xml`, that `FOREGROUND_SERVICE_TYPE_ALARM` / `android.permission.FOREGROUND_SERVICE_ALARM` do not exist anywhere in the Android SDK (13 real FGS types exist through API 36; no `alarm` variant). The existing `mediaPlayback|systemExempted` declaration is the correct, intentional pattern — matches the prior `app-quality-and-native-alarms` T-S1-03/04 deviation. Edits were applied then reverted (`git checkout --`) before landing. Root Cause B from exploration is withdrawn; the user-visible bug is fully attributed to Root Cause A (media-stream steady state), addressed by Slices 2 and 3. |
|
||||
|
||||
### Phases 2–4, 6: Code-Gated Work — COMPLETE
|
||||
|
||||
| Phase | Description | Tasks | Status | Evidence |
|
||||
|-------|-------------|-------|--------|----------|
|
||||
| 2 | Kotlin ring-scoped `STREAM_MUSIC` override/restore channel methods + `flutterOwnsRing` handoff-aware backstop (Batch 2b risk fix) | 2.1–2.9 (9) | 9/9 [x] | Code-inspection verified (no Android test harness in this repo); `flutter analyze` 0 issues |
|
||||
| 3 | Dart port + wiring: `forzarVolumenMediaParaAlarma`/`restaurarVolumenMedia`, restore call sites, override trigger point in `app.dart` | 3.1–3.12 (12) | 12/12 [x] | Strict TDD; 3.9 has one documented, justified deviation (not testable in isolation — verified via source inspection) |
|
||||
| 4 | Fade-in dedup: single `_iniciarFadeIn()` call site gated on handoff confirmation (success or failure, via `finally`) | 4.1–4.5 (5) | 5/5 [x] | Strict TDD; 22/22 tests green in the touched-suite re-run |
|
||||
| 6 | Final static sweep (`systemExempted` consistency check, full-directory test run, `dart format .`) | 6.1–6.3 (3) | 3/3 [x] | 279/279 tests green (hang-fallback split: 272 batched + 7 standalone); `flutter analyze` 0 issues |
|
||||
|
||||
### Phase 5: Manual/On-Device QA — PENDING (Human Gate)
|
||||
|
||||
| Phase | Description | Tasks | Status | Notes |
|
||||
|-------|-------------|-------|--------|-------|
|
||||
| 5 | Manual/on-device QA (volume-0 audibility, Android 14+ service start, dismiss/snooze exact-value restore, no double-ramp at handoff, normal-playback regression check, kill-mid-ring residual gap, sign-off) | 5.1–5.8 (8) | 0/8 [ ] | Mandatory human sign-off gate; requires real/emulated Android 14+ device; Kotlin volume-override and handoff-timing paths are not unit-testable (code-inspected only); these 8 items are the ONLY remaining work for this change |
|
||||
|
||||
**Task Summary**:
|
||||
- 29 tasks complete (`[x]`) across Phases 2, 3, 4, 6
|
||||
- 7 tasks N/A (`[ ]` with justification) in Phase 1 — cancelled by design, ships no code
|
||||
- 8 tasks pending (`[ ]`) in Phase 5 — human gate
|
||||
- **Total: 44 checkboxes, 0 unaccounted**
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
### Unit/Widget Tests
|
||||
|
||||
| Suite | Result | Notes |
|
||||
|-------|--------|-------|
|
||||
| `pantalla_alarma_sonando_test.dart` | PASS | Fade-in dedup gate pair + failure-edge test (Slice 3) |
|
||||
| `pantalla_alarma_sonando_dismiss_guard_test.dart` | PASS | Dismiss/snooze restore-once, dual-call-site no-double-restore, normal-playback regression guard (Slice 2); +1 post-verify dispose-as-sole-caller regression test |
|
||||
| `pantalla_alarma_sonando_scaffold_test.dart` | PASS | Re-run for regression confidence (shared fake extended) |
|
||||
| `servicio_alarmas_android_test.dart` | PASS | Channel-call contract tests for the two new wrapper methods |
|
||||
| `estado_alarmas_snooze_test.dart` | PASS | Included in verify session's targeted run |
|
||||
| **Targeted total (verify session, 2026-07-11)** | **34/34** | Independently re-run by `sdd-verify` |
|
||||
| **Post-verify fix total** | **35/35** | +1 test from commit `79f6f8e`'s dispose-as-sole-caller regression test |
|
||||
| **Full-directory batched run (apply Batch 3, task 6.2)** | **279/279** | 272 files batched + 2 known-flaky files (`estado_alarmas_ejecuciones_test.dart`, `servicio_grabacion_radio_test.dart`) run standalone due to a documented, pre-existing, unrelated full-suite hang |
|
||||
|
||||
### Static Analysis
|
||||
|
||||
| Tool | Result | Coverage |
|
||||
|------|--------|----------|
|
||||
| `flutter analyze` | 0 issues | Whole project (verified independently by `sdd-verify` and again post-fix) |
|
||||
| `dart format .` | Clean for all touched files | 8 unrelated files show pre-existing formatter-version drift on `main`; reverted via `git checkout --` each time to stay in scope |
|
||||
| `rg 'systemExempted\|SYSTEM_EXEMPTED'` (Phase 6.1) | 3 references, all `systemExempted` (manifest x2, Kotlin x1), none `alarm` | Confirms manifest/runtime consistency post-cancellation, matching the pre-change state exactly |
|
||||
|
||||
### Code Coverage
|
||||
|
||||
- Exact-line coverage metrics not collected (informational/non-blocking per verification scope).
|
||||
- Scenario-level mapping: 6/10 spec scenarios fully COMPLIANT with automated coverage; 1/10 PARTIAL (Dart-side gating green, native cross-process timing untestable by any agent); 2/10 DEFERRED (manual-QA-only by spec's own testability note); 1/10 ACCEPTED GAP (process-death residual, explicitly not required in automated coverage per spec text).
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
### Verdict and Post-Verify Fix
|
||||
|
||||
| Pass | Result | Finding |
|
||||
|------|--------|---------|
|
||||
| Verify (2026-07-11) | **PASS WITH WARNINGS** | 0 CRITICAL. WARNING-1: `dispose()`'s restore call site is non-functional as a standalone safety net — verified via actual `flutter test` output, not speculation. Flutter's `Element.unmount()` flips the widget to `defunct` before `State.dispose()` runs, so a fresh `context.read<EstadoAlarmas>()` inside `dispose()` throws `FlutterError` ("Looking up a deactivated widget's ancestor is unsafe") — caught and silently logged by the method's own try/catch, so no crash and no NAMED spec scenario fails (because `_silenciarAudio()` always runs first, while mounted, on every code path that reaches it), but `dispose()` provides zero real coverage for any abrupt teardown that bypasses `_detener()`/`_posponer()`. WARNING-2: task 3.9 (override-ordering test in `app.dart`) has no automated RED/GREEN pair — `PluriWaveApp` hardcodes non-injectable `EstadoRadio`/`EstadoAlarmas`, so the seam is unreachable via `flutter test`; verified via source inspection instead (accurate on re-check). |
|
||||
| Post-verify fix (orchestrator, commit `79f6f8e`) | **WARNING-1 CLOSED** | `EstadoAlarmas` is now captured in `initState()` instead of being freshly read via `context.read()` inside `dispose()`, and a new dispose-as-sole-caller regression test was added. 35/35 alarm suites green (was 34/34), `flutter analyze` 0 issues. This fix is not reflected as a numbered checkbox in `tasks.md` (it is an orchestrator-level post-verify fix); documented here and in `state.yaml` for the audit trail instead. WARNING-2 remains OPEN/ACCEPTED — a documented, justified deviation, not fixed. |
|
||||
|
||||
### Finding Breakdown
|
||||
|
||||
| Severity | Count | Status |
|
||||
|----------|-------|--------|
|
||||
| CRITICAL | 0 | — |
|
||||
| WARNING | 2 | 1 CLOSED (WARNING-1, commit `79f6f8e`); 1 OPEN/ACCEPTED (WARNING-2, documented DI-seam gap) |
|
||||
| SUGGESTION | 4 | OPEN (non-blocking: apply-progress arithmetic drift, dispatch-note scenario miscount, pre-existing `dart format .` drift on unrelated files, pre-existing full-suite test hang) |
|
||||
|
||||
### Compliance Matrix Snapshot
|
||||
|
||||
| Requirement | Scenarios | Compliant | Partial | Deferred (manual QA) | Accepted Gap |
|
||||
|-------------|-----------|-----------|---------|----------------------|--------------|
|
||||
| Manifest declares alarm-eligible FGS | 2 | 1 | 0 | 1 | 0 |
|
||||
| Ring-scoped device-volume override | 7 | 4 | 0 | 2 | 1 |
|
||||
| Single fade-in driver across handoff | 1 | 0 | 1 | 0 | 0 |
|
||||
| **Total** | **10** | **6** | **1** | **2** | **1** |
|
||||
|
||||
Zero FAILING, zero UNTESTED-when-testable.
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
**Estimated Changed Lines**: 260–360 (per tasks.md Review Workload Forecast: manifest 0 lines shipped — Slice 1 cancelled — Kotlin 2 files, Dart 3 prod files, 3 Dart test files)
|
||||
**400-Line Budget Risk**: Medium (mitigated by the chained-slice work-unit structure: Slice 1 / Slice 2 / Slice 3, each independently rollback-isolated)
|
||||
**Build Execution**: Not run — `flutter build`/gradle explicitly forbidden throughout (Kotlin is code-inspection only per design)
|
||||
|
||||
### Kotlin (Code-Inspection Verified)
|
||||
- `android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt`: `@Volatile` companion state (`mediaVolumeOverridden`, `capturedMediaVolume`); `overrideMediaVolumeForRing`/`restoreMediaVolume` channel cases; `restoreMediaVolumeBestEffort()`; `flutterOwnsRing` set point in `confirmFlutterAudio` (Batch 2b)
|
||||
- `android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt`: `stopAlarm()`/`onDestroy()` backstop calls gated by `flutterOwnsRing`; flag declaration + reset in `startAlarm()` (Batch 2b); FGS type constants at L123-124 confirmed UNCHANGED (Slice 1 cancelled)
|
||||
|
||||
### Dart Production
|
||||
- `lib/servicios/servicio_alarmas_android.dart`: `forzarVolumenMediaParaAlarma(double)` / `restaurarVolumenMedia()` added to `PuertoAlarmasAndroid` + `ServicioAlarmasAndroid` impl
|
||||
- `lib/app.dart`: override call as the FIRST statement in `_prearrancarAudioAlarma`, before the `emisora == null` early return; `if (!mounted) return;` guard added (fixed a real `use_build_context_synchronously` finding)
|
||||
- `lib/pantallas/pantalla_alarma_sonando.dart`: `_volumenMediaRestaurado` guard + `_restaurarVolumenMediaUnaVez()`, wired into `_silenciarAudio()` and `dispose()`; fade-in dedup (removed 2 ungated call sites, moved the single call into `_confirmarAudioFlutterListo()`'s `finally` block); **post-verify (commit `79f6f8e`)**: `EstadoAlarmas` now captured in `initState()` instead of `dispose()`'s fresh `context.read()`
|
||||
|
||||
### Dart Tests
|
||||
- `test/servicios/servicio_alarmas_android_test.dart`: 2 new channel-call contract tests
|
||||
- `test/helpers/fakes_alarmas.dart`: `FakePuertoAlarmasAndroid` extended (`volumenForzado`, `volumenRestaurado`, `puertaConfirmarAudioFlutter` gate, `fallaConfirmarAudioFlutter`)
|
||||
- `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart`: dismiss-restores-once, snooze-restores-once, dual-call-site-no-double-restore, normal-playback regression guard; **post-verify**: +1 dispose-as-sole-caller regression test
|
||||
- `test/pantallas/pantalla_alarma_sonando_test.dart`: fade-in gate pair (pre-confirm/post-confirm) + failure-edge test (native channel dead/never-there)
|
||||
|
||||
### Localization
|
||||
- `lib/l10n/*.arb` (13 files): **0 changes** — no new user-visible strings shipped by this change
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Design
|
||||
|
||||
**Documented and Signed Off**:
|
||||
|
||||
1. **Slice 1 cancellation (design correction, not an apply-time deviation)**: design.md and the delta spec both carry a 2026-07-11 correction banner. The two Architecture Decisions describing the FGS manifest/runtime-type change were built on SDK constants that do not exist (`FOREGROUND_SERVICE_TYPE_ALARM`, `android.permission.FOREGROUND_SERVICE_ALARM`), verified via `javap -constants`. Root Cause B is withdrawn; the existing `mediaPlayback|systemExempted` declaration is confirmed correct.
|
||||
2. **Task 3.9 (Phase 3)**: no automated `flutter test` RED/GREEN pair exists — `PluriWaveApp` (the only public entry point) hardcodes non-injectable `EstadoRadio`/`EstadoAlarmas`, so `_prearrancarAudioAlarma`'s ring-start seam is unreachable via widget test without an unscoped DI refactor (out of scope for this design). Verified via source inspection instead; independently re-verified accurate during `sdd-verify`.
|
||||
3. **`flutterOwnsRing` handoff-aware backstop (Batch 2b)**: not in the original design — added mid-apply after Batch 2 discovered that `stopAlarm()` fires identically at both a real ring exit AND the native-to-Flutter handoff moment, meaning the best-effort restore backstop specified by the original design could silence the Flutter-driven remainder of the ring right at handoff. This is a coherent evolution of the design (resolves a real risk found during implementation), not a harmful deviation — see discovery #2335 (RESOLVED).
|
||||
4. **Post-verify fix (commit `79f6f8e`)**: not part of the original design or task list — added by the orchestrator after `sdd-verify` found WARNING-1 (dispose()'s restore call site non-functional as a standalone safety net). `EstadoAlarmas` is now captured in `initState()` per the verify report's own suggested fix direction, rather than left as a fresh `context.read()` inside `dispose()`.
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Likelihood | Status |
|
||||
|------|------------|--------|
|
||||
| Native handoff-timing premature restore (Batch 2 discovery) | Was Med | RESOLVED in Batch 2b via `flutterOwnsRing` flag; Phase 5 QA 5.1/5.5 remain the recommended on-device confirmation since code inspection cannot fully substitute for a real handoff-timing test |
|
||||
| App killed mid-ring leaves volume overridden | Low-Med | Documented, accepted residual gap (best-effort restore only); Phase 5 QA 5.7 is optional/best-effort verification |
|
||||
| Override leaks into normal radio playback | Low | Regression-guard test confirms the override channel is never invoked outside a ring; Phase 5 QA 5.6 is the on-device confirmation |
|
||||
| `dispose()` teardown paths that bypass `_detener()`/`_posponer()` (e.g. raw back-button pop) get no functional restore safety net | Was Med, now Low | CLOSED via commit `79f6f8e` (initState capture); no test currently exercises a raw back-button pop specifically, but the underlying defunct-context failure mode is eliminated |
|
||||
| Kotlin permission/volume flow has no automated test harness | Med | Code-inspection verified against the existing channel/idempotence pattern; Phase 5 manual QA is the runtime gate |
|
||||
| Task 3.9 override-ordering seam untestable in isolation | Low | Accepted, documented deviation; recommended follow-up is a testable DI seam on `PluriWaveApp` if genuine automated coverage is wanted later |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Mandatory Before Shipping
|
||||
|
||||
1. **Phase 5 Manual/On-Device QA** (pending, 8 tasks): Requires a real or emulated Android 14+ device.
|
||||
- Media volume 0 → alarm still audible for the full ring (5.1), specifically re-confirming the `flutterOwnsRing` handoff fix holds on-device
|
||||
- `PluriWaveAlarmService` starts without `ForegroundServiceTypeException` on Android 14+ from a background/killed-app broadcast context (5.2)
|
||||
- Dismiss/snooze restore media volume to the exact pre-alarm value (5.3, 5.4)
|
||||
- No audible double-ramp/volume jump at the native→Flutter handoff moment (5.5)
|
||||
- Normal radio playback unaffected by the override (5.6)
|
||||
- Optional/best-effort: force-kill mid-ring residual-gap check (5.7)
|
||||
- QA sign-off recorded (device model, Android version, pass/fail per scenario) (5.8)
|
||||
|
||||
### Follow-Up Opportunities (Out of Scope)
|
||||
|
||||
- **WARNING-2 (open, accepted)**: task 3.9's override-ordering seam has no automated coverage. Add a testable DI seam to `PluriWaveApp` if genuine automated coverage is required.
|
||||
- **Fade-in constant/curve single-sourcing** (proposal's own noted debt): Kotlin and Dart fade-in implementations still duplicate the same algorithm/constants independently — flagged for future single-sourcing to prevent drift.
|
||||
- **Pre-existing `dart format .` drift** on 8 unrelated files (main branch) — already flagged by apply-progress as out-of-scope follow-up cleanup, still pending.
|
||||
- **Pre-existing full-suite `flutter test` hang** (environment/tooling issue, unrelated to this change, reproduced across 3 apply batches) — recommend separate investigation.
|
||||
- **Full native-only audio for the whole ring** (exploration option b, explicitly out of scope for this change) — remains a future architectural follow-up if the current dual-track handoff proves insufficient after Phase 5 QA.
|
||||
|
||||
---
|
||||
|
||||
## Archive Integrity
|
||||
|
||||
**Attempted Move**: `openspec/changes/alarm-volume-ramp-restore/` → `openspec/changes/archive/2026-07-11-alarm-volume-ramp-restore/`
|
||||
|
||||
**IMPORTANT — INCOMPLETE MOVE, TOOLING LIMITATION**: The `sdd-archive` executor for this change was launched with only `Read`, `Edit`, `Write`, `Glob`, and `mem_*` tools available — **no Bash/shell tool was provided**, so `cp` could not be run and the source folder could not be deleted. All six on-disk artifacts (`explore.md`, `proposal.md`, `design.md`, `tasks.md`, `verify-report.md`, `specs/native-alarms/spec.md`) were reconstructed into this archive folder via `Read`-verified `Write` calls — full content preserved, not summarized or paraphrased — but this is NOT a verified byte-identical filesystem copy in the way `cp` would guarantee, and the original source folder **still exists** at `openspec/changes/alarm-volume-ramp-restore/` alongside this archive copy.
|
||||
|
||||
**Action required**: a human or an agent with shell access must run the equivalent of `Remove-Item -Recurse -Force openspec/changes/alarm-volume-ramp-restore` (PowerShell) or `rm -rf openspec/changes/alarm-volume-ramp-restore` (POSIX) to complete the move, then confirm the folder is gone.
|
||||
|
||||
**Spec Merged**: `openspec/specs/native-alarms/spec.md` created as a new domain (no prior main spec existed for `native-alarms`). The delta's content was preserved verbatim for every requirement/scenario; only the framing was adapted from delta language ("ADDED Requirements", a "CORRECTED (2026-07-11...)" narrative note) into normative present-tense baseline-spec language, following this project's existing convention for freshly-created domain specs (see `openspec/specs/alarm-pre-notice-countdown/spec.md`). The archived copy of the delta (`specs/native-alarms/spec.md` under this archive folder) keeps the original delta framing unmodified, as the historical record — matching the `multi-device-eq` archive precedent.
|
||||
|
||||
**Artifacts Preserved**: All 6 openspec files (explore, proposal, spec, design, tasks, verify-report) reconstructed in full, plus this `archive-report.md` and `state.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
**Archive Ready For**: Documentation/traceability closure now; production shipping is PENDING Phase 5 human QA sign-off
|
||||
**Blocking Issues**: (1) Phase 5 is a known human gate, not a blocker to archiving. (2) The source folder deletion could not be performed by this executor — see "Archive Integrity" above; this DOES block calling the openspec move fully complete.
|
||||
**Recommendation**: Proceed to Phase 5 manual QA on a real/emulated Android 14+ device; separately, have an agent or human with shell access delete `openspec/changes/alarm-volume-ramp-restore/` to finish the archive move.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Observation IDs for Traceability
|
||||
|
||||
- #2302: sdd/alarm-volume-ramp-restore/proposal (scope, 3 slices, approach, risks, rollback plan)
|
||||
- #2308: sdd/alarm-volume-ramp-restore/spec (3 requirements, 10 scenarios, correction banner)
|
||||
- #2310: sdd/alarm-volume-ramp-restore/design (9 architecture decisions, data flow, Slice 1 cancellation banner)
|
||||
- #2316: sdd/alarm-volume-ramp-restore/tasks (44 checkboxes, 4 batches, phase status)
|
||||
- #2332: sdd/alarm-volume-ramp-restore/apply-progress (Batches 1/2/2b/3 merged evidence, 29 tasks complete)
|
||||
- #2335: discovery — RESOLVED: `PluriWaveAlarmService.stopAlarm()` handoff-vs-exit ambiguity (Batch 2b `flutterOwnsRing` fix)
|
||||
- #2342: sdd/alarm-volume-ramp-restore/verify-report (PASS WITH WARNINGS, 0 CRITICAL, 2 WARNING, 4 SUGGESTION)
|
||||
- (new) sdd/alarm-volume-ramp-restore/archive-report — this document, persisted to Engram project `pluriwave`
|
||||
|
||||
All artifacts archived and accessible at `openspec/changes/archive/2026-07-11-alarm-volume-ramp-restore/` for audit trail, pending the source-folder deletion noted above.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Design: Alarm Volume Ramp & Device-Volume Immunity
|
||||
|
||||
> **SLICE 1 CANCELLED — CORRECTION (2026-07-11, apply-stage SDK verification).**
|
||||
> The first two Architecture Decisions below are built on constants that DO NOT EXIST in the
|
||||
> Android SDK: `FOREGROUND_SERVICE_TYPE_ALARM` and `android.permission.FOREGROUND_SERVICE_ALARM`
|
||||
> are fictional (verified via `javap -constants` on the local android-34/35/36 platform jars and a
|
||||
> full `api-versions.xml` sweep — 13 real FGS types, no alarm variant; constants are additive, so
|
||||
> absence in 36 rules out 34/35). The existing `mediaPlayback|systemExempted` declaration is the
|
||||
> documented correct pattern for an alarm-clock app holding exact-alarm permissions starting an FGS
|
||||
> from an AlarmManager broadcast, and the prior `app-quality-and-native-alarms` deviation
|
||||
> (T-S1-03/04) that kept it was right. Slice 1 ships NO code change. Root Cause B from the
|
||||
> exploration is withdrawn; the user-visible bug is fully attributed to Root Cause A (media-stream
|
||||
> steady state), addressed by Slices 2 and 3, which are independent and unaffected.
|
||||
> Evidence trail: engram `sdd/alarm-volume-ramp-restore/apply-progress` (#2332) and project
|
||||
> discovery `android/fgs-alarm-type-nonexistent` (#2333).
|
||||
|
||||
## 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).
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Delta for native-alarms
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Manifest declares alarm-eligible foreground service
|
||||
|
||||
**CORRECTED (2026-07-11, apply-stage SDK verification)**: the originally specified `foregroundServiceType="mediaPlayback|alarm"` + `FOREGROUND_SERVICE_ALARM` permission are FICTIONAL — neither constant exists anywhere in the Android SDK (verified via `javap -constants` on android-34/35/36 platform jars and `api-versions.xml`; 13 real FGS types, no alarm variant). The existing declaration `foregroundServiceType="mediaPlayback|systemExempted"` is the documented correct pattern for an alarm-clock app holding exact-alarm permissions that starts a foreground service from an AlarmManager broadcast on API 34+. The prior `app-quality-and-native-alarms` deviation (T-S1-03/04) that kept `systemExempted` was right. No manifest or runtime change ships in this change; the requirement is re-scoped to the service-start behavior below.
|
||||
|
||||
#### Scenario: Manifest keeps the verified-correct FGS declaration
|
||||
**Testability**: static/grep-verifiable
|
||||
|
||||
- GIVEN the built `AndroidManifest.xml`
|
||||
- WHEN the `PluriWaveAlarmService` `<service>` element is inspected
|
||||
- THEN `android:foregroundServiceType` MUST be `mediaPlayback|systemExempted`
|
||||
- AND the runtime `startForeground` type constants in `PluriWaveAlarmService.kt` MUST match the manifest declaration
|
||||
|
||||
#### 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,87 @@
|
||||
change: alarm-volume-ramp-restore
|
||||
archived_date: 2026-07-11
|
||||
status: archived
|
||||
archived_from: openspec/changes/alarm-volume-ramp-restore
|
||||
|
||||
verification_verdict: "PASS WITH WARNINGS"
|
||||
verify_report_observations:
|
||||
- id: 2342
|
||||
type: verify-report
|
||||
critical_issues: 0
|
||||
warnings: 2 (WARNING-1 closed post-verify via commit 79f6f8e; WARNING-2 accepted, documented deviation)
|
||||
suggestions: 4 (non-blocking)
|
||||
|
||||
artifacts_migrated:
|
||||
- explore.md (root cause analysis A/B/C; B later withdrawn during apply-stage SDK verification)
|
||||
- proposal.md (obs #2302)
|
||||
- spec.md (obs #2308, on-disk delta merged to openspec/specs/native-alarms/spec.md as new domain)
|
||||
- design.md (obs #2310, carries Slice 1 cancellation correction banner)
|
||||
- tasks.md (obs #2316, 44 checkboxes: 29 done, 7 N/A Phase 1 cancelled by design, 8 pending Phase 5 manual QA)
|
||||
- apply-progress.md (obs #2332, Batches 1/2/2b/3 complete; discovery #2335 RESOLVED)
|
||||
- verify-report.md (obs #2342, PASS WITH WARNINGS)
|
||||
|
||||
phase_status:
|
||||
Phase 1 (FGS Manifest + Runtime Type Fix): CANCELLED - 0/7 tasks, by design (target SDK constants confirmed fictional via javap -constants against android-34/35/36 platform jars + api-versions.xml sweep; existing mediaPlayback|systemExempted confirmed correct; Root Cause B withdrawn)
|
||||
Phase 2 (Kotlin Volume Override Channel Methods): COMPLETE - 9/9 tasks [x]
|
||||
Phase 3 (Dart Volume Override Port + Wiring): COMPLETE - 12/12 tasks [x]
|
||||
Phase 4 (Fade-In Dedup at Handoff): COMPLETE - 5/5 tasks [x]
|
||||
Phase 5 (Manual/On-Device QA): PENDING - 8 tasks [ ] (human gate, not executable by any agent)
|
||||
Phase 6 (Final Static Sweep): COMPLETE - 3/3 tasks [x]
|
||||
|
||||
test_results:
|
||||
total_changed_lines: "~260-360 estimated (Kotlin 2 files, Dart 3 prod files, 3 Dart test files); Slice 1 shipped 0 lines (cancelled by design)"
|
||||
flutter_analyze: "0 issues (verify session, independently re-run)"
|
||||
targeted_suites:
|
||||
- pantalla_alarma_sonando_test.dart
|
||||
- pantalla_alarma_sonando_dismiss_guard_test.dart
|
||||
- pantalla_alarma_sonando_scaffold_test.dart
|
||||
- servicio_alarmas_android_test.dart
|
||||
- estado_alarmas_snooze_test.dart
|
||||
total_tests_passing: "34/34 targeted (verify session, 2026-07-11); 279/279 full-directory batched run (apply Batch 3, task 6.2, hang-fallback split); 35/35 alarm suites after post-verify fix"
|
||||
post_verify_fix:
|
||||
commit: 79f6f8e
|
||||
what: "WARNING-1 closed: EstadoAlarmas captured in initState() instead of a fresh context.read() inside dispose(), which was throwing (caught, silently logged) on a defunct-widget-context ancestor lookup for any teardown that bypasses _detener()/_posponer(); plus a new dispose-as-sole-caller regression test"
|
||||
evidence: "35/35 alarm suites green, flutter analyze 0 issues"
|
||||
reflected_in_tasks_md: false
|
||||
|
||||
notes: |
|
||||
Phase 5 (manual/on-device QA) is a mandatory human gate before shipping, 8 tasks (5.1-5.8),
|
||||
requires real or emulated Android 14+ device access. Cannot be completed by any agent.
|
||||
|
||||
Phase 1 (FGS manifest/runtime type fix) is CANCELLED by design, not a defect: sdd-apply
|
||||
independently verified via `javap -constants` against local android-34/35/36 platform jars
|
||||
plus a full FOREGROUND_SERVICE_TYPE_* sweep of api-versions.xml that FOREGROUND_SERVICE_TYPE_ALARM
|
||||
and android.permission.FOREGROUND_SERVICE_ALARM do not exist anywhere in the Android SDK. The
|
||||
existing mediaPlayback|systemExempted declaration is confirmed the correct, intentional pattern
|
||||
(matches the prior app-quality-and-native-alarms T-S1-03/04 deviation). Root Cause B from
|
||||
exploration is withdrawn; design.md and specs/native-alarms/spec.md both carry correction
|
||||
banners documenting this. Slices 2 and 3 are unaffected and both code-complete.
|
||||
|
||||
WARNING-1 (dispose()'s restore call site non-functional as a standalone safety net, due to a
|
||||
defunct-widget-context ancestor lookup thrown — and silently caught — whenever dispose() is the
|
||||
first/only caller) was found during sdd-verify and CLOSED post-verify by the orchestrator via
|
||||
commit 79f6f8e: EstadoAlarmas is now captured in initState() rather than re-read via
|
||||
context.read() inside dispose(), plus a new dispose-as-sole-caller regression test. 35/35 alarm
|
||||
suites green, flutter analyze 0 issues. This fix is NOT reflected as a new checkbox in tasks.md
|
||||
(it is an orchestrator-level post-verify fix, not a numbered task) — documented here and in
|
||||
archive-report.md instead.
|
||||
|
||||
WARNING-2 (task 3.9 has no automated RED/GREEN pair; app.dart's PluriWaveApp entry point
|
||||
hardcodes non-injectable EstadoRadio/EstadoAlarmas instances, so _prearrancarAudioAlarma cannot
|
||||
be exercised via flutter test in isolation) is accepted-documented, not fixed. Verified via
|
||||
source inspection instead (forzarVolumenMediaParaAlarma(1.0) confirmed the first statement in
|
||||
_prearrancarAudioAlarma). Recommended follow-up: add a testable DI seam to PluriWaveApp if
|
||||
genuine automated coverage of this seam is required later.
|
||||
|
||||
Kotlin work (Phase 2 channel methods, Phase 2b flutterOwnsRing handoff-aware backstop) is
|
||||
code-inspection-verified only (no Android test harness in this repo, flutter build/gradle
|
||||
forbidden throughout). Native handoff-timing behavior will be validated during Phase 5 manual
|
||||
QA, specifically tasks 5.1 and 5.5.
|
||||
|
||||
ARCHIVE TOOLING CAVEAT: the sdd-archive executor for this change had no Bash/shell tool
|
||||
available (Read/Edit/Write/Glob/mem_* only) and could not run `cp` or delete the source folder.
|
||||
All six on-disk artifacts (explore.md, proposal.md, design.md, tasks.md, verify-report.md,
|
||||
specs/native-alarms/spec.md) were reconstructed into this archive folder via Read-verified
|
||||
Write calls, preserving full content. The original `openspec/changes/alarm-volume-ramp-restore/`
|
||||
folder was NOT deleted and still exists alongside this archive copy — a human or an agent with
|
||||
shell access must remove it to complete the move (see archive-report.md "Archive Integrity").
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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)
|
||||
|
||||
> **PHASE CANCELLED (orchestrator, 2026-07-11)** — resolution of the BLOCKED note below: the
|
||||
> target constants are fictional (verified against the local SDK), the existing
|
||||
> `mediaPlayback|systemExempted` declaration is confirmed correct, and Root Cause B is withdrawn.
|
||||
> Tasks 1.1-1.7 ship no code. See the correction banner in design.md and the amended Requirement
|
||||
> in specs/native-alarms/spec.md. Slices 2 and 3 proceed unaffected.
|
||||
|
||||
- [ ] 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.
|
||||
|
||||
> **BLOCKED (sdd-apply, 2026-07-11):** `FOREGROUND_SERVICE_TYPE_ALARM` / `android.permission.FOREGROUND_SERVICE_ALARM` do NOT exist in the Android SDK. Verified via `javap -constants` against `android.content.pm.ServiceInfo` and `android.Manifest$permission` in the local `android-34`/`android-35`/`android-36` platform `android.jar`s, plus a full `FOREGROUND_SERVICE_TYPE_*` field sweep of `api-versions.xml`. The only FGS types that exist through API 36 are: camera, connectedDevice, dataSync, health, location, manifest, mediaPlayback, mediaProcessing, mediaProjection, microphone, phoneCall, remoteMessaging, shortService, specialUse, systemExempted — no `alarm` variant. Edits 1.1-1.3 were applied then reverted (`git checkout --`) to avoid landing an unresolved-Kotlin-reference / invalid-manifest-enum compile break that `flutter analyze` cannot catch (Dart-only) and that this task explicitly forbids validating via `flutter build`/gradle. This reconfirms the identical finding already recorded in `app-quality-and-native-alarms` (T-S1-03/T-S1-04), which used `systemExempted`/`FOREGROUND_SERVICE_SYSTEM_EXEMPTED` for the same reason — see design #2310's own "Open Questions" section, which flagged but did not resolve this before approval. Design #2310 / tasks #2316 need correction before Slice 1 can proceed: either identify a real SDK-backed fix for the API 34+ `ForegroundServiceTypeException`, or confirm `systemExempted` was already correct and the actual Slice 1 defect (if any) lies elsewhere. Working tree is clean — no diff left on either file.
|
||||
|
||||
- [ ] 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)
|
||||
|
||||
- [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)
|
||||
|
||||
- [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)
|
||||
|
||||
- [x] 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).
|
||||
- [x] 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.
|
||||
|
||||
> Implementation note: 4.1+4.2 were implemented as a single RED pair (one `testWidgets`, two
|
||||
> assertion stages) in `test/pantallas/pantalla_alarma_sonando_test.dart`, radio path. Rather than
|
||||
> relying on real `estadoStream` subscription timing (existing `_montarPantalla` pre-emits
|
||||
> `reproduciendo` BEFORE mount in all prior tests, which — traced via code inspection — means
|
||||
> `_confirmarAudioFlutterListo()` never actually fires in those fixtures: the broadcast stream drops
|
||||
> the pre-mount event, and the fallback timer's immediate-cancel branch also short-circuits before
|
||||
> any listener would see it), added a new opt-out param `audioYaReproduciendo` (default `true`,
|
||||
> preserves all existing tests byte-for-byte) plus a test-only `Completer`-based gate
|
||||
> (`FakePuertoAlarmasAndroid.puertaConfirmarAudioFlutter`) on `confirmarAudioFlutter`, so the test
|
||||
> can deterministically observe the pre-confirm state, release the gate, then observe the
|
||||
> post-confirm state — independent of stream/timer race conditions. Confirmed RED against the
|
||||
> pre-4.3 code: the gate-pending assertion failed with `Actual: [0.05, 0.85]` (ramp already fired
|
||||
> before the gate was ever released), proving the ramp was ungated.
|
||||
>
|
||||
> Also added a THIRD test beyond the literal 4.1/4.2 wording, covering the explicit edge case in
|
||||
> this batch's own dispatch instructions: if `confirmarAudioFlutter` FAILS (dead/never-there native
|
||||
> channel), the Dart ramp must still start — the ring must never stay stuck at
|
||||
> `_volumenInicialFadeIn` forever, since Dart is the only audible source once native is gone. Added
|
||||
> `FakePuertoAlarmasAndroid.fallaConfirmarAudioFlutter` to simulate this. Confirmed RED against
|
||||
> pre-4.3 code too: the fake's `StateError` propagated UNCAUGHT out of `_confirmarAudioFlutterListo()`
|
||||
> (no try/catch existed), crashing the test — proving the failure path was unhandled before this
|
||||
> slice.
|
||||
|
||||
- [x] 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.
|
||||
|
||||
> Implementation note: moved as specified. `_confirmarAudioFlutterListo()` now wraps the native
|
||||
> channel call in `try { await ... } catch (e) { debugPrint(...); } finally { _iniciarFadeIn(); }` —
|
||||
> matching the existing `try/catch/finally` idiom already used in `_detener()`/`_posponer()` in the
|
||||
> same file. The `finally` block is what satisfies the failure-edge case above: whether the native
|
||||
> confirmation succeeds or throws, the fade-in always starts exactly once (still gated by the
|
||||
> pre-existing `_audioFlutterConfirmado` guard at the top of the method, unchanged). All 3 new tests
|
||||
> (4.1+4.2 combined, plus the failure-edge test) pass after this change; `rg '_iniciarFadeIn'
|
||||
> lib/` confirms exactly one call site remains (inside `_confirmarAudioFlutterListo`) plus the
|
||||
> function definition itself.
|
||||
|
||||
- [x] 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. (Also included `pantalla_alarma_sonando_scaffold_test.dart` and `servicio_alarmas_android_test.dart` for extra confidence since a shared fake was extended. 22/22 tests green, no regressions — none of the pre-existing tests assert on fade/volume timing, since none of them previously exercised `_confirmarAudioFlutterListo()` at all under the old pre-mount-emit fixture pattern, as traced in the 4.1/4.2 note above.)
|
||||
- [x] 4.5 `flutter analyze` and `dart format .` — confirm clean formatting/lint state for all Slice 3 edits. (`flutter analyze`: 0 issues. `dart format .` reformatted the 3 Slice 3 files cleanly; it also touched 8 files unrelated to this change — pre-existing formatter-version drift on `main`, confirmed reproducible and out of scope per this batch's file-scope constraint — reverted via `git checkout --` both times it recurred, see Phase 6 6.3 note.)
|
||||
|
||||
## 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
|
||||
|
||||
- [x] 6.1 Full-repo `rg 'systemExempted|SYSTEM_EXEMPTED'` across `android/app/src/main` — confirm zero remaining references (fully dropped, not kept alongside `alarm`).
|
||||
|
||||
> **INTERPRETATION FLIP (sdd-apply, 2026-07-11):** this check's original "zero remaining
|
||||
> references" wording was written under the Phase 1 premise that later proved false. Ran it anyway:
|
||||
> 3 references found (`AndroidManifest.xml:6` permission, `AndroidManifest.xml:58`
|
||||
> `foregroundServiceType`, `PluriWaveAlarmService.kt:124` runtime constant) — all `systemExempted`/
|
||||
> `SYSTEM_EXEMPTED`, none `alarm`. This is the CORRECT and EXPECTED state, not a failure: per the
|
||||
> Phase 1 cancellation banner (design.md correction, confirmed via `javap -constants` SDK
|
||||
> inspection), `FOREGROUND_SERVICE_TYPE_ALARM`/`FOREGROUND_SERVICE_ALARM` are fictional and
|
||||
> `mediaPlayback|systemExempted` is confirmed the correct, intentional declaration — so
|
||||
> "fully dropped" is no longer the right target; "present and internally consistent between
|
||||
> manifest and runtime" is. Manifest (`systemExempted` at L6+L58) and Kotlin runtime
|
||||
> (`FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` at L124) match each other exactly, same as before this
|
||||
> entire change — Slice 1 shipped no code (cancelled), so this file pair was never touched by any
|
||||
> batch of this change. Marking complete because the check RAN and its result was interpreted
|
||||
> correctly against the corrected design, not because the literal original wording's outcome was
|
||||
> achieved.
|
||||
|
||||
- [x] 6.2 `flutter test` (full suite) and `flutter analyze` — final clean run before requesting review.
|
||||
|
||||
> Implementation note: the full-suite invocation reproduced the documented intermittent hang (this
|
||||
> task's own dispatch instructions named `estado_alarmas_ejecuciones_test.dart` and
|
||||
> `servicio_grabacion_radio_test.dart` as known culprits) — it stalled past `+276` tests for several
|
||||
> minutes of real time with zero further progress and had to be killed. Fell back exactly as
|
||||
> instructed: ran all 44 other test files (everything under `test/` except those two) together —
|
||||
> `01:02 +272: All tests passed!`, zero failures. Then ran the two known-flaky files standalone —
|
||||
> `+7: All tests passed!`, zero failures, confirming (again) they only misbehave under full-suite
|
||||
> load, not on their own — same conclusion already recorded in Batch 2's apply-progress for
|
||||
> `servicio_grabacion_radio_test.dart` (`_fallar must clear activa flag immediately`, timing-
|
||||
> sensitive). 272 + 7 = 279 tests total across all 46 files in the suite, all green. `flutter
|
||||
> analyze`: 0 issues.
|
||||
|
||||
- [x] 6.3 `dart format .` — confirm no formatting diffs remain uncommitted.
|
||||
|
||||
> Implementation note: `dart format .` left the 3 files this batch touched
|
||||
> (`lib/pantallas/pantalla_alarma_sonando.dart`, `test/helpers/fakes_alarmas.dart`,
|
||||
> `test/pantallas/pantalla_alarma_sonando_test.dart`) untouched on its second run (already clean
|
||||
> from the 4.5 pass) — confirmed via `git status --short` showing only those 3 files modified. Both
|
||||
> times `dart format .` ran in this batch (4.5 and 6.3) it ALSO reformatted 8 files this batch never
|
||||
> edited (`lib/pantallas/pantalla_ajustes.dart`, `lib/servicios/servicio_ecualizador.dart`, and 6
|
||||
> test files under `test/estado`, `test/pantallas`, `test/servicios`) — reverted both times via `git
|
||||
> checkout --` to stay within this batch's file-scope constraint (only
|
||||
> `pantalla_alarma_sonando.dart` + test files). This is pre-existing formatter-version drift already
|
||||
> present on `main` before this change started, unrelated to Slice 2 or Slice 3 — worth a
|
||||
> follow-up `dart format .` cleanup commit on its own, out of scope here.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Verification Report
|
||||
|
||||
**Change**: alarm-volume-ramp-restore
|
||||
**Version**: on-disk spec (`openspec/changes/alarm-volume-ramp-restore/specs/native-alarms/spec.md`), post-cancellation-correction, 2026-07-11
|
||||
**Mode**: Strict TDD (Dart slices) + Standard/code-inspection (Kotlin slices, `flutter build` forbidden)
|
||||
**Verified**: 2026-07-11, against commits `acd903d` (Slice 2) and `66a1952` (Slice 3); Slice 1 CANCELLED (see design.md correction banner)
|
||||
|
||||
## Completeness
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Tasks total | 44 (Phase1:7 + Phase2:9 + Phase3:12 + Phase4:5 + Phase5:8 + Phase6:3) |
|
||||
| Tasks complete | 29 (Phase2 9/9, Phase3 12/12, Phase4 5/5, Phase6 3/3) |
|
||||
| Tasks incomplete | 15 (Phase1: 7 cancelled by design; Phase5: 8 mandatory human QA gate) |
|
||||
| Automatable tasks (excl. Phase1-cancelled, Phase5-human-gate) | 29/29 = 100% |
|
||||
|
||||
Note: apply-progress #2332 self-reported "29/37 automatable" -- actual denominator recounts to 29/29 (100%) excluding Phase1 cancelled tasks, or 29/36 including them. Cosmetic arithmetic drift only, not a code issue.
|
||||
|
||||
## Build & Tests Execution
|
||||
|
||||
**Build**: N/A -- flutter build / gradle explicitly forbidden this pass (Kotlin = code-inspection only per design); not executed.
|
||||
|
||||
**Tests**: PASSED 34/34 (independently re-run this verify session)
|
||||
|
||||
```text
|
||||
flutter test test/pantallas/pantalla_alarma_sonando_test.dart test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart test/pantallas/pantalla_alarma_sonando_scaffold_test.dart test/servicios/servicio_alarmas_android_test.dart test/estado/estado_alarmas_snooze_test.dart
|
||||
...
|
||||
00:04 +34: All tests passed!
|
||||
```
|
||||
|
||||
Full-suite run intentionally not attempted -- documented, reproducible environment hang (estado_alarmas_ejecuciones_test.dart / servicio_grabacion_radio_test.dart under full-suite load only, unrelated to this change, already root-caused across Batches 2/2b/3). Targeted runs are the correct strategy here.
|
||||
|
||||
flutter analyze: 0 issues (verified independently this session).
|
||||
|
||||
**Coverage**: Not executed this pass (informational/non-blocking per strict-tdd-verify.md; task checklist specifies targeted tests + analyze only).
|
||||
|
||||
## Spec Compliance Matrix
|
||||
|
||||
(10 scenarios found on-disk; task briefing said 9, minor miscount, SUGGESTION only)
|
||||
|
||||
| Requirement | Scenario | Test | Result |
|
||||
|-------------|----------|------|--------|
|
||||
| Manifest declares alarm-eligible FGS | Manifest keeps the verified-correct FGS declaration | static grep, verified directly: AndroidManifest.xml:58 mediaPlayback\|systemExempted, PluriWaveAlarmService.kt:123-124 MEDIA_PLAYBACK or SYSTEM_EXEMPTED | COMPLIANT |
|
||||
| Manifest declares alarm-eligible FGS | Native service starts from broadcast context on Android 14+ | none (manual QA only) | DEFERRED - Phase 5 task 5.2 |
|
||||
| Ring-scoped device-volume override | Alarm is audible when device media volume is 0 | none automated (manual QA); code inspection supports plausibility | DEFERRED - Phase 5 task 5.1 |
|
||||
| Ring-scoped device-volume override | Player fade-in ramps within the overridden level | pantalla_alarma_sonando_test.dart fade-in dedup tests, ramp reaches [0.05, 0.85] | COMPLIANT (pre-existing ramp mechanism, only gating changed; confirmed still reaches target) |
|
||||
| Ring-scoped device-volume override | Dismiss restores the original captured volume | dismiss_guard_test.dart "detener: restaura el volumen de medios exactamente una vez" | COMPLIANT (Dart side); native exact-value restore deferred - Phase 5 task 5.3 |
|
||||
| Ring-scoped device-volume override | Snooze restores the original captured volume | dismiss_guard_test.dart "posponer: restaura el volumen de medios exactamente una vez" | COMPLIANT (Dart side); deferred - Phase 5 task 5.4 |
|
||||
| Ring-scoped device-volume override | Restore is idempotent across double-exit paths | dismiss_guard_test.dart "_silenciarAudio y dispose en secuencia no duplican la restauracion" plus Kotlin mediaVolumeOverridden guard | COMPLIANT (named scenario passes) - see WARNING-1 for a related-but-distinct dispose()-as-sole-caller gap NOT covered by this named scenario |
|
||||
| Ring-scoped device-volume override | Normal radio playback never triggers the override | dismiss_guard_test.dart regression guard, play/pause never touches channel | COMPLIANT |
|
||||
| Ring-scoped device-volume override | App killed mid-ring - best-effort restore only | none (accepted residual gap per spec own text) | ACCEPTED GAP; Kotlin backstop code-inspected (flutterOwnsRing-gated) |
|
||||
| Single fade-in driver across handoff | No double-ramp interleaving at handoff | pantalla_alarma_sonando_test.dart gate pair plus failure-edge test | PARTIAL - Dart-side gating fully tested/green; native cross-process MediaPlayer timing explicitly untestable by this agent per spec own testability note - deferred Phase 5 task 5.5 |
|
||||
|
||||
Compliance summary: 6/10 fully COMPLIANT with automated coverage, 1 PARTIAL, 2 DEFERRED (manual-QA-only by spec design), 1 ACCEPTED GAP. Zero FAILING, zero UNTESTED-when-testable.
|
||||
|
||||
## Correctness (Static Evidence - Kotlin, code-inspection per design)
|
||||
|
||||
| Requirement | Status | Notes |
|
||||
|------------|--------|-------|
|
||||
| MainActivity Volatile companion state | Implemented | mediaVolumeOverridden: Boolean, capturedMediaVolume: Int? (L914-918) |
|
||||
| overrideMediaVolumeForRing idempotent capture+max | Implemented | guard at top (L324-327), capture+set(max, flag 0) (L328-339) |
|
||||
| restoreMediaVolume no-op guard | Implemented | early return when not overridden (L349-352), clears state in finally (L362-365) |
|
||||
| restoreMediaVolumeBestEffort engine-dead-safe | Implemented | null-checks activeInstance, no-op+log when dead (L952-963) |
|
||||
| PluriWaveAlarmService backstop gated on flutterOwnsRing | Implemented | stopAlarm() L384-386, onDestroy() L528-530 |
|
||||
| flutterOwnsRing set BEFORE native stop in confirmFlutterAudio | Implemented | MainActivity.kt L169-170 |
|
||||
| flutterOwnsRing reset in startAlarm() | Implemented | PluriWaveAlarmService.kt L97, after re-entrancy guard |
|
||||
| Manifest/runtime FGS type unchanged (Slice 1 cancelled) | Confirmed unchanged | manifest:6 permission plus :58 type both still systemExempted; Kotlin:123-124 matches |
|
||||
| _prearrancarAudioAlarma override is FIRST statement | Implemented | lib/app.dart L365-372, before mounted/emisora checks; only construction site of PantallaAlarmaSonando (grep-confirmed) |
|
||||
| Single _iniciarFadeIn() call site | Implemented | lib/pantallas/pantalla_alarma_sonando.dart L152 only, inside _confirmarAudioFlutterListo() finally block |
|
||||
|
||||
## Coherence (Design)
|
||||
|
||||
| Decision | Followed | Notes |
|
||||
|----------|-----------|-------|
|
||||
| Manifest FGS combo (drop systemExempted, add alarm) | N/A - superseded | Correctly reverted/cancelled per design.md own correction banner; both files confirmed unchanged |
|
||||
| Override ownership/state (Volatile on MainActivity companion) | Yes | |
|
||||
| Stream reference level = max, fraction reserved | Yes | fraction accepted-but-unused, documented deviation |
|
||||
| setStreamVolume flags = 0 | Yes | both override (L332) and restore (L357) |
|
||||
| Restore idempotence (once-guarded) | Yes | both Kotlin (mediaVolumeOverridden) and Dart (_volumenMediaRestaurado) layers |
|
||||
| Restore call sites: _silenciarAudio() plus dispose() | Partially | both wired as specified; dispose() call is non-functional as a standalone safety net when reached first - see WARNING-1 |
|
||||
| Native backstop in stopAlarm/onDestroy | Yes, with a superseding refinement | Batch 2b flutterOwnsRing gating resolves a real risk found during implementation (engram #2335 RESOLVED); coherent evolution, not a harmful deviation |
|
||||
| Override trigger point: app.dart, before early return | Yes | |
|
||||
| Fade-in dedup gate via existing handoff seam | Yes | |
|
||||
|
||||
## TDD Compliance
|
||||
|
||||
| Check | Result | Details |
|
||||
|-------|--------|---------|
|
||||
| TDD Evidence reported | Yes | apply-progress #2332, Batch 2 (Phase 3) plus Batch 3 (Phase 4) tables |
|
||||
| All tasks have tests | Mostly | 34/36 automatable tasks (excl. Phase1/Phase5) test-backed; task 3.9 has one explicit, justified deviation (source-inspection only, independently re-verified accurate) |
|
||||
| RED confirmed (tests exist) | Yes | all referenced test files/cases independently re-read and confirmed present |
|
||||
| GREEN confirmed (tests pass) | Yes | 34/34 passed on independent re-run this session |
|
||||
| Triangulation adequate | Yes | 3.5-3.7 = 3 cases for 3 scenarios; 4.1+4.2+failure-edge = 3 cases for gate+failure behavior |
|
||||
| Safety Net for modified files | Yes | 3.12 and 4.4 reruns reported; independently reconfirmed (34/34, this session) |
|
||||
|
||||
TDD Compliance: 5/6 checks fully passed (1 partial - task 3.9 documented, justified deviation)
|
||||
|
||||
## Test Layer Distribution (new tests added by this change)
|
||||
|
||||
| Layer | Tests | Files | Tools |
|
||||
|-------|-------|-------|-------|
|
||||
| Unit | 2 | 1 (servicio_alarmas_android_test.dart) | flutter_test + mock MethodChannel |
|
||||
| Integration (Widget) | 6 | 2 (dismiss_guard_test.dart: 4, pantalla_alarma_sonando_test.dart: 2) | flutter_test testWidgets + Fakes |
|
||||
| E2E | 0 | 0 | not installed/applicable |
|
||||
| Total | 8 | 3 | |
|
||||
|
||||
## Assertion Quality
|
||||
|
||||
No CRITICAL/WARNING trivial-assertion violations found across the 8 new test cases. All assertions call production code and assert specific, non-trivial values (channel method+args, call counts, volume sequences).
|
||||
|
||||
Assertion quality: All assertions verify real behavior.
|
||||
|
||||
## Quality Metrics
|
||||
|
||||
Linter: No errors (flutter analyze: 0 issues)
|
||||
Type Checker: No errors (covered by flutter analyze)
|
||||
|
||||
## Issues Found
|
||||
|
||||
### CRITICAL
|
||||
|
||||
None.
|
||||
|
||||
### WARNING
|
||||
|
||||
1. dispose() restore call site is non-functional as a standalone safety net (verified via actual test execution, not speculation). _restaurarVolumenMediaUnaVez() is called from dispose() via unawaited(...) (pantalla_alarma_sonando.dart L276). When it is the FIRST/ONLY caller (i.e. _volumenMediaRestaurado still false - any widget teardown that bypasses _detener()/_posponer()), its synchronous prefix evaluates context.read EstadoAlarmas - but by the time State.dispose() runs, Flutter Element.unmount() has already flipped the element lifecycle to defunct (this happens before dispose() is invoked, per Flutter framework ordering), so the ancestor lookup throws a FlutterError with the message "Looking up a deactivated widget ancestor is unsafe... save a reference... in didChangeDependencies()" - a well-documented Flutter/Provider anti-pattern. This exact message was observed repeating in the live flutter test output for every test that mounts and unmounts PantallaAlarmaSonando without a prior _silenciarAudio() call. The exception is caught by _restaurarVolumenMediaUnaVez() own try/catch and silently logged (no crash), so app stability is unaffected, and the named spec scenarios (Dismiss restores, Snooze restores, Restore is idempotent) all pass because _silenciarAudio() runs first, while the widget is still mounted, in every code path that reaches it (confirmed: PantallaAlarmaSonando has exactly one construction site, in app.dart, and both _detener()/_posponer() call _silenciarAudio() before triggering navigation pop). However, this means the design stated dual-restore-call-site strategy (invoke restore inside _silenciarAudio() AND again in dispose()) has only ONE functioning Dart-side path in practice; dispose() provides zero actual coverage for any abrupt teardown that bypasses _detener()/_posponer() (e.g. a raw back-button pop if Navigator.canPop() is true - not exercised by any current test). Not a named-spec-scenario failure (WARNING per Decision Gates: design deviation exists implies WARNING unless it breaks a spec), but a real, previously-undiscovered defect worth fixing or explicitly documenting as an accepted residual gap alongside the already-documented process-death gap. Suggested fix direction (not applied - verify does not fix issues): capture the PuertoAlarmasAndroid reference earlier (e.g. in didChangeDependencies()/_iniciarAlarma() into an instance field) so dispose() can invoke it without a fresh context.read().
|
||||
2. Task 3.9 (override-ordering test in app.dart) has no automated RED/GREEN pair - explicitly documented deviation, verified via source inspection only. Independently re-verified this session: forzarVolumenMediaParaAlarma(1.0) is confirmed the first statement in _prearrancarAudioAlarma, claim is accurate. Recommend the already-noted follow-up (testable DI seam on PluriWaveApp) if genuine automated coverage is desired.
|
||||
|
||||
### SUGGESTION
|
||||
|
||||
1. apply-progress #2332 self-reported task count ("29/37 automatable") has a minor arithmetic drift vs the recount here (29/29 excl. Phase1, or 29/36 incl.) - cosmetic documentation nit only.
|
||||
2. Task dispatch briefing said "9 scenarios"; on-disk spec has 10 - minor miscount in the dispatch note, not an artifact defect.
|
||||
3. Pre-existing dart format . drift on 8 unrelated files (main branch) - already flagged by apply-progress as out-of-scope follow-up cleanup, still pending.
|
||||
4. Pre-existing full-suite flutter test hang (environment/tooling issue, unrelated to this change) - already documented across 3 batches, recommend separate investigation.
|
||||
|
||||
## Verdict
|
||||
|
||||
PASS WITH WARNINGS
|
||||
|
||||
Slices 2 (ring-scoped volume override) and 3 (fade-in dedup) are code-complete, fully test-covered where automatable, and static-analysis-clean; Slice 1 is correctly cancelled and internally consistent with its corrected spec/design. Zero CRITICAL issues. Two substantive WARNINGs found (one is a genuine, newly-discovered, evidence-backed defect in the dispose() restore safety net - non-crashing, does not break any named spec scenario, but undermines part of the design intent and should be triaged before or shortly after archive). Phase 5 (8 mandatory human on-device QA tasks) remains entirely unchecked by design - required before this change can be considered fully verified in production; no agent can complete it.
|
||||
Reference in New Issue
Block a user