Files
pluriwave/openspec/changes/archive/2026-07-12-native-alarm-ring/verify-report.md
T
FreeTLab 41b95fed44
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
docs(openspec): archive native-alarm-ring and update the native-alarms spec
Close the SDD cycle for the ring architecture replacement: verified
with one critical (channel silence by omission) fixed and re-checked
before archive, delta merged into the main native-alarms spec (2
requirements removed, 5 added), artifacts archived byte-for-byte.
Phase 3 on-device QA (9 items) remains the mandatory human gate.
2026-07-12 12:36:22 +02:00

22 KiB

Verification Report: native-alarm-ring

Change: native-alarm-ring Capability: native-alarms Version: spec delta #2389 (5 ADDED requirements / 10 scenarios, 2 REMOVED requirements) Mode: Strict TDD (project-wide; WU1 collapsed-cycle exception per design Sec.7-8; WU2 no test harness exists, documented exception) Artifacts read: spec #2389, design #2390 (11 ADRs), tasks #2391 (35 tasks), apply-progress #2392 -- all cross-checked against on-disk openspec/changes/native-alarm-ring/ (authoritative) and live code at HEAD 884567b.

Completeness

Metric Value
Tasks total 35
Tasks complete 26 (Phase 1: 14/14, Phase 2: 12/12)
Tasks incomplete 9 (Phase 3: 3.1-3.9, all correctly left unchecked -- mandatory human/QA gate, on-disk tasks.md re-checked line by line, confirmed unchecked)

WU1 commit bd7f883 (rebase of 83e003a, identical tree/stat) and WU2 commit a69f397 (pushed as 884567b, identical tree/stat) both verified on main, pushed, working tree clean except untracked openspec/changes/native-alarm-ring/.

Build and Tests Execution

Analyze: PASS

$ flutter analyze
Analyzing pluriwave...
No issues found! (ran in 6.8s)

Tests (targeted 4-file suite -- full-suite flutter test intentionally NOT run, known pre-existing hang per project instructions): PASS -- 16/16

$ 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
...
00:07 +16: All tests passed!

Matches apply-progress claim exactly (26 baseline to 16 post-rewrite = 10 deleted audio-mechanism tests: 2+1+1+1 in the ring-screen test file, 2 in the service test file, 3 in the dismiss-guard file -- arithmetic re-verified independently).

Repo-wide symbol sweep (task 1.12/2.10, re-run independently, not trusted from the report):

$ rg -n "confirmarAudioFlutter|forzarVolumenMediaParaAlarma|restaurarVolumenMedia|audioPrearrancado|_volumenInicialFadeInAlarmas|_prearrancarAudioAlarma" --glob '*.dart'
(0 matches)

$ rg -n "flutterOwnsRing|confirmFlutterAudio|overrideMediaVolumeForRing|restoreMediaVolume|restoreMediaVolumeBestEffort|startFadeIn|initialVolume\(" android/
(0 matches)

$ rg -n "setStreamVolume" android/  (repo-wide, both files)
(0 matches)

$ rg -n "pluriwave_alarm_fire_v2" android/
android/.../PluriWaveAlarmService.kt:606: private const val LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"
(exactly 1 match, the migration-deletion constant -- confirmed)

Coverage: not available (no coverage tool configured in this project) -- informational only per Strict TDD rules, not blocking.

Spec Compliance Matrix

