Close the SDD cycle for the data-loss shielding change: verified pass
with warnings (0 critical, 10/10 scenarios with named tests, W1 fixed
post-verify), three stacked work units shipped plus the path-resolution
fix. The persistence-resilience capability spec is promoted to
openspec/specs/.
_cargarEmisorasCustom resolved the file path outside the IO guard, so
a throw from the resolver escaped into _init()'s Future.wait and took
the sibling loads (populares, favoritos, grupos) down with it — a gap
the old catch-all used to cover. Path resolution now gets the same
IO-fail treatment as an unreadable file: degraded flag, logged skip,
siblings unaffected.
Convert the 4 EQ persistence readers (device presets, matrix presets,
device names, per-station presets) to per-entry tolerant parsing via
the shared persistencia_tolerante helper, so one corrupt entry no
longer discards every sibling preset. The principal-preset reader
gains diagnostic logging on its existing fallback path. No degraded
flag or quarantine here (unlike alarms/stations) since EQ writes are
explicit-only and presets are trivially re-creatable.
A single malformed custom-station entry (missing uuid/url) used to wipe
the ENTIRE list on next load, and an unparseable file was treated the
same as an unreadable one -- both destroyed the user's saved stations
with no way to recover the original bytes.
Custom stations now parse per-entry via the shared persistencia_tolerante
helper (survivors kept, bad entries skipped+logged); a file that reads
but fails to decode is quarantined into a `.corrupt` sidecar instead of
being dropped, clearing the live path so the next add/remove starts
fresh. A file that cannot be READ at the OS level is left untouched and
a _customDegradado flag suppresses writes for the session -- unlike
alarms, this suppression is intentionally not lifted by an explicit
add/remove, since the file may still be intact on disk.
A single malformed alarm entry (bad/missing id, wrong type) used to
discard the ENTIRE persisted list on next load, and a fully unparseable
payload let the periodic recalculation silently overwrite it with an
empty one -- both destroyed valid alarms with no user action.
Adds a shared per-entry tolerant-parse helper (persistencia_tolerante.dart)
that skips and logs only the bad entry; ServicioAlarmas now normalizes its
cached raw after a partial load (no dirty-guard thrash) and sets a
degraded-read flag after a total decode failure that suppresses automatic
writes until a good read or an explicit user mutation restores authority.
The ringing screen only confirmed the native-to-Flutter handoff from
its playback-state listener, but app.dart pre-starts the station
before pushing the screen, so `reproduciendo` could be emitted before
the listener subscribed and no further event ever arrived. That
branch only cancelled the fallback timer: the gated fade-in never
started and the native alarm player was never told to stop, so the
alarm blared at the alarm-stream volume with no 5%-to-target ramp.
Previously this was a timing race the stream usually lost; gating the
ramp on the confirmation made the failure deterministic.
The already-playing branch now confirms the handoff explicitly
(idempotent with the listener), and the ramp re-imposes its 5% start
volume immediately instead of waiting for the first periodic tick.
Adds the regression test mounting in the real pre-started path.
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.
_restaurarVolumenMediaUnaVez() read the BuildContext to reach the
alarm port, but dispose() runs after the element is defunct, so the
lookup always threw (caught and logged) and the dispose safety-net
never actually restored the media volume when it was the sole exit
path. The state is now captured once in initState and the restore
helper uses the field. Adds the missing dispose-as-sole-caller
regression test.
The native service and the Flutter player each ran their own 5%-to-
target fade-in, and both could drive audible volume at the handoff,
producing a jump or ramp reset. The Dart ramp now starts exactly once
from the handoff-confirmation path: the player still pre-starts at 5%,
and _confirmarAudioFlutterListo() starts the ramp in a finally block
so it runs whether the native confirmation succeeds or fails — the
alarm can never stay stuck at 5% if the native side is already gone.
Work unit 3/3 of alarm-volume-ramp-restore (fade-in dedup).
The alarm's steady-state audio runs on the Flutter media-stream
player after the native handoff, so device volume 0 silenced it
entirely. The ring now forces STREAM_MUSIC to an audible reference:
Dart requests the override before pre-starting alarm audio (fallback
WAV included), Kotlin captures the current volume once and restores
it idempotently on every exit path (dismiss, snooze, dispose), with
a native best-effort backstop in service teardown.
The backstop is handoff-aware via PluriWaveAlarmService.flutterOwnsRing:
confirmFlutterAudio marks the handoff before triggering the native
stop, so the backstop cannot restore the volume mid-ring right as the
Flutter player takes over (that would re-silence the alarm at volume
0). The flag resets at every ring start; Flutter process death after
handoff remains a documented best-effort gap.
The alarm's perceived loudness keeps ramping 5% to the configured
volume through the player as before; normal radio playback and call
ducking never touch the override.
Work unit 2/3 of alarm-volume-ramp-restore (ring volume override).
Apply-stage SDK verification (javap on android-34/35/36 platform jars
plus api-versions.xml) proved FOREGROUND_SERVICE_TYPE_ALARM and the
FOREGROUND_SERVICE_ALARM permission are fictional constants. The
existing mediaPlayback|systemExempted declaration is the documented
correct pattern for an alarm app holding exact-alarm permissions, so
slice 1 ships no code and root cause B is withdrawn. Spec, design,
and tasks amended with the evidence; volume-override and fade-dedup
slices proceed unaffected.
Move the change folder to changes/archive/2026-07-11-bt-device-identity
with the verified artifact set (verdict: pass with warnings, 0 critical,
102/102 targeted tests) and create the bt-device-identity capability
spec under openspec/specs/. Phase 7 on-device QA remains the pending
human gate before release.
In-progress artifact sets from the current SDD cycles: exploration,
proposal, spec, design, tasks, and verify reports as produced so far.
Also drops a leftover working copy of eq-device-disconnect-revert
whose contents were already committed under changes/archive/.
The rename-priority and reconnect-dedup behaviors were each tested in
isolation but never composed: connect, rename, disconnect, re-pair
with the same MAC. Adds that regression test asserting no duplicate
entry appears, the preset entry survives untouched, and the custom
name still wins after reconnection.
Dart half of bt-device-identity. EstadoEcualizador now caches each
device's platform-reported name in memory so the settings screen
shows the device's own Bluetooth name instead of its raw id when no
custom rename exists, and skips auto-creating preset entries for the
composite-placeholder sentinel. Enabling multi-device EQ triggers the
Bluetooth permission request through the new channel contract. A
flag-guarded one-time migration purges only entries keyed by the
exact literal placeholder id from the three per-device preference
maps, since those collided entries cannot be attributed to a device.
Work unit 2/2 of bt-device-identity (Dart state + migration).
Without BLUETOOTH_CONNECT, Android 12+ returns the fixed placeholder
02:00:00:00:00:00 for every Bluetooth device's address, so all BT
devices collapsed onto the same equalizer identity and renames
appeared to duplicate devices after re-pairing.
Declare the permission, add a requestBluetoothConnect channel method
mirroring the existing notification-permission flow, guard the
placeholder in deviceToMap() with a colon-sanitized name-based
fallback id (replacing the dead 00:00:00:00:00:00 branch), and
re-emit the active device after the grant so already-connected
devices pick up their real MAC without a reconnect.
Work unit 1/2 of bt-device-identity (Kotlin plumbing).
_onDispositivoCambiado() bootstrapped a device-level preset entry for
every never-seen device id, including the built-in speaker. That
persistent level-3 entry masked later global-preset edits (level 3
beats level 4 on every resolution), so disconnecting a BT device or
cold-starting without one could leave the EQ stuck on an outdated
copy instead of the current global preset.
The base speaker is now excluded from the first-seen bootstrap:
disconnect and cold start always resolve through the live hierarchy.
BT/wired/USB devices keep their bootstrap behavior unchanged.
ExoPlayer assigns a new audio session id after transient audio-focus
interruptions (navigation prompts, radar warnings), leaving the
AndroidEqualizer attached to the dead session so playback resumed
without equalization until the next station switch. The session-id
listener now detects genuine rotations through a dedicated guard and
re-activates the equalizer with the current preset, gated on EQ
availability to stay clear of player teardown/rebuild.
Persist the exploration, proposal, spec, design, tasks, and
verify/archive reports produced during the multi-device EQ,
alarm-countdown, and notification-visual-polish SDD cycles.
Replace generic system icons (info bubble, stock alarm clock) with a
custom equalizer-bars vector drawable across all 4 notification
builders: pre-notice, snooze countdown, ringing alarm, and the audio
player. Apply the app's cyan brand color to the 3 alarm notifications
that previously had none. Audio notification now explicitly declares
its icon instead of falling back to the full-color launcher icon,
which Android was auto-silhouetting into an illegible status-bar
blob.
guardarAlarma only recomputed proximaEjecucion for the alarm being
saved; every other alarm kept whatever snapshot the last periodic
recalculation left, which can be stale or already past-due. Since
EstadoAlarmas.proximaAlarma just sorts by proximaProgramable, a stale
sibling could wrongly outrank a freshly activated/created/edited
alarm in the "Próxima alarma" panel until the next 1-minute tick.
Extended the same full-list recalculation guardarVacaciones already
did to guardarAlarma, eliminarAlarma, completarEjecucion,
sincronizarEjecucionesNativas, saltarProxima and
posponerEjecucionHasta, via a shared _recalcularLista helper.
Flutter's StandardMethodCodec encodes Dart ints that fit in 32 bits as
Java Integer, not Long. scheduleAlarm sends preNoticeAtMillis=0 when
rescheduling a snooze, which crashed the unchecked argument<Long>()
cast. Read all millis args as Number and convert with toLong().
posponerAlarma() and posponerProximaDesdePreaviso() called the native
scheduler with no error handling, unlike guardarAlarma(). When the
native call failed (e.g. revoked exact-alarm permission), the
exception escaped before notifyListeners() ran, leaving the alarm
list stuck on stale data with no real alarm scheduled and no snooze
countdown notification.
Both methods now mirror guardarAlarma()'s pattern: permission
pre-check, try/catch into _error, and an unconditional
notifyListeners() so the UI always reflects the outcome. Failures
surface via SnackBar in the ringing screen and in app.dart's
postpone-next handler.
Mirror the shipped snooze-countdown chain for the 30-min pre-notice
notification: re-arm ACTION_PRE_NOTICE at each minute boundary via
slot 9, self-stop at remaining<=1, self-heal from wall clock on
missed ticks. Wire cancellation at all 5 sites (cancelAlarm,
scheduleSpec no-trigger branch, snooze-transition branch,
ACTION_SKIP_NEXT, ACTION_POSTPONE_NEXT) using AlarmScheduler's own
requestCode formula to keep PendingIntent identity consistent.
Centralize every native-side user-facing string in a single
AlarmNotificationStrings store written by Flutter via a new
setNotificationStrings MethodChannel whenever the app locale changes,
and read at notification/channel build time (with English fallbacks)
even when the engine is dead. This replaces the hardcoded Spanish text
in the ringing notification ("Alarma PluriWave", "Posponer", "Detener"),
the pre-notice notification ("Posponer", "Omitir esta vez"), both
notification channels (names + descriptions) and the file-action
choosers ("Abrir carpeta", "Abrir grabación").
The per-alarm preNoticeTemplate/snoozeCountdown template+label args are
dropped from scheduleAlarm and the persisted spec and folded into the
shared store, so a locale change now also relocalizes already-scheduled
alarms. Channels are re-created on each use so their name/description
refresh after a language switch.
Adds alarmRingingNotificationTitle, alarmFire/PreNoticeChannelName,
alarmFire/PreNoticeChannelDescription and openFolder/openRecording
chooser keys across all 13 locales (reusing snoozeAction, stopAlarmAction,
skipNextAction, snoozeAgainAction). Rewrites the template test around
setNotificationStrings. Kotlin is static-reviewed only; no Android build
environment available here.