# Requirement Scenario Evidence Result
1 Sole native ring-audio ownership Media volume 0 does not silence the ring Code-inspection: alarmAudioAttributes() = USAGE_ALARM/CONTENT_TYPE_MUSIC, zero setStreamVolume calls anywhere, native path fully independent of STREAM_MUSIC. Dart: screen has zero audio API surface (compile-time). On-device audibility itself is task 3.8, unchecked. PARTIAL -- mechanism correct by inspection, audibility claim deferred to QA (by design)
2 Sole native ring-audio ownership Fire notification posts with no sound Code-inspection: ensureChannel() deletes the old setSound(DEFAULT_ALARM_ALERT_URI, ...) block but does not call setSound(null, null). Design D4 explicitly specifies setSound(null, null); the codebase's own pre-notice channel (AlarmScheduler.kt/PluriWaveAlarmReceiver.kt) proves setSound(null, null) is the required call for silence in this exact API. Omitting setSound() entirely leaves the channel's default sound (Android plays the platform default notification sound on channels where setSound was never called) -- not silent. FAILING (see CRITICAL-1)
3 Sole native ring-audio ownership Three-stage fallback preserved, single fade anchor Code-inspection: fadeAnchorElapsedMs set once in startAlarm(), reused unchanged across startStationAudio (station), startStationAudio (fallback-station), startFallbackAudio (WAV) -- single shared clock confirmed by reading all 3 call sites. COMPLIANT (code-inspection; on-device confirmation task 3.3/3.6 pending)
4 Exponential dB fade-in ceiling Fade rises exponentially to the ceiling Code-inspection, math verified symbol-by-symbol: gainDb = fraction40-40, curve = 10^(gainDb/20) via Math.pow(10.0, (gainDb/20.0)), result = (ceilingcurve).coerceIn(0f,1f). At elapsed=0: curve=10^(-2)=0.01 -> 1% of ceiling. At elapsed=fadeMs: curve=10^0=1 -> exactly ceiling. COMPLIANT (code-inspection, exact formula match)
5 Exponential dB fade-in ceiling No-fade path starts pop-free Code-inspection: verified on all three sources individually -- station (setVolume before setDataSource/prepareAsync, recomputed+set before start()), fallback-station (same fn, stage="fallback-station"), WAV (setVolume before prepareAsync, recomputed+set before start()). computeFadeVolume returns ceiling immediately when fadeMs<=0. COMPLIANT (code-inspection, all 3 sources)
6 Manual transient focus; no system volume writes Focus requested at start, abandoned at end Code-inspection: requestAlarmAudioFocus() at top of startAudio(); abandonAlarmAudioFocus() in stopAlarm()'s full-teardown branch, reached both from a normal stop AND from onDestroy() (which calls stopAlarm(activeAlarmId)). Both API 26+ (AudioFocusRequest) and pre-26 (deprecated 3-arg) variants present. setStreamVolume: 0 matches repo-wide. COMPLIANT (code-inspection; dumpsys audio on-device confirmation not itemized in QA checklist -- see WARNING-1)
7 Manual transient focus; no system volume writes Dismiss, snooze, and back tear down with nothing to restore Flutter test: fakes no longer have forzarVolumenMediaParaAlarma/restaurarVolumenMedia members at all (compile-time proof); dismiss-guard regression test explicitly proves normal radio playback never touches the native alarm port. Native: 0 setStreamVolume calls anywhere, so nothing was ever captured to restore. COMPLIANT (compile-time + code-inspection; full behavioral confirmation task 3.7 pending)
8 Notification channel migration v2 -> v3 v2 deleted exactly once on upgrade Code-inspection: migrateLegacyChannels() guarded by KEY_CHANNELS_MIGRATED_V3 SharedPreferences flag, deletes 3 legacy ids (native, fire, fire_v2) via runCatching { deleteNotificationChannel(...) } (documented safe no-op if absent), sets the flag, never re-runs. COMPLIANT (code-inspection; no QA checklist item explicitly re-tests a real v2->v3 device upgrade -- see WARNING-1)
9 Ring screen is pure UI Buttons and back call only EstadoAlarmas Flutter test, 16/16 green: _detener() calls only alarmas.finalizarEjecucion(...) + _dismissScreen(); _posponer() calls only alarmas.posponerAlarma(...) + _dismissScreen(); back (PopScope) routes to _detener(). Zero EstadoRadio/audio calls (import removed). Regression test proves normal radio use never reaches the alarm port. COMPLIANT
10 Ring screen is pure UI Reduced Android port surface flutter analyze 0 issues + repo-wide rg 0 matches for the 3 removed port methods across all Dart files -- strongest possible evidence (compile-time absence: the interface, the impl, and the fake all lack the methods). COMPLIANT (compile-time)

Compliance summary: 7/10 fully COMPLIANT, 2/10 PARTIAL (audibility/behavior correctly deferred to the still-pending Phase 3 QA gate, mechanism itself sound), 1/10 FAILING (notification-sound scenario -- CRITICAL-1 below).

REMOVED requirements -- mechanism-absence check

Removed requirement Mechanism searched Result
Ring-scoped device-volume override forzarVolumenMediaParaAlarma, restaurarVolumenMedia (Dart); overrideMediaVolumeForRing, restoreMediaVolume, restoreMediaVolumeBestEffort, mediaVolumeOverridden, capturedMediaVolume, setStreamVolume (Kotlin) 0 matches repo-wide -- fully absent
Single fade-in driver across native-to-Flutter handoff audioPrearrancado, confirmarAudioFlutter, flutterOwnsRing, _iniciarFadeIn/_aplicarVolumenGlobal (old Dart driver) 0 matches repo-wide -- fully absent; exactly one fade driver remains (native startFadeLoop)

Correctness (Static Evidence)

Requirement Status Notes
Sole native ring-audio ownership Implemented Native STREAM_ALARM MediaPlayer is the only audio path; Dart screen has zero audio API surface
Exponential dB fade-in ceiling Implemented Formula matches spec exactly; verified on all 3 sources
Manual transient focus; no system volume writes Implemented Request/abandon symmetric, both API variants, 0 setStreamVolume
Notification channel migration v2->v3 Implemented with defect Migration guard/idempotency correct; silence claim is false (CRITICAL-1)
Ring screen is pure UI Implemented Compile-time enforced

Coherence (Design) -- 11 ADRs

Decision Followed? Notes
D1 -- Pure-fn fade curve, single 50ms loop Yes computeFadeVolume pure, startFadeLoop single instance per ring, reads player fresh each tick
D2 -- Anchor at RING start Yes fadeAnchorElapsedMs set in startAlarm() before startForeground/startAudio
D3 -- Manual AUDIOFOCUS_GAIN_TRANSIENT Yes Both API-level variants, no-op listener, symmetric request/abandon
D4 -- Silent channel pluriwave_alarm_fire_v3 (setSound(null,null)) + folded v3 migration guard NO on the silence part The setSound(null,null) call design explicitly specifies is missing entirely; code just deletes the old sound block. See CRITICAL-1. Migration-guard folding (3 legacy ids, one flag) IS correct.
D5 -- Keep enableVibration(true) Yes Preserved verbatim on the v3 channel
D6 -- Keep MediaPlayer, reject ExoPlayer Yes No ExoPlayer usage in the alarm service; only an unrelated network-config comment repo-wide
D7 -- Kill handoff surface Yes flutterOwnsRing, confirmFlutterAudio, both backstops, all MainActivity handlers/fns/companion state -- all deleted, 0 matches
D8 -- No new channel, status line from static config Yes Text now reads alarma.emisora/neutral key directly; 3 old l10n keys correctly become unused (see SUGGESTION-1)
D9 -- Screen exit unchanged, native stop is sole audio stop Yes _salidaEnCurso guard, PopScope back=Stop, _dismissScreen, pre-captured ScaffoldMessenger all preserved verbatim; _detener/_posponer no longer touch EstadoRadio
D10 -- Keep USAGE_ALARM + CONTENT_TYPE_MUSIC Yes alarmAudioAttributes() unchanged
D11 -- Defer boot-receiver wake-lock/OEM guidance N/A PluriWaveBootReceiver untouched by this change, correctly out of scope

Preserved Invariants (design "hard constraints") -- spot-checked in code

Invariant Verified
Wake-lock acquired before startForeground Yes -- acquireWakeLock() L120, before startForeground L126/133
startForeground before audio starts Yes -- startAudio(...) call at L142, after the startForeground try/catch block
3-stage fallback, 15s timeouts Yes -- STATION_START_TIMEOUT_MILLIS = 15_000L, used for both station stages via scheduleStationFallback
Id-scoped stopAlarm mismatch guard Yes -- stopAlarm() L400-409: a stop for a non-active id only cancels that id's notification, active ring untouched
AlarmNotificationStrings / NotificationBrand / full-screen intent Yes -- all three present in buildNotification()
Pre-notice channel (pluriwave_alarm_pre_notice) untouched, separate from the ring channel Yes -- different id, different file (PluriWaveAlarmReceiver), correctly explicit setSound(null, null) there (the contrast that exposes CRITICAL-1)

Exit Path -- traced hop by hop

Stop path: _detener() (pantalla_alarma_sonando.dart:42) -> EstadoAlarmas.finalizarEjecucion(alarmaId) (estado_alarmas.dart:252) -> android.ocultarNotificacionAlarma(alarmaId) (estado_alarmas.dart:260) -> ServicioAlarmasAndroid.ocultarNotificacionAlarma invokes MethodChannel dismissAlarmNotification (servicio_alarmas_android.dart:273-274) -> MainActivity.kt:135-145 handler -> PluriWaveAlarmService.stop(this, id) -> ACTION_STOP intent -> onStartCommand -> stopAlarm(requestedId) -> since requestedId == activeAlarmId, full teardown: cancel fallback timer, cancel fade loop, player.stop()/release(), clear activeAlarmId, release wake lock, abandon audio focus, cancel the fire notification, stopForeground(STOP_FOREGROUND_REMOVE), stopSelf(). Confirmed at every hop by direct code read, not inferred.

Snooze path: _posponer(minutos) -> EstadoAlarmas.posponerAlarma(alarma, minutos) (estado_alarmas.dart:194) -> same android.ocultarNotificacionAlarma(alarma.id) call (line 201) -> identical dismissAlarmNotification -> PluriWaveAlarmService.stop -> identical stopAlarm full teardown, before Android re-programs the snoozed occurrence. Same path, confirmed.

Back path: PopScope(canPop: false, onPopInvokedWithResult: ... unawaited(_detener())) -- identical to Stop. Confirmed by direct code read.

TDD Compliance (Strict TDD Mode active)

Check Result Details
TDD Evidence reported Yes apply-progress contains a "TDD Cycle Evidence" table for both work units
All tasks have tests Partial, justified WU1: yes (compile-coupled). WU2: zero -- no Kotlin test harness exists in this project (independently re-confirmed this pass: no android/app/src/test* or src/androidTest* dirs under android/app/src/, only debug/, main/, profile/)
RED confirmed Collapsed (WU1), N/A (WU2) WU1: classic per-file RED is structurally impossible -- flutter test compiles the whole suite as one unit, and deleting the 3 port methods + audioPrearrancado breaks compilation of every referencing test. This is design's own documented exception (design.md Sec.7-8), independently re-verified by reading the WU1 diff: production + test edits land in the exact same commit, confirming lib/tests could not be split. Not a skipped step.
GREEN confirmed Yes Re-ran the exact 4-file targeted suite myself: 16/16 green, matches apply-progress claim exactly, arithmetic re-verified (26 baseline minus 10 deleted = 16)
Triangulation adequate Single, appropriately so The rewritten regression test ("EstadoRadio reproduccion normal nunca toca el puerto...") is a single negative/proof-by-absence case -- multiple triangulated cases are not meaningful for this kind of assertion
Safety Net for modified files Yes Task 1.1 ran the full 4-file baseline (26/26 green) BEFORE any production edit

TDD Compliance: 5/6 checks fully passed, 1 partial-but-justified (WU2 has no runner by hard architectural constraint, substituted by code-inspection + static rg + the mandatory Phase 3 human QA gate, per design's own acceptance-gate decision "Kotlin is not agent-compilable -> on-device QA is the acceptance gate").

Test Layer Distribution

Layer Tests Files Tools
Unit (Kotlin, computeFadeVolume) 0 0 none -- no JVM/Kotlin test harness in this project (see SUGGESTION-2)
Integration (Dart widget tests) 16 4 flutter_test
E2E 0 0 not installed
Total 16 4

Assertion Quality

No trivial/banned patterns found in the touched test files (tautologies, ghost loops, smoke-test-only, mock-heavy). The new/rewritten regression test calls real production code (radio.reproducir, radio.audio.pausar) and asserts against fake-recorded state with distinct, meaningful expected values (length unchanged, lists empty) -- not a tautology. Trimmed survivor tests (posponer-5, system-back) keep concrete behavioral assertions (snoozeHasta equality, ocultadas contains id, programadas.last.snoozeHasta) after dropping only the now-obsolete audio assertions.

Assertion quality: All assertions verify real behavior

Quality Metrics

Dart Linter/Analyzer: 0 issues (flutter analyze, full repo) Kotlin Linter: Not available -- no ktlint/detekt configuration detected in this project; substituted by manual code-inspection above

Issues Found

CRITICAL

CRITICAL-1 -- Fire notification channel pluriwave_alarm_fire_v3 is not actually silent, contradicting spec and design.

  • Where: android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt, ensureChannel() (~L682-690)
  • What: WU2 deleted the old setSound(Settings.System.DEFAULT_ALARM_ALERT_URI, ...) block but never added a replacement setSound(null, null) call. The channel is constructed as NotificationChannel(CHANNEL_ID, name, IMPORTANCE_HIGH).apply { description = ...; enableVibration(true) } -- no setSound call at all.
  • Why this fails: Android's NotificationChannel plays the platform default notification sound when setSound() is never called; omission is not equivalent to silence. This is not a guess -- it is proven by this exact codebase: the pre-notice channel (AlarmScheduler.kt and PluriWaveAlarmReceiver.kt, both building pluriwave_alarm_pre_notice) explicitly calls .setSound(null, null) to achieve real silence. If simply omitting the call produced silence, that explicit call would be dead code -- it is not; it is the established, necessary pattern in this project.
  • Design contradiction: design.md ADR D4 (line 142) states verbatim: setSound(null, null) (silent: the player is the only audio). The implementation deviates from its own design.
  • Root cause: traced to tasks.md task 2.7's phrasing itself: "delete the setSound(...) block entirely (no setSound call = silent)" -- this parenthetical is the incorrect premise that both the implementation and apply-progress ("silent by omission") inherited and repeated unquestioned.
  • Spec impact: violates the ADDED requirement "Sole native ring-audio ownership" -> scenario "Fire notification posts with no sound" (spec #2389). In practice this means every alarm fire likely plays a brief platform "ding" layered on top of the native ring's fade-in-from-near-silence -- reintroducing a second audible source at the exact moment this whole redesign was meant to eliminate that class of bug.
  • Fix: add .setSound(null, null) inside the channel = NotificationChannel(...).apply { ... } block in ensureChannel(), alongside the existing enableVibration(true). One-line fix, no other code affected. Also worth a note that this bug would have been mechanically produced (not merely observed) by task 3.6 of the on-device QA checklist ("fire notification posts with no sound") had it been reached -- this verify pass catches it earlier via code-inspection, which the spec itself lists as a valid testability method for this exact scenario.

WARNING

WARNING-1 -- Phase 3 on-device QA checklist (3.3-3.9) does not explicitly itemize the v2->v3 channel migration on a real upgrade device, nor a dumpsys audio focus-abandon check.

  • The spec lists "code-inspection + on-device QA" as testability for both "v2 deleted exactly once on upgrade" and "Focus requested at start, abandoned at end (dumpsys audio)". Code-inspection passed for both (see matrix above). The 7-item checklist (3.3-3.9) covers fade/pop/second-source/dismiss-snooze-back/media-volume/FSI-delivery well, but has no item that (a) installs on a device with a pre-existing pluriwave_alarm_fire_v2 channel and confirms it is gone plus _v3 exists post-upgrade, or (b) runs dumpsys audio to directly confirm focus abandonment rather than inferring it from "other app's audio resumes." Neither blocks archiving on its own (both mechanisms are code-inspection-sound), but both are real, spec-named testability gaps the human QA pass should close before this change is considered fully verified end-to-end.

SUGGESTION

SUGGESTION-1 -- Three l10n getters are now dead code (expected/harmless per design D8). alarmRingingFallbackActive, alarmRingingTryingStation, alarmRingingPreparingFallback remain defined across all 13 generated app_localizations_*.dart files but have zero callers anywhere in lib/ or test/ (confirmed via repo-wide rg). This was called out explicitly in design.md as an accepted, harmless side effect -- no l10n source-of-truth (.arb) work is required by the spec's Non-Functional Notes. No action required unless a future cleanup pass wants to prune unused keys from the 13 .arb files.

SUGGESTION-2 -- No unit-test harness exists for PluriWaveAlarmService.kt, so the one genuinely pure, exactly-specified function in this change (computeFadeVolume) has zero automated coverage. The function has no Android framework dependency (elapsedMs: Long, fadeMs: Long, ceiling: Float -> Float, pure math) and would be trivially testable with a plain JVM unit test (no instrumentation/emulator needed) if an android/app/src/test/ source set plus JUnit dependency were added. This is a repo-wide gap (pre-dates this change) rather than something WU2 introduced, but this change is the first to put exact-value mathematical correctness (the dB curve) on the line, which is exactly the kind of logic that benefits most from a real automated regression test instead of relying on code-inspection plus human QA forever. Non-blocking; worth considering for a future change.

SUGGESTION-3 -- Minor ordering nit vs. the design's ASCII diagram (non-blocking, no functional effect identified). stopAlarm()'s actual order is cancel-fade -> player stop/release -> clear id -> release wake lock -> abandon audio focus -> cancel notification -> stopForeground, while design.md's Sec.2 diagram lists "abandon focus" before "release wake lock." No ADR pins this exact relative order and no functional dependency between wake-lock release and focus abandonment was found; flagging only for documentation-diagram accuracy, not as a defect.

Verdict

FAIL

One CRITICAL issue blocks archiving: the fire notification channel is not actually silent, directly contradicting both the spec ("Fire notification posts with no sound") and the implementation's own design ADR D4 (setSound(null, null)). The fix is a one-line addition in PluriWaveAlarmService.kt::ensureChannel(). Everything else in this change -- the fade-curve math, the single ring-anchored fade loop, the manual audio-focus request/abandon symmetry, the migration-guard folding, the full Dart pure-UI rewrite and port reduction, and the end-to-end exit path -- is implemented correctly and matches spec/design/tasks with strong evidence (16/16 targeted tests green, flutter analyze 0 issues, exhaustive rg sweeps at 0 matches for every removed symbol, and line-by-line math/code verification for every ADR). Recommend routing back to sdd-apply for the one-line fix, then re-verify, before proceeding to the mandatory Phase 3 on-device QA gate and sdd-archive.