Author SHA1 Message Date
FreeTLab 35df016aa3 docs(sdd): add technical design for the functional redesign 2026-07-28 18:14:07 +02:00
FreeTLab 433373ad7f docs(sdd): add delta specs for the functional redesign 2026-07-28 18:06:46 +02:00
FreeTLab f58cf8739f docs(sdd): add functional redesign proposal 2026-07-28 18:02:07 +02:00
FreeTLab 675b7fb4b7 docs(design): add Claude Design handoff bundle for the functional redesign 2026-07-28 15:58:39 +02:00
FreeTLab 17f8e69529 chore: bump version to 1.2.0+122 before the functional redesign 2026-07-28 15:58:32 +02:00
FreeTLab b183b3f3e5 fix(eq): stop the enable toggle from landing behind a disk write
cambiarActivo persisted BEFORE telling the audio engine, so two quick taps
raced on a SharedPreferences write. When the first write resolved last, the
engine received the FIRST tap's value after the second one: the checkbox read
enabled while the sound stayed flat, and toggling again could invert it the
other way. Reported as the equalizer connecting and disconnecting at random
and the checkbox disagreeing with what is audible.

Reorder to engine first, disk last. The engine call is now issued before any
await, so overlapping taps reach it in tap order and the last tap wins. Each
subsequent step re-checks _activo, so a call that a newer tap superseded
mid-flight neither applies a preset nor persists a value the user has already
changed their mind about. Persisting last also puts what the user HEARS ahead
of what is merely stored.

The regression test drives two opposite taps through a persistence fake whose
FIRST write is the slow one — the exact ordering hazard — and asserts the
engine ends matching the state the UI shows. It fails on the previous
ordering and passes on this one.

An earlier attempt serialized every engine mutation through a shared Future
lane. It fixed this case and deadlocked four widget tests: the lane field
outlived a tester.runAsync block, so a future created in the real async zone
was later chained from the fake-async zone that never advances it. Reverted
in favour of the ordering fix, which needs no cross-zone state.

Only the enable toggle is addressed here. The other reported symptom —
equalization seeming to come and go while playing — is not explained by this
race and is still open; the handler rebuilds the whole AndroidEqualizer on
every player recreation, which is the next place to look.
2026-07-28 13:32:57 +02:00
ShanaiaBot c8f6162deb chore: bump version to 1.1.16+121 [ci skip] 2026-07-27 15:51:17 +02:00
FreeTLab 2403da3c2e refactor(auto): drop the in-car equalizer, keep EQ on the phone
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
The car tree carried a whole equalizer feature: an `Ecualizador` root folder
with the six factory presets, a browsable `Personalizado` folder, five band
folders and thirteen gain leaves each, plus the media-id namespaces, routing
predicates, persistence-targeting and children-changed plumbing that fed
them. Equalization is a phone task; the driver should not be tuning 5 bands
from a car screen.

Removed: the `eq_preset:`/`eq_banda:`/`eq_gain:` namespaces and their
predicates and parsers, the `ecualizador`/`eq_custom` folder ids and their
getChildren branches, itemPresetEq/presetsEq/itemEqPersonalizado/bandasEq/
gananciasBandaEq, resolverPresetEq, presetPersonalizadoEfectivo,
dispositivoDestinoEq, debeAplicarPrincipalAhora/debeAplicarSeleccionAhora,
aplicarPresetPorMediaId, aplicarGananciaPorMediaId, and in the handler the
playFromMediaId branches, _presetPersonalizadoAuto, _dispositivoActivoAuto,
_dispositivoDestinoEqAuto and the subscribeToChildren/_hijosSubjects
notification machinery that existed only to refresh band titles after a gain
tap.

Deliberately KEPT: automatic per-device EQ. Reaching the car still applies
that device's preset, because that lives in EstadoEcualizador and the
output-device detection, not in this tree — it works with Android Auto or
without it. Configuring is what moves to the phone; applying stays automatic.

Also kept: the `eq_preset_*_v1` SharedPreferences keys in
ServicioEcualizador, which share a name with the deleted media-id prefix by
coincidence only and hold the phone's own presets.

The root folder set goes from five entries to four (three without local
music); its test now asserts no equalizer folder is offered at all, so a
reintroduction has to be deliberate.
2026-07-27 15:50:24 +02:00
ShanaiaBot d12dd49afe chore: bump version to 1.1.15+120 [ci skip] 2026-07-26 01:26:53 +02:00
FreeTLab 9b8209ac93 fix(radio): discover live API mirrors instead of hardcoding dead ones
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m34s
Two of the three Radio Browser hosts this client shipped no longer resolve.
The retry loop rotates de1 -> nl1 -> at1, so once the first attempt failed
for any transient reason the remaining two were guaranteed to fail as well:
the retries meant to add resilience had become a dead end, and a single blip
surfaced as "No connection to the radio API" with a healthy API and a healthy
network. The live mirror list confirms only one server remains:

  [{"ip":"91.98.4.78","name":"de1.api.radio-browser.info"},
   {"ip":"2a01:4f8:1c1d:699::1","name":"de1.api.radio-browser.info"}]

The API docs say exactly what this code was doing wrong: "Never use a direct
link to a single new server. It is much better to get a list of the servers",
pointing clients at all.api.radio-browser.info to enumerate what exists.

Seed with that round-robin host plus de1, then resolve the real list from
/json/servers once per instance and rotate over that. Discovery shares one
in-flight request across concurrent callers, because the home screen loads
two lists at once through Future.wait, and any failure silently leaves the
seed list in place — it still contains a working host, so a failed discovery
must never be worse than not trying. Explicitly injected servers disable
discovery so callers can still pin a mirror.

Build the User-Agent from the running package too. The API asks clients to
identify themselves, and this header claimed PluriWave/0.1.0 while the app
shipped 1.1.x. A literal cannot stay correct here — CI bumps the version on
every single release — so read it via package_info_plus, already a dependency
used in three other places. If package info is unavailable the product name
goes out alone rather than a made-up version, and resolution never throws: a
header must not be able to fail a request.
2026-07-26 01:26:10 +02:00
ShanaiaBot 30f4445235 chore: bump version to 1.1.14+119 [ci skip] 2026-07-25 20:44:26 +02:00
FreeTLab 4042cf5ffd fix(eq): stop the phone's FM sink from posing as the active output
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
Regression from the previous commit. Ranking every AudioDeviceInfo type this
build does not name individually ABOVE the built-in speaker was meant to let
a car stereo on LE Audio or an automotive bus win. It also promoted the
internal sinks a phone exposes permanently: on the Xiaomi test device
AudioManager reports TYPE_FM (14) as an output, so getActiveAudioDevice
picked it over the real speaker with nothing connected at all. Confirmed on
device:

  audio_devices.onListen -> {id=other:14:4, type=14, name=2412DPC0AG}

It then reached Dart under an `other:14:4` id whose type is neither the base
speaker nor a known one, slipped past the collision guard and had a preset
row persisted for it -- reinstating the exact symptom this series set out to
kill: a permanent green active-output dot on a device that was not connected.

Replace the deny-by-omission ranking with an explicit allow list of outputs a
user actually connects. The built-in speaker sits below all of them and above
everything else, so any sink that physically exists but is never where media
plays (TYPE_FM, TYPE_BUILTIN_SPEAKER_SAFE, telephony, remote submix) can no
longer be selected. A one-time purge clears the `other:` rows the bad build
persisted; genuine ones re-register on their next connection.

Fix the USB type constant while here: TYPE_USB_HEADSET is 22, not 14, and 14
is TYPE_FM. The Kotlin USB branch hardcoded 14 and the Dart type table
mirrored the same mistake, so the two cancelled out for real USB headsets
while making a phone's own FM sink decode as USB audio. Both now use 22.

Verified with javap against android.jar (android-36) rather than trusting the
comment that introduced the error.
2026-07-25 20:43:47 +02:00
ShanaiaBot c94bc3d770 chore: bump version to 1.1.13+118 [ci skip] 2026-07-25 17:03:39 +02:00
FreeTLab 4f91f490b8 feat(eq): name Bluetooth devices from the system pairing list
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m34s
A Bluetooth device only reports its own name through
AudioDeviceInfo.productName while it is enumerated as an active output, i.e.
while it is connected. Paired-but-switched-off devices therefore had no name
to fall back on, and the platform-name cache is in-memory only by design
(bt-device-identity ADR-4), so it self-heals per session ONLY for whatever
happens to be connected. Every other device showed its raw id.

Android already knows those names: BluetoothAdapter.getBondedDevices() lists
every pairing with its name and MAC, connected or not, and nothing in this
app was asking. Read it and seed the platform-name cache from it, keyed
bt_a2dp:<uppercase MAC> to match the ids the audio layer emits.

Seeded BEFORE the active-device query so a live enumeration name, being the
fresher of the two, still wins; a user's custom name outranks both. Re-read
on refrescarDispositivoActual so pairing or renaming a device in system
settings shows up as soon as the list becomes visible.

Reading the bond list is gated by BLUETOOTH_CONNECT from API 31 and by the
legacy BLUETOOTH permission below it, so declare the latter with
maxSdkVersion 30. It is a normal permission: granted at install, no runtime
prompt, no new friction. When the answer is unavailable — permission denied,
no adapter, Bluetooth off — both layers return an empty map rather than
throwing, and the row degrades to the id exactly as before.

Does not help rows persisted under a bt_a2dp:name: placeholder id: those
never had a MAC to match against.
2026-07-25 17:01:40 +02:00
ShanaiaBot 2391fb767b chore: bump version to 1.1.12+117 [ci skip] 2026-07-25 16:11:27 +02:00
FreeTLab 39ead7bea4 fix(eq): stop the phone speaker from impersonating a Bluetooth device
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
deviceToMap handed the builtin_speaker id to EVERY output type its `when`
did not name. A car stereo on LE Audio (TYPE_BLE_HEADSET) or an automotive
bus (TYPE_BUS) therefore arrived in Dart under the phone speaker's own id,
carrying a type that maps to `desconocido` -- which slipped past the
type-only esBase guard and persisted a device entry keyed builtin_speaker.
From that moment on, every playback through the phone's own speaker matched
that entry, so the green active-output dot stayed pinned to whatever the user
had renamed it to (a car, in the reported case) whether or not anything was
connected. The dot was never wrong; the row was poisoned.

Give unnamed output types their own `other:<type>:<address>` id namespace,
and match esBase by id as well as by type so no future native regression can
re-create the collision. A guarded one-time migration purges what the
collision already persisted from all three device-keyed maps.

Fix the ranking too: builtin_speaker sat inside the priority list as a peer,
so any type absent from that list sorted BELOW the always-present speaker
and could never win. The speaker is now the explicit last resort, externally
connected outputs outrank it, and virtual or call-only sinks (earpiece,
telephony, remote submix, SCO) are ranked below it so they can never be
reported as where music is playing.

Route every AudioDeviceInfo.getAddress read through a version-guarded
helper. It is API 28 with minSdk 24, and two pre-existing unguarded calls in
this same method were latent NoSuchMethodError crashes on Android 7-8.1.
Android lint for :app goes from 8 errors to 6.

Also lets the user manage the list, which is how they recover from a bad
entry without waiting for a release: a remove action clears a device's
preset, name and matrix entries, unnamed rows show their transport and
address tail instead of a raw bt_a2dp:AA:BB:... id, and the green dot
finally carries a tooltip and a semantics label saying what it means.

Device QA pending for wired and USB outputs: no jack or adapter available to
exercise those paths. Their detection is unchanged by this commit.
2026-07-25 16:10:36 +02:00
ShanaiaBot eee2ae98d0 chore: bump version to 1.1.11+116 [ci skip] 2026-07-25 15:07:54 +02:00
FreeTLab 1e33a79724 fix(recordings): open the recordings folder from the system file manager
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
The recordings live in app-private storage (<data>/app_flutter/grabaciones),
which the Android sandbox forbids any other app from reading, so no
ACTION_VIEW on a file:// or FileProvider URI could ever open it. On top of
that, viewDirectory built an EMPTY candidate list for that path:
directoryDocumentUri returned null (path outside external storage) and
FileProvider.getUriForFile threw because pluriwave_file_paths.xml never
covered app_flutter. The loop never ran, so both entry points -- the radio
recorder and Settings -- always showed "could not open the folder".

Publish the folder as a browsable storage root via
RecordingsDocumentsProvider instead. The files never leave private storage;
the document framework asks us for them one document at a time, and the user
can browse, copy out, rename and delete straight from the file manager. The
root follows a user-configured path and falls back to the default recordings
directory. Its title reuses the already-translated recordingsFolderTitle, so
no new literal is introduced in any of the 13 locales.

Also fixes "open last recording", broken by the same missing FileProvider
root, and replaces Intent.createChooser with a bare startActivity in the
candidate loop: a chooser never throws when nothing can handle the intent, so
the first candidate always "succeeded" and the fallback chain never ran.

Device QA pending -- the provider is driven entirely by the platform's
document framework, so no unit test covers it. Each candidate logs its own
name under file_actions.viewDirectory for logcat triage.
2026-07-25 15:07:10 +02:00
ShanaiaBot 321362b1bf chore: bump version to 1.1.10+115 [ci skip] 2026-07-25 13:52:58 +02:00
FreeTLab d0abe32eef fix(audio): survive audio_service init hang on Android Auto cold start
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
AudioService.init has no internal timeout and an unhandled
onConnectionSuspended case in its MediaBrowser self-bind; under bind
contention with the car's connection it can hang forever, so runApp
never ran (black car screen, white phone UI until process kill).

Race init against an 8s timeout without ever re-calling it: on timeout
run a bootstrap app that waits on the same future, wires the handler
exactly once when it resolves, reports errors via FlutterError, and
swaps to the real app. Auto browse sources now register before the
init await since they take no handler dependency.
2026-07-25 13:43:40 +02:00
ShanaiaBot 37dee8cb5a chore: bump version to 1.1.9+114 [ci skip] 2026-07-24 11:17:21 +02:00
Javier Bautista Fernández 87acfae069 fix(alarm): declare pendingMissedIntent nullable to stop STOP_NATIVE NPE
Build & Deploy PluriWave / Análisis de código (push) Successful in 31s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m27s
cancelAutoSilence calls pendingMissedIntent with FLAG_NO_CREATE, the
one Android flag whose entire purpose is to make getBroadcast() return
null on no match. The Kotlin signature declared a non-null return type,
so the compiler-inserted assertion threw NPE inside onStartCommand,
crashing the process before the Dart-side postpone flow could reach
its actual +N-minute reschedule call.
2026-07-24 11:16:36 +02:00
ShanaiaBot f950da789a chore: bump version to 1.1.8+113 [ci skip] 2026-07-23 00:02:15 +02:00
Javier Bautista Fernández 3d48da77f5 docs(sdd): add alarm-system-overhaul verify report (PASS WITH WARNINGS, device QA pending)
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
2026-07-23 00:01:39 +02:00
ShanaiaBot c3b7a302e7 chore: bump version to 1.1.7+112 [ci skip] 2026-07-22 23:53:17 +02:00
Javier Bautista Fernández 29f7d54e85 fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes,
only uninstall silenced it) plus systematic hardening of every stop path.

Native (Kotlin):
- Verified stop: stopActiveAlarm now derives its result from the real
  post-teardown state (companion instance + synchronous stopEverything +
  activeRingingId check) instead of reporting unconditional success.
- Atomic teardown: every stop path (stop action, notification button,
  snooze, missed, onDestroy, startForeground failure) funnels through one
  stopEverything() covering audio, wakelock, notification, foreground
  state and firing-record cleanup; player.release() guarded.
- Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a
  FIRED->MISSED transition with a localized missed-alarm notification;
  repeating alarms keep their native rearm, deleted alarms never produce
  ghost MISSED notifications.
- Durable firing record with onStartCommand re-validation (resurrection
  guard) and boot-time stale cleanup; firing records cleared on every
  refuse/mismatch/cancel path.
- New notification-only dismissal channel (dismissAlarmNotificationOnly)
  so UI-level dedup can never kill a live ring's audio.

Flutter (Dart):
- Stop/disable/edit/delete of a ringing alarm always attempt to silence
  it; on native-query failure the stop falls back toward silence via the
  id-scoped legacy stop.
- Verified-stop results surface failures: the ringing screen keeps
  dismiss-by-design on success, but on a verified failure it stays up
  with a persistent force-stop banner (guarded against double-dismiss)
  and auto-dismisses if the ring ends externally (missed/notification).
- Missed events sync alarm bookkeeping without opening the ringing UI.
- 4 new l10n keys translated across all 13 locales (ARB guard green).

550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds
(2 deterministic + 1 refuter-corroborated critical fixed); formal
gentle-ai receipt waived by maintainer authorization (correction scope
legitimately exceeded the frozen genesis paths). On-device QA checklist
in openspec/changes/alarm-system-overhaul/tasks.md pending before
archive.
2026-07-22 23:52:36 +02:00
ShanaiaBot 0f9a6a1719 chore: bump version to 1.1.6+111 [ci skip] 2026-07-22 10:26:48 +02:00
Javier Bautista Fernández 163ff69f7a feat(eq): android auto custom equalizer and robust device detection
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s
- MainActivity: onListen re-emits the current active device and registers
  the audio device callback idempotently, so recreated activities resync
  instead of freezing the active-device id on a disconnected device.
- servicio_dispositivo_audio: resubscribir() re-opens the event channel;
  estado_ecualizador exposes refrescarDispositivoActual() with an
  in-flight guard, invoked on app resume and when opening advanced EQ
  options, clearing stale green-dot device selections.
- navegacion_auto/servicio_audio: new 'Personalizado' browse tree in
  Android Auto (5 band folders, 13 gain steps each) applied live via
  setBanda; preset and gain taps persist at device level when
  multi-device EQ is active and respect station/matrix overrides,
  with apply-before-persist ordering and children-changed notifications.
- l10n: regenerate stale generated localizations; add rxdart as direct
  dependency for the subscribeToChildren override.
2026-07-22 10:26:02 +02:00
ShanaiaBot 3473034b58 chore: bump version to 1.1.5+110 [ci skip] 2026-07-21 10:05:18 +02:00
Javier Bautista Fernández 90b75c1825 chore(l10n): add CI guard against ARB placeholder corruption
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
Adds tool/check_arb_placeholder_corruption.py, a static check that flags
literal "?" glued to an ICU placeholder brace in any lib/l10n/app_*.arb
value that has a placeholders metadata block. This is the exact corruption
shape fixed in the previous commit; flutter analyze doesn't catch it since
the JSON/ICU stays syntactically valid. Wired as a CI step before
flutter analyze so it fails fast.

Also audited lib/l10n/app_localizations_ext.dart (hand-maintained weekday/
month/date-sentence maps, not covered by ARB tooling): all 22 locale maps
have the full 13/13 keys with no corruption or leftover English — no
changes needed there.
2026-07-21 10:04:41 +02:00
ShanaiaBot 689a386403 chore: bump version to 1.1.4+109 [ci skip] 2026-07-21 09:56:59 +02:00
Javier Bautista Fernández fb7fe8774b fix(l10n): repair corrupted duration abbreviations in ar/bn/hi/ja/ru/zh ARB files
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
Sleep-timer duration strings (durationHoursMinutesSeconds, durationMinutesSeconds,
durationMinutesOnly, durationSecondsOnly) contained literal "?" characters instead
of the native hour/minute/second abbreviation in 6 locales. Replaced with correct
native short-form units per locale, verified byte-exact against a pinned spec table
and against the already-correct neighboring hoursLabel/minutesLabel/secondsLabel
values in each file.
2026-07-21 09:56:18 +02:00
ShanaiaBot 12967e9894 chore: bump version to 1.1.3+108 [ci skip] 2026-07-20 12:04:33 +02:00
Javier Bautista Fernández 7daa6cfdb6 fix(auto): clear stuck bluetooth EQ selection on device disconnect
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m40s
The advanced EQ device list kept the green-dot selection on the last
connected Bluetooth device after it disconnected, instead of falling
back to the default preset.

- MainActivity.kt: onAudioDevicesRemoved recomputed the active output
  device via AudioManager.getDevices(), which can still momentarily
  report the just-removed sink (observed on Bluetooth A2DP). Removed
  device ids are now excluded explicitly instead of trusting
  getDevices() to already be current.
- estado_ecualizador.dart: cambiarMultiDeviceEnabled() re-seeds
  dispositivoActualId from a fresh query when the toggle turns back
  on, matching cargarPersistido(), so a stale id from before the
  toggle flip can't leave the dot pinned to a disconnected device.
2026-07-20 12:03:59 +02:00
ShanaiaBot 09e0216874 chore: bump version to 1.1.2+107 [ci skip] 2026-07-20 10:22:52 +02:00
Javier Bautista Fernández 2a5030431b fix(android): break literal /* in KDoc that unclosed the comment
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m15s
Kotlin nests block comments, so the literal `audio/*` text inside a
KDoc comment opened a phantom nested comment. The closing */ two
lines later closed that nested one instead, leaving the real KDoc
open for the rest of the file and breaking release compilation.
2026-07-20 10:22:10 +02:00
ShanaiaBot dca3a1107e chore: bump version to 1.1.1+106 [ci skip] 2026-07-20 01:16:35 +02:00
FreeTLab 49def4b276 docs(openspec): archive android-auto-local-music-phase3
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 1m18s
Merges its delta requirements into the android-auto-media base spec.
Completes the 3-phase local-music-in-Android-Auto feature. Standing
pre-release gate: on-device/DHU validation of every native surface
built across all 4 phases (SAF picking, metadata extraction, art
cache, queue/shuffle handler wiring) is still outstanding.
2026-07-20 01:15:46 +02:00
FreeTLab dfd40ca937 feat(auto): queue playback and shuffle for local music folders [size:exception]
Adds "Reproducir carpeta" (sequential) and "Reproducir aleatorio"
(Fisher-Yates over the name-sorted order) as folder-scoped playable
actions, with auto-advance on track completion and skip next/prev.

Isolation from live radio is structural, not disciplinary: the
public playMediaItem always clears the local queue on any call, and
a new private _encolarCambioFuente is the only path that can advance
within it. _cambiarFuente, ControladorReconexion, and the reconnect
error path are untouched -- confirmed by a byte-for-byte empty diff
on all 4 pre-existing radio/reconnect regression suites, independently
re-run before and after (21/21 both times).

Handler wiring itself is static-review-only (PluriWaveAudioHandler
can't be unit-instantiated); the isolation/advance/race-guard
decision logic is extracted into cola_local.dart's pure functions,
which are fully unit-tested.
2026-07-20 01:08:15 +02:00
FreeTLab 85dd043cd4 docs(openspec): archive android-auto-local-music-phase2
Merges its delta requirements into the android-auto-media base spec.
Completes Phase 2; only Phase 3 (subfolder scoping, shuffle) remains.
On-device validation of the native metadata path is still an
outstanding pre-release gate across all local-music phases shipped
today.
2026-07-19 23:59:21 +02:00
FreeTLab 352eb9fc37 feat(auto): real metadata, quality sort and name buckets for local music [size:exception]
Local tracks now show embedded title/artist/album art (via native
MediaMetadataRetriever, cached through the existing FileProvider)
instead of the raw filename, falling back gracefully when a file
has no usable tags. Adds two navigable entry points per folder: sort
by audio quality (bitrate, capped at 150 tracks per folder to bound
worst-case latency) and alphabetical name buckets -- the closest
realistic form of "filtering" given Android Auto has no text-search
UI in this integration.

Metadata resolves only for the page actually being browsed (same
slice-cheap-then-map discipline as the paging change), backed by a
flat 256-entry LRU session cache that survives across pages. No new
permission, no new pub dependency, no l10n changes (car-tree labels
stay hardcoded Spanish, matching every existing label in the tree).
2026-07-19 23:52:08 +02:00
FreeTLab e030a0975d docs(openspec): archive android-auto-local-music-paging
Merges its delta requirements into the android-auto-media base spec.
This closes out Phase-1 polish for local music; Phase 2 (metadata,
sort/filter, real art) and Phase 3 (subfolder scoping, shuffle)
remain the only planned future work for this feature.
2026-07-19 22:21:45 +02:00
FreeTLab 725169cd31 feat(auto): page local-music folders instead of truncating at 50 [size:exception]
Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
2026-07-19 22:14:05 +02:00
FreeTLab 977cbcd8cc docs(openspec): archive android-auto-local-music Phase 1
Merges its delta requirements into the android-auto-media base spec.
Phases 2 (metadata/sort/filter/art) and 3 (subfolder scoping/shuffle)
remain planned future work.
2026-07-19 20:37:54 +02:00
FreeTLab 6ae7e378c4 feat(auto): browse and play local music folders in Android Auto [size:exception]
Phase 1: pick a device folder via SAF (persisted grant, no new
permission), browse its nested subfolders/tracks as a 5th Android
Auto root folder (hidden until configured), and play tracks through
the existing pipeline (EQ, art rotation, cold-start-safe source).
No metadata/sort/filter/shuffle yet -- filename is the title, generic
rotating art is the placeholder; deferred to a follow-up phase.

Adds a new pluriwave/file_actions native method (listAudioChildren)
and an onActivityResult override in MainActivity for the SAF folder
picker -- both static-review-only, no Android build available here.
2026-07-19 20:30:50 +02:00
FreeTLab 99897ec848 chore(release): open 1.1.0 development line [version set]
Marks the start of the local-folder music playback feature work.
2026-07-19 18:34:21 +02:00
FreeTLab 9bfa9ac408 docs(openspec): archive android-auto-eq-presets
Merges its delta requirements into the android-auto-media base spec.
2026-07-19 14:19:11 +02:00
FreeTLab 90cd232ad2 feat(auto): expose EQ presets as a browsable Android Auto folder
Adds an Ecualizador folder listing the 6 fixed presets; selecting one
applies and persists it through the existing headless-safe seam
without touching playback or the now-playing media item.
2026-07-19 14:12:39 +02:00
FreeTLab 066fedb7bc docs(openspec): archive android-auto-favorite-groups
Merges its delta requirements into the android-auto-media base spec.
2026-07-19 13:47:39 +02:00
FreeTLab f368bcc777 feat(auto): surface favorite groups as Android Auto sub-folders
Favoritos now renders non-empty custom groups as grupo:<id>
sub-folders (hidden when empty) with ungrouped stations left as
direct leaves, reusing the existing hijos() path so the zero-groups
case stays byte-identical to today's flat list.
2026-07-19 13:42:25 +02:00
FreeTLab f9003436ea docs(openspec): archive android-auto-media and auto-media-art-quality
Promotes the android-auto-media capability spec to openspec/specs/
and moves both completed changes into openspec/changes/archive/.
2026-07-19 13:22:18 +02:00
FreeTLab c193650cc4 fix(auto): fall back to brand art and surface quality on dead/missing favicons
Android Auto no longer copies the launcher icon as placeholder art; it
rotates through the same 4 on-brand station_art assets the phone UI
already uses, keyed by the same per-station hash for visual parity.
Malformed or unusable favicon URLs (including a Dart Uri quirk where
'http://' reports hasAuthority=true with an empty host) now fail the
validity gate instead of being handed to the OS media browser as-is.
Browsable items also show codec/bitrate as a subtitle when known.
2026-07-19 13:06:18 +02:00
ShanaiaBot 08cae2a5d4 chore: bump version to 1.0.1+105 [ci skip] 2026-07-16 16:29:34 +02:00
Javier Bautista Fernández 07c6e32af0 docs(auto): android auto research guide and sdd artifacts for android-auto-media
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
2026-07-16 16:28:54 +02:00
Javier Bautista Fernández 35bb180612 feat(auto): browsable Android Auto media tree with play-by-id [size:exception]
Expose PluriWave to Android Auto (projected) as a media app:

- Declare car media support (automotive_app_desc.xml + manifest meta-data)
  so Android Auto discovers the existing MediaBrowserService.
- New navegacion_auto.dart: ConstructorArbolAuto builds the browse tree
  (Favoritos / Todas las emisoras / Mis emisoras, 50-item cap, stable
  emisora:<id> media ids), reproducirPorMediaId routes a car tap to the
  existing playMediaItem pipeline, FuenteEmisorasAutoLocal serves the tree
  cold-start-safe (local favorites/custom stations before Flutter UI runs).
- PluriWaveAudioHandler overrides getChildren/getMediaItem/playFromMediaId
  as thin delegations; playback pipeline untouched.
- EstadoRadio pushes live station snapshots to the browse source and
  reconciles the selected station when playback starts from the car.
- Every playable item ships title + artUri; stations without logo fall
  back to a bundled default art (android.resource://).

Tests: 52/52 green (10 new navegacion_auto, 2 new estado_radio, plus
audio safety-net suites). Handler overrides and native XML are
static-review-only (no Android build env). Size exception approved for a
single reviewable commit.
2026-07-16 16:28:44 +02:00
ShanaiaBot 43781274ce chore: bump version to 1.0.0+104 [ci skip] 2026-07-15 15:44:16 +02:00
Javier Bautista Fernández 03de273369 chore(release): promote to 1.0.0 stable [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
The app is stable, so mark this as the 1.0.0 milestone. The CI bump step
now honors a [version set] marker: when present it ships the pinned semver
as-is and only advances the build number (Play requires it monotonic),
instead of the automatic patch bump that cannot cross the 0.x -> 1.0.0
boundary. Normal commits keep auto-incrementing the patch (1.0.1, 1.0.2, ...).
2026-07-15 15:43:16 +02:00
ShanaiaBot f1bb54d25a chore: bump version to 0.1.102+103 [ci skip] 2026-07-14 15:19:47 +02:00
Javier Bautista Fernández 38d78fc4f8 fix(alarm): honor the persisted trigger on reschedule so app updates stop re-arming the wrong day
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
reschedulePersistedAlarms re-armed every stored alarm through the native
recompute engine (trustDartTrigger defaulted to false). That engine can
diverge from Dart's next-occurrence verdict and arm the alarm for the
wrong day, so it silently never fires. Because this runs on boot, unlock
and ACTION_MY_PACKAGE_REPLACED (which fires on every app install), each
new build re-broke correctly-armed alarms while snooze kept working
(snooze never touches the recompute for its trigger value).

Trust the persisted trigger (Dart's own verdict, saved when the alarm was
last armed) whenever it is still in the future; a genuinely stale past
trigger still falls back to the native recompute inside scheduleSpec.
2026-07-14 15:18:38 +02:00
ShanaiaBot 448fbec354 chore: bump version to 0.1.101+102 [ci skip] 2026-07-12 23:33:36 +02:00
FreeTLab d8e67a5204 fix(alarm): make all date math wall-clock correct across DST and timezone changes
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m46s
Full time-domain audit (three shipped date bugs prompted it) found one
root cause and two latent travel defects, all now fixed:

Day-stepping used add(Duration(days: 1)), which shifts the absolute
instant by exactly 86400s — documented Dart behavior (sdk#47666), so
crossing a DST transition drifted the wall hour by +-1h permanently
for the rest of the candidate scan (verified: 2026-03-28 07:30
Europe/Madrid + "1 day" = 08:30). The native Calendar engine preserves
wall time, and the single-authority fix made the drifted Dart verdict
win. Candidates now advance by calendar reconstruction (_siguienteDia:
DateTime(y, m, d+1, hora, minuto)), the same wall-clock-preserving
semantics as Calendar.add(DAY_OF_YEAR, 1) plus AOSP DeskClock's
defensive hour/minute re-assertion, keeping both engines in agreement
through any transition.

Instant-valued fields (snoozeHasta/snoozeOrigen/proximaEjecucion/
ultimaEjecucionGestionada/creadaEn/actualizadaEn) serialized as
offset-less local ISO, so re-parsing after a device timezone change
reinterpreted the same wall fields as a different instant. They now
serialize as UTC ("Z"); reads normalize to local, and legacy
offset-less payloads parse identically — no migration. fechaUnica
stays local on purpose: it is a wall-clock date.

One-shot alarms sent fechaUnica's midnight epoch to the native side,
whose boot/travel re-arm derives the calendar day back from it in the
CURRENT zone — a westward shift rolled the date to the previous day.
The channel now anchors the date at local noon, keeping it stable
across real-world zone shifts.

Property tests lock the no-drift guarantee (400 daily / 200 weekday
iterations must all land exactly at hora:minuto — on DST-observing
dev machines this crosses real transitions), plus UTC round-trip,
legacy-payload compatibility, and wall-date preservation tests.
2026-07-12 23:32:32 +02:00
ShanaiaBot e84cd2d7ed chore: bump version to 0.1.100+101 [ci skip] 2026-07-12 23:18:26 +02:00
FreeTLab 9c7cf4e261 fix(alarm): anchor snooze to the ringing occurrence, never a future one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
posponerAlarma anchored the snooze to snoozeOrigen ?? proximaEjecucion,
but once the native fire path works, the fire-time sync records the
handled occurrence and recalculation advances proximaEjecucion to the
NEXT day before the user can even tap snooze on the still-ringing
screen. "Posponer 3" therefore armed the snooze a full day out
(captured on-device: snoozeCountdown remaining=1443 minutes). The bug
was invisible before because the broken delivery path never advanced
proximaEjecucion while ringing — each fix unmasked the next.

The anchor is now the newest occurrence that is not meaningfully in
the future (shared 90s imminence window): snoozeOrigen for re-snoozes,
proximaEjecucion on the watchdog path where it is still today's
just-due occurrence, ultimaEjecucionGestionada on the native-fire path
where the sync recorded the ringing occurrence, then now. ServicioAlarmas
exposes ahora() so the anchor uses the same injectable clock as the
rest of the scheduling math. Test fixtures that snoozed half an hour
before the ring — a state the ringing screen can never be in, since it
is posponerAlarma's only production caller — now move the clock to
ring time, preserving their original expectations.
2026-07-12 23:17:26 +02:00
ShanaiaBot 597c98d0e7 chore: bump version to 0.1.99+100 [ci skip] 2026-07-12 23:07:11 +02:00
FreeTLab 8741cdba5f fix(alarm): honor Dart's next-occurrence verdict on fresh schedule calls
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m32s
The system ran two independent next-occurrence engines: Dart computes
proximaProgramable (what the UI shows) and sends it as triggerAtMillis,
but the native scheduleAlarm discarded it and recomputed from
hour/minute/weekdays. Two engines over the same data WILL diverge —
observed on-device: Dart said "today 22:48", the native weekday scan
armed next Friday, and the alarm silently never rang at its hour while
snooze (which bypasses recomputation and obeys a timestamp) always
worked. That asymmetry was the user-visible "saving an alarm breaks,
snoozing works" split.

Fresh channel calls now arm exactly the trigger Dart sent whenever it
is in the future or within the shared 90s imminence window; the native
recompute remains as the fallback for stale triggers and for
autonomous re-arms with no fresh Dart data (onAlarmFired's next
occurrence, boot/persisted reschedules). Snooze preservation is
untouched: a live native snooze still short-circuits through the
compute path. Also logs the weekdays/trigger/lastHandled payload on
every schedule call so day-convention divergences are diagnosable
from logcat.
2026-07-12 23:05:56 +02:00
ShanaiaBot 3fd5080cd1 chore: bump version to 0.1.98+99 [ci skip] 2026-07-12 12:37:23 +02:00
FreeTLab 41b95fed44 docs(openspec): archive native-alarm-ring and update the native-alarms spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
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
ShanaiaBot a788eabfcb chore: bump version to 0.1.97+98 [ci skip] 2026-07-12 12:21:42 +02:00
FreeTLab 6f07e27905 fix(alarm): silence the fire channel explicitly instead of by omission
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m51s
Omitting setSound on a NotificationChannel leaves the platform DEFAULT
notification sound active — omission is not silence. The v3 channel
now calls setSound(null, null) exactly like the pre-notice channel
does, so the native STREAM_ALARM player stays the ring's only audible
source. Caught by verification against design D4 before any build.
2026-07-12 12:20:42 +02:00
ShanaiaBot 8a4a8bd5d7 chore: bump version to 0.1.96+97 [ci skip] 2026-07-12 12:03:57 +02:00
FreeTLab 884567beaa feat(alarm): native-only ring with DeskClock fade curve and silent channel
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s
PluriWaveAlarmService becomes the sole ring-audio owner for the whole
ring (WU2 of 2, completes the split started at bd7f883): the linear
step ramp (initialVolume/startFadeIn, 250ms steps) is replaced by a
DeskClock-style exponential dB curve (computeFadeVolume: gainDb =
fraction*40-40, curve = 10^(gainDb/20)) on a 50ms Handler loop anchored
at ring start, not audio start, so all three fallback sources (station,
fallback station, bundled WAV) share one clock and a source that joins
mid-fade enters at the elapsed level instead of restarting from
silence. Each source also recomputes and applies the curve immediately
before start() to stay pop-free through prepareAsync's variable
buffering delay.

The STREAM_MUSIC device-volume override/restore is replaced by manual
AUDIOFOCUS_GAIN_TRANSIENT request/abandon on STREAM_ALARM (no-op focus
listener, requested once per ring in startAudio, abandoned
unconditionally in stopAlarm's full-teardown branch): the service never
calls setStreamVolume on any stream. The fire notification channel
migrates pluriwave_alarm_fire_v2 -> pluriwave_alarm_fire_v3, now silent
(no setSound; native MediaPlayer is the only audible source) while
keeping vibration and IMPORTANCE_HIGH for the full-screen intent; the
migration guard folds in a third delete for the v2 id alongside the two
pre-existing legacy ids, guarded by a renamed channels_migrated_v3 flag
so it still runs exactly once.

Since the Dart ringing screen (WU1) no longer calls confirmFlutterAudio,
overrideMediaVolumeForRing or restoreMediaVolume, their native surface
is now dead: deletes the flutterOwnsRing handoff flag and both its
backstop call sites in PluriWaveAlarmService, and the three
MethodChannel handlers plus their backing methods and companion state
in MainActivity.
2026-07-12 12:02:45 +02:00
ShanaiaBot 5bd861d7fb chore: bump version to 0.1.95+96 [ci skip] 2026-07-12 11:37:53 +02:00
FreeTLab bd7f883118 refactor(alarm): make the ringing screen pure UI over a reduced native port
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m40s
PantallaAlarmaSonando no longer owns any audio orchestration (fallback
player, dB ramp, native handoff confirm, media-volume override/restore):
it only calls EstadoAlarmas.finalizarEjecucion/posponerAlarma from
Stop/Snooze/back, keeping the single-exit guard, PopScope back=Stop and
dismiss semantics intact. The status line now reads directly from the
alarm's static config (station name or a neutral label) instead of a
live playback/handoff state.

PuertoAlarmasAndroid drops confirmarAudioFlutter,
forzarVolumenMediaParaAlarma and restaurarVolumenMedia, and app.dart no
longer pre-starts a station before pushing the ring screen. This is the
Dart half of moving to a single native ring-audio owner (WU1 of 2); the
Kotlin service rebuild lands next and keeps this intermediate state
shippable with no double audio.
2026-07-12 11:36:31 +02:00
ShanaiaBot 836fb44adb chore: bump version to 0.1.94+95 [ci skip] 2026-07-12 00:36:40 +02:00
FreeTLab 2e64740b26 fix(alarm): anchor the fade at alarm time and defer the override to first audio
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".

The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
2026-07-12 00:35:29 +02:00
ShanaiaBot f73a12ad48 chore: bump version to 0.1.93+94 [ci skip] 2026-07-12 00:05:54 +02:00
FreeTLab 86225cbc68 fix(alarm): scope native stop to the ringing id and intercept system back
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Two exit-path holes found by adversarial review before the next build:

PluriWaveAlarmService.stopAlarm() never compared the requested id to
activeAlarmId, so any stop request for a DIFFERENT alarm tore down
whichever ring was active: with two alarms firing close together, the
second one's routine hide-notification call (via dismissAlarmNotification
-> ACTION_STOP) killed the first alarm mid-ring and prematurely restored
the device volume override. A mismatched id now only cancels that id's
notification and returns; null keeps full-teardown semantics for
internal/onDestroy callers.

The ringing screen never intercepted the system back gesture: a plain
route pop ran only dispose(), leaving the shared radio player ringing
with no alarm UI left anywhere to stop it. Back now routes through
PopScope into the same _detener() flow as the Stop button, guarded by a
single-exit flag so a back-press racing a button tap cannot run the
teardown twice and pop the route underneath.

Also resets the shared handler gain to 1.0 on ring exit: the fade-in
mutates the radio player's persistent volume, and exiting mid-ramp used
to leave every later radio play at the partial ramp level.
2026-07-12 00:04:53 +02:00
ShanaiaBot e84a41acd4 chore: bump version to 0.1.92+93 [ci skip] 2026-07-11 23:33:15 +02:00
FreeTLab 1b1b692f04 fix(alarm): apply the configured fraction to the ring volume override
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
overrideMediaVolumeForRing parsed and logged the fraction argument but
called the no-arg override, which always forced STREAM_MUSIC to the
device maximum. On-device logcat confirmed it: the Dart side sent
fraction=0.5 yet the stream was set to index=30 of 30. Combined with
the player now ramping to its full range, the ring peaked at 100% of
the device maximum instead of the configured 50%.

The override now sets the stream to round(max * fraction), clamped to
at least 1 so rounding can never mute the ring. With fraction=0.5 the
stream caps at half the device maximum and the player ramps up to that
cap, so the ring peaks at the configured level and the opening buffer
click drops to the configured fraction rather than full scale.
2026-07-11 23:32:05 +02:00
ShanaiaBot 5f65d068a8 chore: bump version to 0.1.91+92 [ci skip] 2026-07-11 23:03:13 +02:00
FreeTLab 3ebb41aa9d fix(alarm): arm a just-passed occurrence instead of skipping it a day
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m42s
The native next-occurrence recompute required the trigger to be
strictly in the future, while the Dart side keeps an occurrence whose
trigger passed within a 90s tolerance. When the periodic resync
re-armed an alarm microseconds after its trigger (app foregrounded,
the 60s tick straddling the trigger instant), computeNextTriggerMillis
recomputed the next weekday/daily occurrence as tomorrow and, through
the shared FLAG_UPDATE_CURRENT fire PendingIntent, replaced the
in-flight fire before AlarmManager delivered it. The alarm never rang
until the screen was turned on and the Dart watchdog caught it late.

computeNextTriggerMillis now mirrors Dart's toleranciaDisparoInminente:
base is lowered by a 90s grace window so a just-passed occurrence is
armed (and delivered ~immediately) rather than pushed to the next day.
The handledFloor (lastHandledAtMillis + 60s) stays a hard lower bound,
so an already-fired occurrence can never be re-selected — no
double-fire. Dart contract tests lock the boundary the native constant
must track. Native verification is on-device (no JVM test harness).
2026-07-11 23:02:14 +02:00
ShanaiaBot 812922d7f1 chore: bump version to 0.1.90+91 [ci skip] 2026-07-11 22:49:56 +02:00
FreeTLab 5291221fc8 fix(alarm): cap the ring at the configured volume instead of the device max
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
The ring-scoped media override forced STREAM_MUSIC to the hardware
maximum, so the alarm's configured percentage was applied on top of a
maxed speaker: "50%" meant 50% of the phone's absolute maximum and
the fade rode against that ceiling, far louder than the device-
relative level users were used to. On-device logcat also showed the
just_audio player emitting one buffer at volume 1.0 before the 5%
pre-start took effect, a full-scale click on the maxed stream.

The stream is now capped at the alarm's configured volume (still
independent of the device's own level, so it rings at device-volume
0), and the player ramps from ~5% up to its full range under that
cap. Perceived peak is the configured fraction of the device maximum,
reached gradually; the opening click drops to the configured fraction
instead of full scale.
2026-07-11 22:48:56 +02:00
ShanaiaBot 2c28f1696a chore: bump version to 0.1.89+90 [ci skip] 2026-07-11 22:27:25 +02:00
FreeTLab b294b287c7 fix(alarm): stop a duplicate fire event from killing the active ring
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m31s
The live eventosAlarma stream and the one-shot obtenerEventoInicial()
both read the same native fire event on cold start, so the same alarm
id can reach _mostrarAlarmaSonando twice within the same tick. The
second, duplicate delivery correctly detected an alarm was already
active and hit the "ignored" branch — but that branch unconditionally
called ocultarNotificacionAlarma, whose native handler
(dismissAlarmNotification) unconditionally stops
PluriWaveAlarmService for that id.

Confirmed via on-device logcat: the duplicate's stop landed ~180ms
after the ring-scoped media-volume override was captured and ~2.3s
before the real native-to-Flutter handoff, so flutterOwnsRing was
still false and the teardown backstop restored the device's original
volume immediately. The Flutter/radio player kept ringing regardless
(it starts independently of the native service), now anchored to
whatever volume the device happened to be at — explaining both
"ignores the configured ramp" and "plays at the device's own volume."

The ignored branch now only hides the notification when the duplicate
carries a genuinely different alarm id than the one already ringing;
a duplicate of the SAME ring's own event is now a pure no-op.
2026-07-11 22:26:13 +02:00
ShanaiaBot b7af1064cc chore: bump version to 0.1.88+89 [ci skip] 2026-07-11 17:35:37 +02:00
FreeTLab c65497e58a docs(openspec): archive persistence-corruption-guard and promote its spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
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/.
2026-07-11 17:34:36 +02:00
ShanaiaBot 762e740c89 chore: bump version to 0.1.87+88 [ci skip] 2026-07-11 17:21:09 +02:00
FreeTLab 48e74e6bfe fix(radio): treat custom-station path resolution as part of the IO surface
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
_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.
2026-07-11 17:19:44 +02:00
ShanaiaBot d824ef5c21 chore: bump version to 0.1.86+87 [ci skip] 2026-07-11 13:01:16 +02:00
FreeTLab 45b7fc8741 fix(eq): keep valid presets when stored maps are partially corrupt
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
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.
2026-07-11 13:00:15 +02:00
ShanaiaBot 2255c18ce8 chore: bump version to 0.1.85+86 [ci skip] 2026-07-11 12:46:49 +02:00
FreeTLab 13ad736917 fix(radio): quarantine corrupt custom-station files instead of silently emptying them
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
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.
2026-07-11 12:45:52 +02:00
ShanaiaBot a34182fdaf chore: bump version to 0.1.84+85 [ci skip] 2026-07-11 12:28:50 +02:00
FreeTLab 65c1ac2085 fix(alarm): stop corrupt entries and unreadable payloads from wiping saved alarms
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
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.
2026-07-11 12:27:39 +02:00
ShanaiaBot 23ab3494a7 chore: bump version to 0.1.83+84 [ci skip] 2026-07-11 11:09:36 +02:00
FreeTLab 7eaa87b462 fix(alarm): start the fade-in when pre-started audio is already playing
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
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.
2026-07-11 11:08:33 +02:00
ShanaiaBot f6ea4e64b9 chore: bump version to 0.1.82+83 [ci skip] 2026-07-11 10:33:15 +02:00
FreeTLab efbf289f6b docs(openspec): archive alarm-volume-ramp-restore and promote native-alarms spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
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.
2026-07-11 10:32:07 +02:00
ShanaiaBot 0804a612ec chore: bump version to 0.1.81+82 [ci skip] 2026-07-11 10:17:46 +02:00
FreeTLab 79f6f8ef38 fix(alarm): capture alarm state before dispose so its volume restore works
Build & Deploy PluriWave / Análisis de código (push) Successful in 43s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m52s
_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.
2026-07-11 10:16:25 +02:00
ShanaiaBot b9539223de chore: bump version to 0.1.80+81 [ci skip] 2026-07-11 09:58:08 +02:00
FreeTLab 66a19525bd fix(alarm): defer the Dart fade-in until the native handoff confirms
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s
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).
2026-07-11 09:57:06 +02:00
ShanaiaBot a6e1177752 chore: bump version to 0.1.79+80 [ci skip] 2026-07-11 09:17:06 +02:00
FreeTLab acd903d9a8 feat(alarm): make the ring immune to device media volume
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m39s
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).
2026-07-11 09:15:37 +02:00
ShanaiaBot 251d3fd3cd chore: bump version to 0.1.78+79 [ci skip] 2026-07-11 01:23:25 +02:00
FreeTLab 43f61d7c21 docs(openspec): cancel alarm FGS slice, the alarm service type never existed
Build & Deploy PluriWave / Análisis de código (push) Successful in 34s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
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.
2026-07-11 01:22:18 +02:00
ShanaiaBot 39693ce995 chore: bump version to 0.1.77+78 [ci skip] 2026-07-11 01:16:08 +02:00
FreeTLab 159334f997 docs(openspec): archive bt-device-identity and promote its spec
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m44s
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.
2026-07-11 01:15:06 +02:00
ShanaiaBot 41b35c7f44 chore: bump version to 0.1.76+77 [ci skip] 2026-07-11 00:57:31 +02:00
FreeTLab 747738d20a docs(openspec): add SDD artifact trails for bt-device-identity and alarm-volume-ramp-restore
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
In-progress artifact sets from the current SDD cycles: exploration,
proposal, spec, design, tasks, and verify reports as produced so far.
Also drops a leftover working copy of eq-device-disconnect-revert
whose contents were already committed under changes/archive/.
2026-07-11 00:56:22 +02:00
ShanaiaBot 0b18540935 chore: bump version to 0.1.75+76 [ci skip] 2026-07-11 00:54:46 +02:00
FreeTLab 8cca7c3daa test(devices): cover rename surviving a re-pair cycle end to end
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m42s
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.
2026-07-11 00:53:45 +02:00
ShanaiaBot 158203fee9 chore: bump version to 0.1.74+75 [ci skip] 2026-07-11 00:26:07 +02:00
FreeTLab b17c582572 fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
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).
2026-07-11 00:25:00 +02:00
ShanaiaBot 224763bca3 chore: bump version to 0.1.73+74 [ci skip] 2026-07-11 00:03:51 +02:00
FreeTLab aef4e02c1f fix(devices): use real Bluetooth MAC as device identity on Android 12+
Build & Deploy PluriWave / Análisis de código (push) Successful in 46s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m53s
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).
2026-07-11 00:02:38 +02:00
ShanaiaBot d3763eaec5 chore: bump version to 0.1.72+73 [ci skip] 2026-07-10 23:55:15 +02:00
FreeTLab 8f7ca8059b fix(eq): resolve base-speaker preset live instead of pinning a stale copy
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
_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.
2026-07-10 23:54:01 +02:00
ShanaiaBot bfa95a1e57 chore: bump version to 0.1.71+72 [ci skip] 2026-07-10 18:53:22 +02:00
FreeTLab 0ab63731d0 fix(eq): re-apply equalizer when the native audio session rotates
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
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.
2026-07-10 18:51:45 +02:00
ShanaiaBot a31dc07318 chore: bump version to 0.1.70+71 [ci skip] 2026-07-04 12:43:21 +02:00
FreeTLab bccc5c48b8 docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
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.
2026-07-04 12:42:11 +02:00
ShanaiaBot e5b6d8acb3 chore: bump version to 0.1.69+70 [ci skip] 2026-07-02 18:54:39 +02:00
FreeTLab 8f2bf2bdd6 feat(notifications): add branded monochrome icon and color to all notifications
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m28s
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.
2026-07-02 18:53:33 +02:00
ShanaiaBot c78af4b1e8 chore: bump version to 0.1.68+69 [ci skip] 2026-07-02 15:23:08 +02:00
Javier Bautista Fernández 28b663bbe7 fix(alarm): recalculate every alarm on each mutation, not just the touched one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
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.
2026-07-02 15:21:58 +02:00
ShanaiaBot a8a2db4b64 chore: bump version to 0.1.67+68 [ci skip] 2026-07-02 10:53:12 +02:00
Javier Bautista Fernández 7cfde24811 fix(alarm): avoid ClassCastException casting snooze millis to Long
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m29s
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().
2026-07-02 10:52:07 +02:00
ShanaiaBot da9d32849c chore: bump version to 0.1.66+67 [ci skip] 2026-07-01 00:24:18 +02:00
FreeTLab 6acbd7ca93 fix(alarm): handle snooze reschedule failures instead of silently dropping them
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s
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.
2026-07-01 00:22:58 +02:00
ShanaiaBot cc98f3f331 chore: bump version to 0.1.65+66 [ci skip] 2026-06-30 22:11:58 +02:00
FreeTLab 5877c2a4ee feat(alarm): add true per-minute live countdown to pre-notice
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
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.
2026-06-30 22:10:41 +02:00
ShanaiaBot f5cf261f12 chore: bump version to 0.1.64+65 [ci skip] 2026-06-30 15:44:24 +02:00
Javier Bautista Fernández ffd09a2179 i18n(alarm): localize all native notification, channel and chooser texts
Build & Deploy PluriWave / Análisis de código (push) Successful in 40s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
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.
2026-06-30 15:41:29 +02:00
Javier Bautista Fernández 481944815f fix(alarm): unfreeze snooze modal and add per-minute snooze countdown
The snooze button was fire-and-forget without error handling: if
posponerAlarma threw (e.g. native scheduleAlarm returns false on a
device without exact-alarm permission), _dismissScreen never ran. The
stuck modal also kept _alarmaSonandoActiva true, which made the next
ring get ignored. _posponer/_detener now dismiss in a finally and stop
audio defensively.

Add a native, AlarmManager-driven snooze countdown notification that
re-posts every minute ("Rings in N min", 3->2->1) while the engine is
dead. scheduleSpec drives scheduleSnoozeCountdown for snoozes (instead
of the 30-min pre-notice). Localized text and button labels travel
Dart->Kotlin as {minutes} templates, same pattern as preNoticeTemplate.

Notification actions: "snooze again" (snoozeAgain, anchored to now)
reports back via the existing snoozed event; "stop" (cancelSnooze)
records a handled occurrence for cold-start reconciliation and emits a
new snoozeCancelled event handled in EstadoAlarmas.

Adds snoozeCountdown/snoozeAgainAction keys across all 13 locales and a
test for the snoozeCancelled event. Kotlin changes are static-reviewed
only; no Android build environment available here.
2026-06-30 15:27:05 +02:00
ShanaiaBot 89ff6a3912 chore: bump version to 0.1.63+64 [ci skip] 2026-06-28 11:56:21 +02:00
FreeTLab 4ffd73d136 fix(alarm): localize pre-notice countdown and fix snooze dismiss
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
Replace hardcoded Spanish pre-notice text with computed remaining
minutes using l10n template passed via MethodChannel. Fix snooze
dismiss in dead-app state with canPop guard and SystemNavigator.pop
fallback.
2026-06-28 11:55:15 +02:00
FreeTLab 58922de6fc fix(eq): seed device ID at startup and add device management UI
Fix multi-device EQ auto-switching by calling obtenerDispositivoActual()
during cargarPersistido() to seed the initial device ID. Add device
management modal with rename support, EQ preset editing, and connection
status indicator. Translate device UI keys to all 13 locales.
2026-06-28 11:55:15 +02:00
ShanaiaBot 71978de68f chore: bump version to 0.1.62+63 [ci skip] 2026-06-27 12:16:24 +02:00
FreeTLab d1d4afb88f i18n(eq): translate advanced EQ keys to all 11 remaining locales
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
Add advancedEq* translations for ar, bn, de, fr, hi, id, it, ja, pt,
ru, zh — matching register and style conventions of each locale.
2026-06-27 11:38:45 +02:00
FreeTLab 4632d53eb8 feat(eq): add per-device equalizer with 4-level preset resolution
Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.

- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
2026-06-27 11:33:53 +02:00
ShanaiaBot 8f42e67b48 chore: bump version to 0.1.61+62 [ci skip] 2026-06-26 23:05:20 +02:00
FreeTLab f7753c8402 Merge branch 'feat/s6-quality-gates' into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 1m10s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m29s
2026-06-12 00:12:43 +02:00
FreeTLab 8a032e6e62 feat(quality): harden lint rules and add quality-gate tests 2026-06-12 00:05:06 +02:00
FreeTLab 202bef3539 feat(ui): design token discipline, accessibility and i18n pass
- Replace all hardcoded Color literals outside lib/tema with theme tokens (new static brand palette in PluriWaveTokens); media notification uses the brand color instead of the Material default purple
- Favorite button on station cards grows to a 48dp target and becomes an independent semantics node for screen readers (Semantics container fix)
- All flutter_animate call sites route through the PluriAnimate reduced-motion gate (zero direct .animate() left)
- Locale-aware short dates via intl DateFormat (new lib/l10n/formato_fechas.dart) replacing the hardcoded DD/MM/YYYY; proper plural messages for the favorites counter; example stream URL as a localized key - all 13 locales
- Rounded shimmer placeholders matching card radii; shimmer loading state in search instead of a bare spinner; rounded icon variants unified in settings; bottom-sheet conventions on the custom station form
- Fix latent debug crash: vacation editor read AppLocalizations in initState
- 11 new tests (121 total green), flutter analyze clean
2026-06-11 23:42:16 +02:00
FreeTLab 52855e75c2 refactor(state): extract recording and search state, scope screen rebuilds
- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions
- New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter
- New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists
- Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens
- Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration
- 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
2026-06-11 21:43:18 +02:00
FreeTLab 0416b301b2 refactor(state): extract export/import service and equalizer state from EstadoRadio
- New ServicioExportImport owns the v2 backup envelope, pretty JSON encode and graceful decode; byte-compatible with existing exports, locked by a round-trip test
- pantalla_ajustes delegates backup serialization to the service (inline jsonDecode/jsonEncode removed)
- New EstadoEcualizador ChangeNotifier owns all EQ state and persistence (principal/current/per-station presets, active flag), exposed via its own provider so EQ changes no longer rebuild EstadoRadio consumers
- EstadoRadio slims down ~210 lines and keeps 15 delegating compat members marked TODO(S4b) for the next slice to remove
- Player EQ toggle rewired to the new provider to avoid going stale
- 4 new tests (103 total green), flutter analyze clean
2026-06-11 21:16:30 +02:00
FreeTLab 0380bbb1e7 feat(streaming): buffer resilience and automatic reconnection
- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently
- Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code
- Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion
- Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops
- Reset retry budget on station change; route stream timeouts through the network-error class
- 10 new tests (99 total green), flutter analyze clean
2026-06-11 19:54:30 +02:00
FreeTLab 079e19f0ee feat(audio): audio session integration and runtime robustness
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
2026-06-11 16:25:09 +02:00
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
2026-06-11 15:33:30 +02:00
ShanaiaBot b5acf97ba4 chore: bump version to 0.1.60+61 [ci skip] 2026-06-04 16:30:30 +02:00
Javier Bautista Fernández cf9422dff3 Exportar e importar absolutamente toda la información de las preferencias de la aplicación
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
2026-06-04 16:05:58 +02:00
ShanaiaBot 957615dcd6 chore: bump version to 0.1.59+60 [ci skip] 2026-06-03 22:07:12 +02:00
FreeTLab 089b8b4227 fix(i18n): normalize translations and fallbacks
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
2026-06-03 21:20:08 +02:00
ShanaiaBot a5475ce118 chore: bump version to 0.1.58+59 [ci skip] 2026-06-03 14:55:56 +02:00
Javier Bautista Fernández 00fe49c309 fix: resolver advertencias de analisis i18n
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m26s
2026-06-03 14:54:50 +02:00
Javier Bautista Fernández 643ba1eb45 fix: completar migracion i18n de literales visibles
Build & Deploy PluriWave / Análisis de código (push) Failing after 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-06-03 13:43:43 +02:00
ShanaiaBot 7abc8c3b0f chore: bump version to 0.1.57+58 [ci skip] 2026-06-02 10:20:40 +02:00
Javier Bautista Fernández 2e17dfd511 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
2026-06-02 10:19:38 +02:00
Javier Bautista Fernández d449d8577b Add localization support for search and alarm features in multiple languages
- Updated Japanese, Portuguese, Russian, and Chinese localization files with new strings for search and alarm functionalities.
- Enhanced the search screen with localized titles, subtitles, and filter labels.
- Integrated localization into the alarm screen, including actions and messages related to alarm management.
- Refactored country and language lists to use localized keys for better maintainability.
- Improved user experience by providing localized hints and messages throughout the application.
2026-06-02 10:19:37 +02:00
Javier Bautista Fernández ffe1c41458 eliminados los snooze 2026-06-02 09:21:43 +02:00
ShanaiaBot d423676623 chore: bump version to 0.1.56+57 [ci skip] 2026-06-01 13:21:13 +02:00
Javier Bautista Fernández de07316d79 feat(alarmas): agregar fade-in configurable en activacion
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m29s
2026-06-01 13:20:06 +02:00
ShanaiaBot c3a22c4658 chore: bump version to 0.1.55+56 [ci skip] 2026-05-31 00:34:24 +02:00
FreeTLab 7c7bd64e85 Merge pull request 'fix(ci): remove keytool verification step that fails on runner' (#10) from fix/remove-keytool-verify into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m5s
Reviewed-on: #10
2026-05-31 00:33:01 +02:00
FreeTLab 2aeef1626c fix(ci): remove keytool verification step that fails on runner 2026-05-31 00:32:21 +02:00
ShanaiaBot 1d39293fe3 chore: bump version to 0.1.54+55 [ci skip] 2026-05-31 00:25:54 +02:00
FreeTLab ef4b8ab323 Merge pull request 'fix(ci): simplify Flutter test execution' (#9) from fix/simplify-ci-tests into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m29s
Reviewed-on: #9
2026-05-31 00:11:44 +02:00
FreeTLab 20c135a848 fix(ci): simplify Flutter test execution
- Remove complex Python wrapper that caused timeout issues
- Use direct flutter test with --concurrency=1 --timeout=60s
- Simplify cleanup with pkill instead of Python loop
- Increase timeout from 4m to 15m (tests take ~10s but CI overhead is high)
2026-05-31 00:08:01 +02:00
Javier Bautista Fernández 82f70e2fa3 fix(ci): poll flutter test output from file
Build & Deploy PluriWave / Análisis de código (push) Failing after 10m55s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-29 13:55:29 +02:00
Javier Bautista Fernández eb23a438b6 fix(ci): stop flutter tests after success sentinel
Build & Deploy PluriWave / Análisis de código (push) Failing after 12m35s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-29 13:36:51 +02:00
Javier Bautista Fernández d45fbe60db fix(alarms): skip handled occurrence when recalculating
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:29:41 +02:00
Javier Bautista Fernández 3640a76253 fix(ci): enforce critical test watchdog
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:26:15 +02:00
Javier Bautista Fernández 4a00472a83 fix(ci): isolate critical flutter tests
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:16:52 +02:00
Javier Bautista Fernández 028e2d69b1 fix(alarms): harden native alarm lifecycle
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:13:39 +02:00
FreeTLab 8f6124fc1a fix(ci): avoid killing flutter between test files
Build & Deploy PluriWave / Análisis de código (push) Failing after 13m30s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-29 00:02:51 +02:00
FreeTLab 6dd045ea42 fix(ci): clean up flutter test processes
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-28 23:59:32 +02:00
FreeTLab 8f77550a05 fix(ci): bound critical alarm tests
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-28 23:54:18 +02:00
FreeTLab cf994757a4 test(ci): stabilize hidden failures
Build & Deploy PluriWave / Análisis de código (push) Failing after 13m34s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-28 23:37:51 +02:00
FreeTLab e47c0a88e0 fix(alarms): skip completed occurrence when rescheduling
Build & Deploy PluriWave / Análisis de código (push) Failing after 14m55s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-28 19:51:23 +02:00
FreeTLab f5c2f0a879 chore: merge origin/main 2026-05-28 18:22:12 +02:00
Javier Bautista Fernández 9a9ef95b07 Merge remote-tracking branch 'origin/main'
Build & Deploy Pluriwave / Análisis de código (push) Failing after 13m6s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 14m58s
# Conflicts:
#	android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
2026-05-28 12:31:12 +02:00
Javier Bautista Fernández 659e6da189 fix(alarms): harden native playback and pre-notice actions 2026-05-28 12:03:58 +02:00
FreeTLab eae19e1d70 feat(ci): automate play upload from PRO 2026-05-27 14:01:53 +02:00
ShanaiaBot 10d18b5064 chore: bump version to 0.1.53+54 [ci skip] 2026-05-25 22:32:33 +02:00
FreeTLab a46a7ede21 fix(ci): simplificar verificación de firma, quitar unzip que falla
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m45s
2026-05-25 22:31:46 +02:00
ShanaiaBot 04a281b80c chore: bump version to 0.1.52+53 [ci skip] 2026-05-25 21:59:46 +02:00
ShanaiaBot 7569a5b020 chore: bump version to 0.1.51+52 [ci skip] 2026-05-25 21:50:51 +02:00
FreeTLab 7dceed5dae fix(ci): load release signing from key properties
Build & Deploy Pluriwave / Análisis de código (push) Successful in 22s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m32s
2026-05-25 21:50:03 +02:00
ShanaiaBot 03b56c98e7 chore: bump version to 0.1.50+51 [ci skip] 2026-05-25 21:39:07 +02:00
FreeTLab e447816d3f Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m38s
2026-05-25 21:37:42 +02:00
FreeTLab c189078c26 Corrección de publicación 2026-05-25 21:37:40 +02:00
ShanaiaBot 18016cc406 chore: bump version to 0.1.49+50 [ci skip] 2026-05-25 21:12:35 +02:00
FreeTLab e5aa1439bd refactor(ci): reemplazar actions/checkout por git clone directo, eliminar dependencia de GitHub
Build & Deploy Pluriwave / Análisis de código (push) Successful in 25s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m30s
2026-05-25 21:11:47 +02:00
FreeTLab 42dd64635c feat(pluriwave): añadir firma release con keystore pluriwave-upload para Google Play
Build & Deploy Pluriwave / Análisis de código (push) Failing after 3s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-05-25 20:47:19 +02:00
ShanaiaBot 41bbd0ea17 chore: bump version to 0.1.48+49 [ci skip] 2026-05-23 01:23:59 +02:00
FreeTLab 896349ad5f feat(app): add onboarding and harden alarms
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s
2026-05-23 01:22:49 +02:00
ShanaiaBot 27b8fccac9 chore: bump version to 0.1.47+48 [ci skip] 2026-05-22 20:03:18 +02:00
FreeTLab 3ab138a4fa feat(alarms): add native ringing service
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m8s
2026-05-22 20:02:27 +02:00
ShanaiaBot c8fff0d977 chore: bump version to 0.1.46+47 [ci skip] 2026-05-22 19:40:56 +02:00
FreeTLab cfea818133 fix(alarms): prevent overlapping playback
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m0s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 19:40:09 +02:00
ShanaiaBot bc27e7832d chore: bump version to 0.1.45+46 [ci skip] 2026-05-22 19:34:11 +02:00
FreeTLab 26078ad49b fix(alarms): skip stale startup executions
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m21s
2026-05-22 19:33:21 +02:00
ShanaiaBot 2816a97c93 chore: bump version to 0.1.44+45 [ci skip] 2026-05-22 19:24:53 +02:00
FreeTLab a976b8e797 fix(alarms): fallback native scheduling
Build & Deploy Pluriwave / Análisis de código (push) Successful in 27s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m28s
2026-05-22 19:23:57 +02:00
ShanaiaBot d7277a9274 chore: bump version to 0.1.43+44 [ci skip] 2026-05-22 18:55:28 +02:00
FreeTLab ee09224c13 fix(android): import activity not found exception
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m10s
2026-05-22 18:54:34 +02:00
ShanaiaBot 0675750b2e chore: bump version to 0.1.42+43 [ci skip] 2026-05-22 18:43:17 +02:00
FreeTLab a48dd6ddf9 fix(alarms): refresh next execution reliably
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m0s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 18:42:11 +02:00
ShanaiaBot eb185231a1 chore: bump version to 0.1.41+42 [ci skip] 2026-05-22 18:31:57 +02:00
FreeTLab 809255bd43 fix(recordings): open last file on android
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m2s
2026-05-22 18:30:49 +02:00
ShanaiaBot fde651eee9 chore: bump version to 0.1.40+41 [ci skip] 2026-05-22 18:25:26 +02:00
FreeTLab 9ad58898e0 fix(recordings): open folder with android picker
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m0s
2026-05-22 18:24:25 +02:00
ShanaiaBot 6a5fcd8d96 chore: bump version to 0.1.39+40 [ci skip] 2026-05-22 17:22:10 +02:00
FreeTLab b6e66e75ce test(favorites): cover sqlite migrations
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 17:21:03 +02:00
ShanaiaBot f6a9ba0086 chore: bump version to 0.1.38+39 [ci skip] 2026-05-22 16:59:29 +02:00
FreeTLab 157d52996e fix(i18n): localize settings order copy
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m44s
2026-05-22 16:58:19 +02:00
ShanaiaBot aaeee51233 chore: bump version to 0.1.37+38 [ci skip] 2026-05-22 16:19:43 +02:00
FreeTLab 5f35db6352 feat(favorites): manage favorite groups in ui
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
2026-05-22 16:18:31 +02:00
ShanaiaBot c46d941e6c chore: bump version to 0.1.36+37 [ci skip] 2026-05-22 16:11:50 +02:00
FreeTLab 9bd973b327 feat(favorites): add group persistence foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 25s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m50s
2026-05-22 16:10:18 +02:00
ShanaiaBot c347ce9d8e chore: bump version to 0.1.35+36 [ci skip] 2026-05-22 15:56:13 +02:00
FreeTLab f667277e35 feat(stations): add quality filters and list ordering
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m42s
2026-05-22 15:54:51 +02:00
ShanaiaBot 0114e4805e chore: bump version to 0.1.34+35 [ci skip] 2026-05-22 15:25:18 +02:00
FreeTLab 8190c4ab8d feat(recording): add safety limits and adaptive headers
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m37s
2026-05-22 15:24:25 +02:00
ShanaiaBot 2320dbdc5f chore: bump version to 0.1.33+34 [ci skip] 2026-05-22 15:05:07 +02:00
FreeTLab 785a41f0c4 docs: add pending ux recording and search tasks
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m37s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 15:04:20 +02:00
ShanaiaBot 30fe6c6667 chore: bump version to 0.1.32+33 [ci skip] 2026-05-22 15:03:56 +02:00
FreeTLab 3b0efb641c feat(i18n): expand supported languages
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been cancelled
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 15:03:07 +02:00
ShanaiaBot 4e22bd4e98 chore: bump version to 0.1.31+32 [ci skip] 2026-05-22 13:50:22 +02:00
FreeTLab 6480c56f99 feat(i18n): migrate settings literals
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m44s
2026-05-22 13:49:34 +02:00
ShanaiaBot 116d878a98 chore: bump version to 0.1.30+31 [ci skip] 2026-05-22 13:31:04 +02:00
FreeTLab 3f548fd53e feat(i18n): add localization foundation
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 13:30:17 +02:00
ShanaiaBot d85dee6fa8 chore: bump version to 0.1.29+30 [ci skip] 2026-05-22 13:13:48 +02:00
FreeTLab e1d1d6c639 feat(ui): refine navigation and sleep timer
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m19s
2026-05-22 13:13:05 +02:00
ShanaiaBot 0edad1bfcb chore: bump version to 0.1.28+29 [ci skip] 2026-05-22 01:56:05 +02:00
FreeTLab a181cc8e85 feat(ui): refresh premium visual assets
Build & Deploy Pluriwave / Análisis de código (push) Successful in 27s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m50s
2026-05-22 01:54:33 +02:00
ShanaiaBot 72f6f4e974 chore: bump version to 0.1.27+28 [ci skip] 2026-05-22 01:27:10 +02:00
FreeTLab 4ae93182fa fix(alarm): add due alarm watchdog
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m14s
2026-05-22 01:26:36 +02:00
ShanaiaBot d8823a328d chore: bump version to 0.1.26+27 [ci skip] 2026-05-22 01:06:36 +02:00
FreeTLab eeadcc1cc6 fix(alarm): improve firing and preferred station
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m15s
2026-05-22 01:06:02 +02:00
ShanaiaBot 28067e392d chore: bump version to 0.1.25+26 [ci skip] 2026-05-22 00:40:36 +02:00
FreeTLab a3a648c633 feat(alarm): complete musical alarm flows
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m21s
2026-05-22 00:40:01 +02:00
ShanaiaBot 7f1874f873 chore: bump version to 0.1.24+25 [ci skip] 2026-05-21 23:47:41 +02:00
FreeTLab fb808ebb60 feat(alarm): add musical alarm foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m45s
2026-05-21 23:47:06 +02:00
ShanaiaBot 8c2cba093c chore: bump version to 0.1.23+24 [ci skip] 2026-05-21 22:16:46 +02:00
FreeTLab a9202c6eb3 fix(settings): show real version and map equalizer gains
Build & Deploy Pluriwave / Análisis de código (push) Successful in 13s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m4s
2026-05-21 22:16:18 +02:00
ShanaiaBot dac1b602e2 chore: bump version to 0.1.22+23 [ci skip] 2026-05-21 22:00:26 +02:00
FreeTLab 921e972183 fix(player): stabilize equalizer and visualizer
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m50s
2026-05-21 21:59:59 +02:00
ShanaiaBot d0ceaac3f3 chore: bump version to 0.1.21+22 [ci skip] 2026-05-21 21:18:27 +02:00
FreeTLab a6a91af402 feat(player): add radio recording and real waveform
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m27s
2026-05-21 21:17:59 +02:00
ShanaiaBot 6aa9a59d7b chore: bump version to 0.1.20+21 [ci skip] 2026-05-21 20:53:03 +02:00
FreeTLab 0e18c82292 fix(player): recreate audio player on station switch
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s
2026-05-21 20:52:28 +02:00
ShanaiaBot 0456850f3d chore: bump version to 0.1.19+20 [ci skip] 2026-05-21 01:12:43 +02:00
FreeTLab ef22454350 fix(player): separate selection from audio state
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m15s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-21 01:12:20 +02:00
ShanaiaBot b23450819c chore: bump version to 0.1.18+19 [ci skip] 2026-05-21 00:58:14 +02:00
FreeTLab 1791207bd4 fix(player): restore setUrl source loading
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m16s
2026-05-21 00:57:49 +02:00
ShanaiaBot fe531a1784 chore: bump version to 0.1.17+18 [ci skip] 2026-05-21 00:50:49 +02:00
FreeTLab 6b0faebc7f fix(player): serialize live stream switching
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m24s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
2026-05-21 00:50:23 +02:00
ShanaiaBot 26d8151d7a chore: bump version to 0.1.16+17 [ci skip] 2026-05-21 00:36:40 +02:00
FreeTLab f49d349616 fix(player): restore historical station switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m22s
2026-05-21 00:36:16 +02:00
ShanaiaBot 37aea7e99f chore: bump version to 0.1.15+16 [ci skip] 2026-05-21 00:24:26 +02:00
FreeTLab ee26c78d82 fix(player): handle play errors on station switch
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m23s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
2026-05-21 00:24:04 +02:00
ShanaiaBot 6249ed1b2c chore: bump version to 0.1.14+15 [ci skip] 2026-05-21 00:13:36 +02:00
FreeTLab 01135e8a3d fix(player): prevent stale station overwrite
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-21 00:13:12 +02:00
ShanaiaBot 67fe4413f4 chore: bump version to 0.1.13+14 [ci skip] 2026-05-20 23:56:24 +02:00
FreeTLab be0d6c5a9e fix(player): restore stable audio switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-20 23:56:03 +02:00
ShanaiaBot abea51ba3f chore: bump version to 0.1.12+13 [ci skip] 2026-05-20 23:44:46 +02:00
FreeTLab 10520fef48 fix(ui): unify scroll and improve playback switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
2026-05-20 23:44:24 +02:00
ShanaiaBot 34022e0814 chore: bump version to 0.1.11+12 [ci skip] 2026-05-20 23:22:45 +02:00
FreeTLab 7fcd0f544e feat(radio): add nearby discovery and paged search
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m34s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-20 23:22:23 +02:00
ShanaiaBot f888153aa9 chore: bump version to 0.1.10+11 [ci skip] 2026-05-20 22:51:15 +02:00
FreeTLab b9cf42b91c fix(player): stabilize first playback and refresh design
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m20s
2026-05-20 22:50:49 +02:00
ShanaiaBot 22e19d1cb0 chore: bump version to 0.1.9+10 [ci skip] 2026-05-20 22:15:45 +02:00
FreeTLab 3be59d740c feat(ui): add generated premium assets
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-20 22:15:24 +02:00
ShanaiaBot 2fb794a43b chore: bump version to 0.1.8+9 [ci skip] 2026-05-20 21:30:14 +02:00
FreeTLab d8acf74771 feat(ui): implement award mockup redesign
Build & Deploy Pluriwave / Análisis de código (push) Successful in 10s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-20 21:29:47 +02:00
ShanaiaBot eb0ef37c76 chore: bump version to 0.1.7+8 [ci skip] 2026-05-20 20:19:03 +02:00
FreeTLab 4bcd86f59c fix(ci): use compatible reorder callback
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m13s
2026-05-20 20:18:43 +02:00
FreeTLab 9c51454d57 fix(ci): resolve premium UI analyzer errors
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
Build & Deploy Pluriwave / Análisis de código (push) Failing after 9s
2026-05-20 20:07:24 +02:00
FreeTLab c707fc9911 feat(ui): add premium PluriWave redesign
Build & Deploy Pluriwave / Análisis de código (push) Failing after 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-05-20 18:42:22 +02:00
ShanaiaBot f95a8290ae chore: bump version to 0.1.6+7 [ci skip] 2026-04-27 17:36:54 +02:00
Javier Bautista Fernández 40f1d77a40 fix: Correct file resolver call and update preset equalizer in tests
Build & Deploy Pluriwave / Análisis de código (push) Successful in 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
2026-04-27 17:36:35 +02:00
Javier Bautista Fernández 7dc8fbe99d Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Failing after 10s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-27 17:34:16 +02:00
Javier Bautista Fernández d579a0e107 feat: Implement startup retry mechanism for custom stations and equalizer persistence
- Added state management for startup retry and custom station handling in `EstadoRadio`.
- Created tasks for implementing strict TDD with RED tests for HTTP failure retries and EQ persistence.
- Developed verification report to ensure compliance with TDD practices.
- Introduced fake services for testing, including `FakeServicioAudio`, `FakeServicioFavoritos`, and `FakeServicioRadio`.
- Implemented widget tests for `PantallaInicio` and `PantallaFavoritos` to validate UI behavior with custom stations.
- Enhanced `ServicioRadio` to support host rotation and retry logic for API calls.
- Established a new configuration file to enforce project constraints and testing rules.
2026-04-27 17:34:04 +02:00
ShanaiaBot 2f52a31242 chore: bump version to 0.1.5+6 [ci skip] 2026-04-07 14:51:59 +02:00
Javier Bautista Fernández 922b3b4859 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m8s
2026-04-07 14:51:39 +02:00
Javier Bautista Fernández bb5937e184 Mejora, aumentar el nº de elementos seleccionables como timer para apagar la radio 2026-04-07 14:51:26 +02:00
ShanaiaBot a51b8377a2 chore: bump version to 0.1.4+5 [ci skip] 2026-04-07 14:50:44 +02:00
Javier Bautista Fernández 547a667ada fix. In maldito ; de más
Build & Deploy Pluriwave / Análisis de código (push) Successful in 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been cancelled
2026-04-07 14:50:25 +02:00
Javier Bautista Fernández 8a455eb6bb Fix. Igual es un simple espacio de más
Build & Deploy Pluriwave / Análisis de código (push) Failing after 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 14:47:39 +02:00
Javier Bautista Fernández ebd26af169 Fix. corregir elementos tiempo desconexión
Build & Deploy Pluriwave / Análisis de código (push) Failing after 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 14:40:10 +02:00
Javier Bautista Fernández 933ced76ba Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Failing after 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 12:52:28 +02:00
Javier Bautista Fernández a8e9c91f9d Actualización. CI. Añadir más minutos en el selector del timer del sueño 2026-04-07 12:52:18 +02:00
ShanaiaBot e59ac7d552 chore: bump version to 0.1.3+4 [ci skip] 2026-04-07 12:45:56 +02:00
Javier Bautista Fernández 556151c64d Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
2026-04-07 12:45:38 +02:00
Javier Bautista Fernández 8e2c01f626 fix. faltaba el caso en el que el tiempo aún no fuese cero 2026-04-07 12:38:29 +02:00
ShanaiaBot b41a28452d chore: bump version to 0.1.2+3 [ci skip] 2026-04-07 12:31:08 +02:00
Javier Bautista Fernández a8425d65bc fix. Solución a que no se detenga la música
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m56s
2026-04-07 12:30:41 +02:00
ShanaiaBot 0dc554e5fb chore: bump version to 0.1.1+2 [ci skip] 2026-04-07 01:10:39 +02:00
FreeTLab ea4fc369f6 Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Análisis de código (push) Successful in 7s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m10s
2026-04-07 01:10:23 +02:00
FreeTLab 47c6505c41 Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
Build & Deploy Pluriwave / Análisis de código (push) Failing after 9s
2026-04-07 01:00:55 +02:00
FreeTLab 23b73bf0e0 Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Análisis de código (push) Failing after 4s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 00:59:03 +02:00
FreeTLab b13176eaeb Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Análisis de código (push) Failing after 4s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 00:45:12 +02:00
FreeTLab d97bc06a5b Añadir .gitea/workflows/build.yml 2026-04-07 00:43:48 +02:00
FreeTLab 2b1f3adb3a Actualizar .gitea/workflows/ci.back 2026-04-07 00:43:28 +02:00
FreeTLab 50088eb94f Actualizar .gitea/workflows/ci.yml
Flutter CI/CD — PluriWave / Test + Build (push) Failing after 2m15s
2026-04-07 00:40:11 +02:00
FreeTLab b61b3218fc fix(ci): runner macos-14 + ANDROID_HOME (#8)
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
2026-04-06 14:21:18 +02:00
FreeTLab 651c4e1360 Merge pull request 'fix(reproduccion): robustez HTTP cleartext, errores ExoPlayer y certificados SSL' (#7) from feature/fix-reproduccion-robustez into main
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
Reviewed-on: #7
2026-04-05 19:08:21 +02:00
FreeTLab 1250f40322 Merge pull request 'feat(v0.5.0): visualizador de audio animado' (#6) from feature/visualizador-audio into main
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
Reviewed-on: #6
2026-04-05 19:07:59 +02:00
ShanaiaBot b0fdba5119 ci: retrigger workflow
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
2026-04-05 07:49:51 +02:00
ShanaiaBot 44849986d2 fix(reproduccion): robustez HTTP cleartext, errores ExoPlayer y certificados SSL
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
**Fix 1 — HTTP cleartext (streams sin HTTPS):**
- Añadir android/app/src/main/res/xml/network_security_config.xml con
  cleartextTrafficPermitted=true para permitir streams de radio HTTP
- Referenciar en AndroidManifest.xml con android:networkSecurityConfig
- Resuelve: 'Cleartext HTTP traffic to [host] not permitted' en ExoPlayer
- Radio Paradise (Dance Wave, HTTP) y otras radios HTTP funcionan ahora

**Fix 2 — Gestión de error TYPE_SOURCE y todos los PlaybackException:**
- Añadir listener en playbackEventStream.onError en PluriWaveAudioHandler
- _gestionarErrorReproduccion() emite AudioProcessingState.error al UI,
  loggea el error y resetea el player a estado idle limpio
- _mensajeAmigable() traduce códigos ERROR_CODE_IO_*, ERROR_CODE_PARSING_*,
  ERROR_CODE_DECODING_* y mensajes de Cleartext/HandshakeException a texto legible
- EstadoRadio.reproducir() captura la excepción y cancela el timer si estaba activo
- EstadoRadio escucha el estadoStream y cancela timer ante cualquier error

**Fix 3 — Artwork con certificado autofirmado:**
- errorWidget en CachedNetworkImage captura HandshakeException silenciosamente
- Muestra _iconoFallback (icono de radio) en lugar de imagen rota
- El error de artwork no se propaga ni interrumpe la reproducción

**Fix 4 — UI consistente en estado de error:**
- PantallaReproductor._Controles muestra mensaje + botón Reintentar en error
- PantallaReproductor._Artwork muestra overlay wifi_off en estado de error
- MiniReproductor muestra botón refresh (reintentar) en estado de error
- EstadoReproduccion.error ya estaba definido; ahora el estadoStream lo emite
- Timer cancelado automáticamente cuando la reproducción falla
- Test de smoke corregido (boilerplate MyApp → placeholder válido)

Fixes: cleartext HTTP, cert autofirmado, ExoPlayer TYPE_SOURCE, UI inconsistente
2026-04-04 20:43:56 +02:00
511 changed files with 100076 additions and 1566 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+181
View File
@@ -0,0 +1,181 @@
name: Build & Deploy PluriWave
on:
push:
branches: [main, PRO]
env:
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
KEYSTORE_PATH: /Users/freetlab/.openclaw/workspace/.secure/pluriwave/pluriwave-upload.jks
KEYSTORE_ALIAS: pluriwave-upload
PLAY_PACKAGE_NAME: es.freetimelab.pluriwave
CURRENT_REF: ${{ gitea.ref }}
jobs:
analizar:
name: Análisis de código
runs-on: [self-hosted, macos, arm64, flutter]
steps:
- name: Clonar rama actual
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
git fetch origin "$BRANCH"
git checkout "$BRANCH"
- name: Obtener dependencias
run: flutter pub get
- name: Verificar integridad de literales i18n
run: python3 tool/check_arb_placeholder_corruption.py
- name: Analizar código
run: flutter analyze --no-fatal-infos --no-fatal-warnings
- name: Ejecutar tests criticos
timeout-minutes: 15
run: |
flutter test test/servicios/servicio_programacion_alarmas_test.dart test/estado/estado_alarmas_test.dart --concurrency=1 --timeout=60s
- name: Limpiar procesos Flutter de tests
if: always()
run: pkill -f 'flutter_tester|flutter_tools.snapshot|dartaotruntime' 2>/dev/null || true
build:
name: Build APK + AAB release
runs-on: [self-hosted, macos, arm64, flutter]
needs: analizar
steps:
- name: Clonar rama actual
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
git fetch origin "$BRANCH"
git checkout "$BRANCH"
- name: Configurar keystore de firma
env:
KEYSTORE_PASSWORD: ${{ secrets.PLURIWAVE_KEYSTORE_PASSWORD }}
run: |
if [ ! -f "$KEYSTORE_PATH" ]; then
echo "ERROR: Keystore no encontrado en $KEYSTORE_PATH"
exit 1
fi
echo "storeFile=$KEYSTORE_PATH" > android/key.properties
echo "storePassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "keyAlias=$KEYSTORE_ALIAS" >> android/key.properties
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "✅ Keystore configurado"
- name: Bump versión patch + commit
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git config user.name "ShanaiaBot"
git config user.email "shanaia@freetimelab.es"
CURRENT=$(grep '^version:' pubspec.yaml | awk '{print $2}')
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
NEW_BUILD=$((BUILD + 1))
# If the triggering commit explicitly pins the version name via the
# [version set] marker, ship that semver as-is (a milestone like 1.0.0
# or a major/minor jump the automatic patch bump cannot reach) and only
# advance the build number, which Google Play requires to stay
# monotonic. Otherwise keep the default automatic patch+build bump.
if git log -1 --pretty=%B | grep -q '\[version set\]'; then
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
else
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
MINOR=$(echo "$SEMVER" | cut -d. -f2)
PATCH=$(echo "$SEMVER" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
fi
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
git push origin "HEAD:${BRANCH}"
- name: Extraer versión
id: version
run: |
VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2)
COMMIT=$(git rev-parse --short HEAD)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
- name: Obtener dependencias
run: flutter pub get
- name: Build APK release
run: flutter build apk --release
- name: Build AAB release
run: flutter build appbundle --release
- name: Publicar en ftl-builds (Zimaboard)
run: |
VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk"
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/flutter-apk/app-release.apk \
"ShanaiaBot@192.168.0.33:${DESTINO}/${APK_NOMBRE}"
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
- name: Preparar credenciales de Google Play
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
exit 1
fi
mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
- name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' }}
run: |
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
- name: Publicar AAB en Google Play Internal Testing
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
PLAY_TRACK: internal
PLAY_RELEASE_STATUS: completed
run: fastlane android upload_internal
- name: Notificar Telegram
if: always()
run: |
VERSION="${{ steps.version.outputs.version }}"
COMMIT="${{ steps.version.outputs.commit }}"
BRANCH="${CURRENT_REF#refs/heads/}"
BOT_TOKEN=$(plutil -extract 'EnvironmentVariables:TELEGRAM_BOT_TOKEN' raw /Users/freetlab/Library/LaunchAgents/ai.openclaw.gateway.plist 2>/dev/null || echo "")
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
if [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
else
MSG="${MSG}%0APublicado en builds.freetimelab.es"
fi
else
MSG="❌ *PluriWave* build FAILED · rama ${BRANCH} · ${COMMIT}"
fi
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
@@ -11,7 +11,10 @@ on:
jobs:
flutter-ci:
name: Test + Build
runs-on: macmini-flutter
#runs-on: macos-14
runs-on: [self-hosted, macos, arm64, flutter]
env:
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
steps:
- name: Checkout
+1
View File
@@ -32,6 +32,7 @@ migrate_working_dir/
.pub/
/build/
/coverage/
.atl/
# Symbolication related
app.*.symbols
+50
View File
@@ -0,0 +1,50 @@
# TODO
## Internacionalización AAA
- [x] Diseñar una base de internacionalización profesional con ficheros ARB separados por idioma.
- [x] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema.
- [x] Añadir traducción inicial español/inglés para el shell, navegación, timer de sueño y selector de idioma.
- [x] Añadir soporte inicial para un conjunto amplio de idiomas muy hablados: inglés, español, chino, hindi, árabe, portugués, francés, ruso, alemán, japonés, indonesio, bengalí e italiano.
- [x] Ejecutar escaneo UTF-8 sobre ARB/código tocado y corregir corrupciones visibles en los textos migrados.
- [ ] Validar no solo el guardado UTF-8 en código, sino también el render real en la aplicación para acentos, ñ, signos, alfabetos no latinos y direcciones RTL.
- [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones.
- [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados.
- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`.
- [ ] Revisar profesionalmente todas las traducciones nuevas con hablantes nativos o servicio especializado antes de considerarlas definitivas.
- [ ] Preparar traducciones adicionales si se decide ampliar más allá del conjunto inicial.
- [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener.
- [ ] Verificar que no queda ningún literal hardcodeado fuera del sistema de traducciones.
## UX y accesibilidad visual
- [x] Revisar los paneles informativos superiores de cada pantalla: recuperar márgenes internos elegantes para que el texto no quede pegado a los bordes.
- [x] Añadir comportamiento adaptativo en el header premium para escalas de texto grandes y pantallas estrechas.
- [ ] Probar la aplicación con escalas de texto grandes/muy grandes del sistema en dispositivo real o golden tests.
- [ ] Diseñar una solución elegante para textos largos en todos los paneles secundarios: reflow, límites razonables, scroll, wraps controlados y jerarquías que mantengan la estética AAA.
## Grabaciones
- [x] Añadir en Ajustes un acceso elegante para abrir la carpeta de grabaciones con el gestor de ficheros del sistema mediante intent.
- [x] Añadir configuración de tamaño máximo de fichero de grabación; valor por defecto: 500 MB.
- [x] Detener automáticamente la grabación si se para o pausa la reproducción.
- [x] Detener automáticamente la grabación si se cambia de emisora.
- [ ] Probar en Android real que el intent de carpeta funciona con rutas internas y rutas escogidas por el usuario.
## Búsqueda de emisoras
- [x] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
## Favoritos
- [x] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos.
- [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia.
## Agrupaciones de favoritos
- [x] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
- [x] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
- [x] Gestionar desde la vista Favoritos qué emisoras pertenecen a cada agrupación/lista.
- [x] Diseñar migración SQLite base para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
- [x] Completar UI en Ajustes para crear, editar y borrar listas de favoritos.
- [x] Completar UI en Favoritos para mover emisoras entre listas.
+5
View File
@@ -23,6 +23,11 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
cancel_subscriptions: true
close_sinks: true
unawaited_futures: true
prefer_final_locals: true
avoid_dynamic_calls: true
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+35 -7
View File
@@ -1,10 +1,21 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
import java.util.Properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
fun secret(name: String, propertyName: String): String? =
keystoreProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
?: System.getenv(name)?.takeIf { it.isNotBlank() }
android {
namespace = "es.freetimelab.pluriwave"
compileSdk = flutter.compileSdkVersion
@@ -20,21 +31,38 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "es.freetimelab.pluriwave"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
val storeFilePath = secret("KEYSTORE_PATH", "storeFile")
val storePasswordValue = secret("KEYSTORE_PASSWORD", "storePassword")
val keyAliasValue = secret("KEYSTORE_ALIAS", "keyAlias")
val keyPasswordValue = secret("KEY_PASSWORD", "keyPassword")
if (!storeFilePath.isNullOrBlank()) {
storeFile = file(storeFilePath)
}
if (!storePasswordValue.isNullOrBlank()) {
storePassword = storePasswordValue
}
if (!keyAliasValue.isNullOrBlank()) {
keyAlias = keyAliasValue
}
if (!keyPasswordValue.isNullOrBlank()) {
keyPassword = keyPasswordValue
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}
+87 -1
View File
@@ -3,19 +3,41 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!--
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
and by this legacy permission below it. Normal permission: granted at
install, no runtime prompt.
-->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30"/>
<application
android:label="PluriWave"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
@@ -39,6 +61,11 @@
</intent-filter>
</service>
<service
android:name=".PluriWaveAlarmService"
android:foregroundServiceType="mediaPlayback|systemExempted"
android:exported="false" />
<!-- Receptor de controles de media (auriculares, notificación) -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
@@ -48,9 +75,68 @@
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveAlarmReceiver"
android:exported="false"
android:directBootAware="true">
<intent-filter>
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveBootReceiver"
android:exported="true"
android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.USER_UNLOCKED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.TIME_SET"/>
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/pluriwave_file_paths" />
</provider>
<!--
Publishes the app-private recordings folder as a browsable storage
root for the system file manager. MANAGE_DOCUMENTS restricts direct
access to the document framework (DocumentsUI); grantUriPermissions
lets it hand single-file access to whatever app the user picks.
-->
<provider
android:name=".RecordingsDocumentsProvider"
android:authorities="${applicationId}.recordings"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Android Auto discovery (android-auto-media) -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
<queries>
<intent>
@@ -0,0 +1,91 @@
package es.freetimelab.pluriwave
import android.content.Context
/**
* Localized strings for native alarm notifications, channels and choosers.
*
* Flutter is the single source of truth for i18n: it pushes the current-locale
* strings via the `setNotificationStrings` MethodChannel whenever the app locale
* is (re)configured. They are persisted in device-protected storage so the
* native side can read them when building a notification or channel even while
* the Flutter engine is dead (alarm fired from a killed app, after reboot, in
* direct-boot). Every getter falls back to English when a value is unset.
*/
object AlarmNotificationStrings {
private const val PREFS = "pluriwave_alarm_strings"
const val KEY_RING_TITLE = "ringTitle"
const val KEY_SNOOZE = "snoozeLabel"
const val KEY_STOP = "stopLabel"
const val KEY_SKIP = "skipLabel"
const val KEY_SNOOZE_AGAIN = "snoozeAgainLabel"
const val KEY_FIRE_CHANNEL_NAME = "fireChannelName"
const val KEY_FIRE_CHANNEL_DESC = "fireChannelDescription"
const val KEY_PRE_NOTICE_CHANNEL_NAME = "preNoticeChannelName"
const val KEY_PRE_NOTICE_CHANNEL_DESC = "preNoticeChannelDescription"
const val KEY_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"
const val KEY_SNOOZE_COUNTDOWN_TEMPLATE = "snoozeCountdownTemplate"
const val KEY_OPEN_FOLDER = "openFolderTitle"
const val KEY_OPEN_RECORDING = "openRecordingTitle"
const val KEY_RECORDINGS_ROOT_TITLE = "recordingsRootTitle"
const val KEY_MISSED_TITLE = "missedTitle"
const val KEY_MISSED_TEMPLATE = "missedTemplate"
/** Persists the localized strings pushed by Flutter. Blank values are removed. */
fun save(context: Context, values: Map<String, Any?>) {
val editor = prefs(context).edit()
for ((key, value) in values) {
val str = value as? String
if (str.isNullOrBlank()) editor.remove(key) else editor.putString(key, str)
}
editor.apply()
}
fun ringTitle(context: Context) = get(context, KEY_RING_TITLE, "PluriWave alarm")
fun snoozeLabel(context: Context) = get(context, KEY_SNOOZE, "Snooze")
fun stopLabel(context: Context) = get(context, KEY_STOP, "Stop")
fun skipLabel(context: Context) = get(context, KEY_SKIP, "Skip this time")
fun snoozeAgainLabel(context: Context) = get(context, KEY_SNOOZE_AGAIN, "Snooze again")
fun fireChannelName(context: Context) = get(context, KEY_FIRE_CHANNEL_NAME, "Ringing alarms")
fun fireChannelDescription(context: Context) =
get(context, KEY_FIRE_CHANNEL_DESC, "Urgent sound and screen when a music alarm must ring")
fun preNoticeChannelName(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_NAME, "Alarm reminders")
fun preNoticeChannelDescription(context: Context) =
get(context, KEY_PRE_NOTICE_CHANNEL_DESC, "Silent notifications before the alarm")
fun openFolderTitle(context: Context) = get(context, KEY_OPEN_FOLDER, "Open folder")
fun openRecordingTitle(context: Context) = get(context, KEY_OPEN_RECORDING, "Open recording")
/** Title of the storage root published by [RecordingsDocumentsProvider]. */
fun recordingsRootTitle(context: Context) =
get(context, KEY_RECORDINGS_ROOT_TITLE, "PluriWave recordings")
fun missedTitle(context: Context) = get(context, KEY_MISSED_TITLE, "Missed alarm")
fun missedText(context: Context, name: String): String =
format(
// "10 minutes" mirrors AlarmScheduler.AUTO_SILENCE_MILLIS
// (READ-3/READ-4) -- keep both, and the alarmMissedNotificationText
// entry of ALL 13 lib/l10n/app_*.arb files, in sync.
get(context, KEY_MISSED_TEMPLATE, "{name} was silenced automatically after 10 minutes."),
name
)
fun preNoticeText(context: Context, minutes: Long): String =
format(get(context, KEY_PRE_NOTICE_TEMPLATE, "Starts in {minutes} min"), minutes)
fun snoozeCountdownText(context: Context, minutes: Long): String =
format(get(context, KEY_SNOOZE_COUNTDOWN_TEMPLATE, "Rings in {minutes} min"), minutes)
private fun format(template: String, minutes: Long): String =
template.replace("{minutes}", minutes.toString())
private fun format(template: String, name: String): String =
template.replace("{name}", name)
private fun get(context: Context, key: String, fallback: String): String =
prefs(context).getString(key, null)?.takeIf { it.isNotBlank() } ?: fallback
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
package es.freetimelab.pluriwave
import androidx.annotation.ColorInt
/**
* Shared brand color for native notification icons.
*
* Single source of truth for the cyan tint applied via `NotificationCompat.Builder.setColor()`
* across all PluriWave alarm and audio notifications, mirroring the [AlarmNotificationStrings]
* shared-constants precedent.
*/
object NotificationBrand {
@ColorInt const val CYAN: Int = 0xFF21D4D9.toInt()
}
@@ -0,0 +1,300 @@
package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class PluriWaveAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val alarmId = intent.getStringExtra(EXTRA_ALARM_ID) ?: run {
Log.w(TAG, "alarm.receiver missing alarmId action=${intent.action}")
return
}
val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave"
val snoozeMinutes = sanitizeSnoozeMinutes(intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5))
Log.d(TAG, "alarm.receiver action=${intent.action} id=$alarmId title=$title")
when (intent.action) {
ACTION_FIRE -> {
AlarmScheduler(context).onAlarmFired(alarmId)
PluriWaveAlarmService.start(context, intent)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE)
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
}
// The service's startForeground notification (single FSI owner) is
// posted by PluriWaveAlarmService.start above; the receiver must NOT
// post a duplicate fire notification.
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver fire startActivity ERROR id=$alarmId", error)
}
}
ACTION_PRE_NOTICE -> {
showPreNoticeNotification(
context,
alarmId,
title,
snoozeMinutes,
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
)
}
ACTION_POSTPONE_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_POSTPONE_NEXT)
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAt)
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
}
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver postponeNext startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver postponeNext startActivity ERROR id=$alarmId", error)
}
}
ACTION_SKIP_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).cancelPreNoticeCountdown(alarmId)
AlarmScheduler(context).skipNext(alarmId)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
}
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver skipNext startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error)
}
}
ACTION_SNOOZE_COUNTDOWN -> {
AlarmScheduler(context).handleSnoozeCountdownTick(alarmId)
}
ACTION_MISSED -> {
AlarmScheduler(context).onAlarmMissed(alarmId)
}
ACTION_SNOOZE_AGAIN -> {
val snoozed = AlarmScheduler(context).snoozeAgain(alarmId, snoozeMinutes)
if (snoozed != null) {
// Reuses the existing native-snooze event so Flutter records
// the new snooze (live) and the cold-start sync imports it.
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to alarmId,
"alarmTitle" to snoozed.title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
"snoozeMinutes" to snoozeMinutes
)
)
}
}
ACTION_CANCEL_SNOOZE -> {
val occurrence = AlarmScheduler(context).cancelSnooze(alarmId)
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
if (occurrence != null) {
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZE_CANCELLED,
"occurrenceAtMillis" to occurrence
)
)
}
}
else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId")
}
}
private fun showPreNoticeNotification(
context: Context,
alarmId: String,
title: String,
snoozeMinutes: Int,
triggerAtMillis: Long,
occurrenceAtMillis: Long
) {
ensureChannel(context)
val remaining = computeRemainingMinutes(triggerAtMillis)
val contentText = AlarmNotificationStrings.preNoticeText(context, remaining)
val openAppIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 1),
Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val skipNextIntent = PendingIntent.getBroadcast(
context,
requestCode(alarmId, 2),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_SKIP_NEXT
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val postponeNextIntent = PendingIntent.getBroadcast(
context,
requestCode(alarmId, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_POSTPONE_NEXT
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
putExtra(EXTRA_TRIGGER_AT, triggerAtMillis)
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAtMillis)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_pluriwave)
.setColor(NotificationBrand.CYAN)
.setContentTitle(title)
.setContentText(contentText)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setSilent(true)
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.addAction(0, AlarmNotificationStrings.snoozeLabel(context), postponeNextIntent)
.addAction(0, AlarmNotificationStrings.skipLabel(context), skipNextIntent)
.build()
try {
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId remaining=$remaining")
} catch (error: SecurityException) {
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
}
// Re-arm the next minute tick so the countdown keeps live-updating
// until the real alarm fires. Reuses the SAME [remaining] computed
// above for the notification text to avoid a second clock read that
// could drift and cause an off-by-one between displayed text and the
// next-boundary math.
AlarmScheduler(context).armNextPreNoticeCountdownTick(
id = alarmId,
title = title,
snoozeMinutes = snoozeMinutes,
triggerAtMillis = triggerAtMillis,
occurrenceAtMillis = occurrenceAtMillis,
remaining = remaining
)
}
/**
* Computes the number of minutes remaining until [triggerAtMillis] using
* ceiling rounding (consistent with [AlarmScheduler]'s snooze-countdown
* ceilMinutes), clamped to a minimum of 1. Handles Doze-delayed wakeups
* and clock drift.
*/
private fun computeRemainingMinutes(triggerAtMillis: Long): Long =
maxOf(1L, (triggerAtMillis - System.currentTimeMillis() + 59_999L) / 60_000L)
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Re-create each time so the localized name/description refresh after a
// locale change (Android updates them on an existing channel).
val channel = NotificationChannel(
CHANNEL_ID,
AlarmNotificationStrings.preNoticeChannelName(context),
NotificationManager.IMPORTANCE_LOW
).apply {
description = AlarmNotificationStrings.preNoticeChannelDescription(context)
setSound(null, null)
enableVibration(false)
}
manager.createNotificationChannel(channel)
}
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
companion object {
const val TAG = "PluriWave"
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
const val ACTION_SNOOZE_COUNTDOWN = "es.freetimelab.pluriwave.alarm.SNOOZE_COUNTDOWN"
const val ACTION_SNOOZE_AGAIN = "es.freetimelab.pluriwave.alarm.SNOOZE_AGAIN"
const val ACTION_CANCEL_SNOOZE = "es.freetimelab.pluriwave.alarm.CANCEL_SNOOZE"
const val ACTION_MISSED = "es.freetimelab.pluriwave.alarm.MISSED"
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction"
const val EXTRA_STATION_NAME = "stationName"
const val EXTRA_STATION_URL = "stationUrl"
const val EXTRA_FALLBACK_STATION_NAME = "fallbackStationName"
const val EXTRA_FALLBACK_STATION_URL = "fallbackStationUrl"
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume"
const val EXTRA_FADE_IN_SECONDS = "fadeInSegundos"
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
/**
* Shared PendingIntent requestCode formula (READ-3/READ-4): kept in
* ONE place so instance call sites (showPreNoticeNotification, which
* resolve this unqualified via companion-member lookup) and
* companion-object call sites ([pendingMissedIntent]) can never
* diverge into two different formulas for the same alarm id.
*/
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
/** Shared PendingIntent factory for the MISSED transition alarm (Decision 3). */
fun pendingMissedIntent(context: Context, alarmId: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
context,
requestCode(alarmId, 4),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_MISSED
putExtra(EXTRA_ALARM_ID, alarmId)
},
flags or PendingIntent.FLAG_IMMUTABLE
)
}
}
@@ -0,0 +1,873 @@
package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.io.File
/**
* Foreground service that owns native alarm audio and the single ringing
* notification (NOTIFICATION_ID, full-screen intent).
*
* Sole ring-audio ownership: this service is the ONLY audio source for the
* whole ring, from start to dismiss/snooze/timeout, on STREAM_ALARM via its
* own MediaPlayer (station stream, fallback station, or bundled WAV). The
* Flutter ringing screen is display-only: it never starts a player and
* never touches system volume, only EstadoAlarmas.finalizarEjecucion /
* posponerAlarma from Stop/Snooze/back.
*/
class PluriWaveAlarmService : Service() {
private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null
private var activeAlarmId: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var stationFallbackRunnable: Runnable? = null
private var fadeLoopRunnable: Runnable? = null
private var fadeAnchorElapsedMs: Long = 0L
private var audioFocusRequest: AudioFocusRequest? = null
private val noopAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { }
override fun onBind(intent: Intent?): IBinder? = null
/**
* Paired-write helper (feedback item, READ-6): the instance-scoped
* [activeAlarmId] and the same-process companion [activeRingingId] must
* always move together -- setting one without the other would let
* [stopActiveVerified] read a stale/wrong ring state. Used at every write
* site instead of assigning each field separately.
*/
private fun setActiveIds(id: String?) {
activeAlarmId = id
activeRingingId = id
}
override fun onCreate() {
super.onCreate()
// Same-process companion instance (feedback item 1, RISK-1/RES-1/REL-2):
// lets stopActiveVerified() call stopEverything() SYNCHRONOUSLY instead
// of trusting an async startService dispatch to have completed.
instance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
when (action) {
ACTION_STOP -> {
stopAlarm(requestedId)
return START_NOT_STICKY
}
ACTION_STOP_ACTIVE -> {
// Id-agnostic fail-safe stop (Decision 1): silences whatever is
// ringing regardless of the id the caller passed (or omitted).
// Used by the ringing UI and the notification Stop action so a
// stop request can never silently no-op a live ring.
stopEverything()
return START_NOT_STICKY
}
ACTION_SNOOZE -> {
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
if (requestedId != null) {
val snoozed = AlarmScheduler(this).snooze(requestedId, minutes)
if (snoozed != null) {
// D1 fix (Decision 2.1): report the native snooze back to
// Flutter so the canonical config records it. If the engine
// is dead this is a no-op and the cold-start sync
// (getNativeSnoozeState) reconciles on next launch.
MainActivity.notifyAlarmEvent(
mapOf(
"alarmId" to requestedId,
"alarmTitle" to snoozed.title,
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
"snoozeMinutes" to minutes
)
)
}
}
stopAlarm(requestedId)
return START_NOT_STICKY
}
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
}
return START_NOT_STICKY
}
private fun startAlarm(intent: Intent?) {
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
if (activeAlarmId != null) {
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
// Orphaned firing record fix (RES-2): the newcomer's own firing
// record + auto-silence were already armed by onAlarmFired before
// this refusal, so they must be cleared here or a false MISSED
// fires 10 minutes later for an alarm that never actually rang.
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
return
}
// onStartCommand re-validation (Decision 4): a redelivered/resurrected
// start for a firing record older than the auto-silence bound must
// never resume audio -- treat it as an already-missed ring instead.
val scheduler = AlarmScheduler(this)
val firingAge = scheduler.firingRecordAgeMillis(alarmId)
if (firingAge != null && firingAge > AlarmScheduler.AUTO_SILENCE_MILLIS) {
Log.w(TAG, "alarm.service startAlarm stale firing record id=$alarmId ageMs=$firingAge; treating as missed")
scheduler.onAlarmMissed(alarmId)
stopSelf()
return
}
// Durable firing record (Decision 4): written before MediaPlayer.start()
// (via startAudio below) so a process death mid-ring leaves proof the
// ring was in flight for the re-validation above / boot cleanup.
scheduler.recordFiring(alarmId)
setActiveIds(alarmId)
// Anchor the fade curve at RING start, not audio start (design D2):
// every source in the 3-stage fallback chain shares this ONE clock,
// so a source that begins mid-fade (e.g. after a station timeout)
// joins at the already-elapsed gain instead of restarting from
// silence (Requirement: Exponential dB fade-in ceiling).
fadeAnchorElapsedMs = SystemClock.elapsedRealtime()
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
val fallbackStationName =
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME)
val fallbackStationUrl =
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL)
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
val fadeInSegundos =
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60)
val snoozeMinutes = sanitizeSnoozeMinutes(
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
acquireWakeLock()
// The FSI notification must be visible BEFORE audio prepares (prepareAsync is
// slow); startForeground runs first so the ringing surface never lags audio.
try {
val notification = buildNotification(alarmId, title, stationName, snoozeMinutes)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
} catch (error: Throwable) {
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
releaseWakeLock()
// Second documented clear site (feedback item, READ-5): this
// branch never reaches stopEverything(), so without the same
// cleanup below the receiver-armed auto-silence timer + durable
// firing record for alarmId would survive and fire a ghost
// MISSED notification ~10 minutes later for a ring that never
// actually started.
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
setActiveIds(null)
stopSelf()
return
}
startAudio(
alarmId,
stationName,
stationUrl,
fallbackStationName,
fallbackStationUrl,
fallbackSound,
volume,
fadeInSegundos
)
}
private fun startAudio(
alarmId: String,
stationName: String?,
stationUrl: String?,
fallbackStationName: String?,
fallbackStationUrl: String?,
fallbackSound: String?,
volume: Float,
fadeInSegundos: Int
) {
player?.release()
player = null
requestAlarmAudioFocus()
startFadeLoop(alarmId, volume, fadeInSegundos)
// Three-stage ordered fallback: primary station -> fallback station -> bundled WAV.
// Each stage owns its own 15s timeout window via scheduleStationFallback.
val startBundled: (String) -> Unit = { reason ->
startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason)
}
val startFallbackStation: (String) -> Unit = { reason ->
if (fallbackStationUrl.isNullOrBlank()) {
startBundled(reason)
} else {
startStationAudio(
alarmId,
fallbackStationName,
fallbackStationUrl.trim(),
volume,
fadeInSegundos,
"fallback-station",
startBundled
)
}
}
if (stationUrl.isNullOrBlank()) {
startFallbackStation("station url missing")
return
}
startStationAudio(
alarmId,
stationName,
stationUrl.trim(),
volume,
fadeInSegundos,
"station",
startFallbackStation
)
}
private fun startStationAudio(
alarmId: String,
stationName: String?,
stationUrl: String,
volume: Float,
fadeInSegundos: Int,
stage: String,
onStageFailed: (String) -> Unit
) {
player?.release()
player = null
scheduleStationFallback(alarmId, stage, onStageFailed)
val startVolume = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = false
setVolume(startVolume, startVolume)
setDataSource(
this@PluriWaveAlarmService,
Uri.parse(stationUrl),
mapOf("User-Agent" to "PluriWave/0.1.0 (native alarm)")
)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
cancelStationFallback()
// Recompute at prepare-time (not the stale value captured
// before prepareAsync): buffering can take seconds, during
// which the fade clock keeps advancing. Setting volume
// BEFORE start() avoids an audible pop (Requirement:
// No-fade path starts pop-free; same principle applies
// mid-fade).
val current = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
it.setVolume(current, current)
it.start()
Log.d(
TAG,
"alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl"
)
}
setOnCompletionListener {
if (activeAlarmId != alarmId) return@setOnCompletionListener
Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl")
onStageFailed("$stage completed")
}
setOnErrorListener { mp, what, extra ->
Log.e(
TAG,
"alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl"
)
runCatching { mp.reset() }
if (activeAlarmId == alarmId) {
onStageFailed("$stage error")
}
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service $stage preparing id=$alarmId station=$stationName url=$stationUrl")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error)
onStageFailed("$stage prepare failed")
}
}
private fun startFallbackAudio(
alarmId: String,
fallbackSound: String?,
volume: Float,
fadeInSegundos: Int,
reason: String
) {
cancelStationFallback()
player?.release()
player = null
val source = fallbackAssetPath(fallbackSound)
val startVolume = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = true
setVolume(startVolume, startVolume)
setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
// Recompute at prepare-time; see the matching comment in
// startStationAudio's setOnPreparedListener.
val current = computeFadeVolume(
SystemClock.elapsedRealtime() - fadeAnchorElapsedMs,
fadeInSegundos * 1000L,
volume
)
it.setVolume(current, current)
it.start()
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "alarm.service fallback error id=$alarmId what=$what extra=$extra source=$source")
mp.reset()
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service fallback preparing id=$alarmId source=$source reason=$reason")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service fallback prepare failed id=$alarmId source=$source", error)
}
}
private fun scheduleStationFallback(
alarmId: String,
stage: String,
onStageFailed: (String) -> Unit
) {
cancelStationFallback()
val runnable = Runnable {
if (activeAlarmId == alarmId) {
Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain")
onStageFailed("$stage timeout")
}
}
stationFallbackRunnable = runnable
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
}
/**
* Single ring-anchored fade loop (Requirement: Exponential dB fade-in
* ceiling; design D1). Ticks every [FADE_TICK_MILLIS] and reads [player]
* FRESH on each tick -- not a captured MediaPlayer reference -- so the
* SAME loop survives the 3-stage source swap (station -> fallback
* station -> bundled WAV) instead of needing a fresh ramp per source.
* Guarded by [activeAlarmId] so a stale loop from a superseded ring can
* never touch a new one's player. Stops rescheduling once elapsed
* reaches the fade window; further ticks would be redundant since
* [computeFadeVolume] already clamps to the ceiling past that point.
*/
private fun startFadeLoop(alarmId: String, ceiling: Float, fadeInSegundos: Int) {
cancelFadeLoop()
if (fadeInSegundos <= 0) return
val fadeMs = fadeInSegundos * 1000L
val runnable = object : Runnable {
override fun run() {
if (activeAlarmId != alarmId) return
val elapsed = SystemClock.elapsedRealtime() - fadeAnchorElapsedMs
val current = computeFadeVolume(elapsed, fadeMs, ceiling)
runCatching { player?.setVolume(current, current) }
if (elapsed < fadeMs) {
mainHandler.postDelayed(this, FADE_TICK_MILLIS)
}
}
}
fadeLoopRunnable = runnable
mainHandler.postDelayed(runnable, FADE_TICK_MILLIS)
Log.d(TAG, "alarm.service fade loop started id=$alarmId seconds=$fadeInSegundos")
}
private fun cancelFadeLoop() {
fadeLoopRunnable?.let { mainHandler.removeCallbacks(it) }
fadeLoopRunnable = null
}
private fun cancelStationFallback() {
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
stationFallbackRunnable = null
}
private fun alarmAudioAttributes(): AudioAttributes =
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
private fun stopAlarm(alarmId: String?) {
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
// Scope the teardown to the alarm that is actually ringing: a stop
// request for a DIFFERENT id (e.g. a second alarm firing while this
// one rings — Dart hides the newcomer's notification, which routes
// through ACTION_STOP with the newcomer's id) must not kill the
// active ring, release its wake lock, or prematurely restore the
// device volume. Only the id-specific notification cancel below is
// honored for the mismatched id. A null alarmId (internal callers,
// onDestroy) keeps full-teardown semantics.
if (alarmId != null && activeAlarmId != null && alarmId != activeAlarmId) {
Log.d(
TAG,
"alarm.service stop ignored for id=$alarmId (active=$activeAlarmId)"
)
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
)
// Orphaned firing record fix (RES-2): this mismatched id is not
// being torn down by stopEverything() below (that only tears down
// activeAlarmId), so its own firing record + auto-silence must be
// cleared here to avoid a false MISSED 10 minutes later.
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(alarmId)
scheduler.cancelAutoSilence(alarmId)
return
}
stopEverything()
}
/**
* Atomic full teardown (Decision 2, NA "Atomic Stop Coupling"): every stop
* entry point (ACTION_STOP id-match/null, ACTION_STOP_ACTIVE, ACTION_SNOOZE
* via [stopAlarm], onDestroy via [stopAlarm]) funnels through this ONE
* method so no path can perform a partial teardown. Id-agnostic by design:
* it always tears down whatever [activeAlarmId] currently is.
*/
private fun stopEverything() {
val stoppingId = activeAlarmId
cancelStationFallback()
cancelFadeLoop()
try {
player?.stop()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service stop player failed", error)
}
try {
player?.release()
} catch (error: Throwable) {
// Non-atomic release fix (RES-4): a throw here must not abort the
// rest of the teardown below (state reset, wakelock, firing-record
// clear, stopForeground, stopSelf all still need to run).
Log.w(TAG, "alarm.service release player failed", error)
}
player = null
setActiveIds(null)
releaseWakeLock()
abandonAlarmAudioFocus()
if (stoppingId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(stoppingId)
)
val scheduler = AlarmScheduler(this)
scheduler.clearFiringRecord(stoppingId)
scheduler.cancelAutoSilence(stoppingId)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
private fun buildNotification(
alarmId: String,
title: String,
stationName: String?,
snoozeMinutes: Int
) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_pluriwave)
.setColor(NotificationBrand.CYAN)
.setContentTitle(AlarmNotificationStrings.ringTitle(this))
.setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName"
)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, AlarmNotificationStrings.snoozeLabel(this), snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, AlarmNotificationStrings.stopLabel(this), stopPendingIntent(alarmId))
.build()
private fun openAlarmPendingIntent(
alarmId: String,
title: String,
snoozeMinutes: Int
): PendingIntent =
PendingIntent.getActivity(
this,
requestCode(alarmId, 20),
Intent(this, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun stopPendingIntent(alarmId: String): PendingIntent =
PendingIntent.getService(
this,
requestCode(alarmId, 21),
Intent(this, PluriWaveAlarmService::class.java).apply {
// Fail-safe fix (feedback item 1, SS-4a/NA-1a): the notification
// Stop action must route through the id-agnostic stop so it can
// never no-op a live ring; the extra id is kept only for logs.
action = ACTION_STOP_ACTIVE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun snoozePendingIntent(alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
this,
requestCode(alarmId, 30 + minutes),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_SNOOZE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"PluriWave:AlarmWakeLock"
).apply {
setReferenceCounted(false)
acquire(10 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
try {
if (wakeLock?.isHeld == true) wakeLock?.release()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service wakeLock release failed", error)
}
wakeLock = null
}
/**
* Requests transient alarm-scoped audio focus (Requirement: Manual
* transient focus; no system volume writes; design D3). Manual instead
* of relying on MediaPlayer's implicit focus handling so the service
* keeps STREAM_ALARM audible without ever writing another app's stream
* volume. AUDIOFOCUS_GAIN_TRANSIENT signals "temporary, give it back
* when I'm done" -- the OS pauses/ducks other playback for the ring and
* resumes it automatically once focus is abandoned. No-op listener:
* this service never reacts to focus loss (an alarm should keep
* ringing regardless of what else wants focus).
*/
private fun requestAlarmAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(alarmAudioAttributes())
.setOnAudioFocusChangeListener(noopAudioFocusChangeListener)
.build()
audioFocusRequest = request
audioManager.requestAudioFocus(request)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
noopAudioFocusChangeListener,
AudioManager.STREAM_ALARM,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT
)
}
}
/** Abandons the focus request from [requestAlarmAudioFocus]; a safe no-op if none is held. */
private fun abandonAlarmAudioFocus() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(noopAudioFocusChangeListener)
}
}
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
val path = fallbackAssetPath(sound)
try {
val descriptor = assets.openFd(path)
mediaPlayer.setDataSource(
descriptor.fileDescriptor,
descriptor.startOffset,
descriptor.length
)
descriptor.close()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
val cached = File(cacheDir, path.substringAfterLast('/'))
assets.open(path).use { input ->
cached.outputStream().use { output -> input.copyTo(output) }
}
mediaPlayer.setDataSource(cached.absolutePath)
}
}
private fun fallbackAssetPath(sound: String?): String {
val fileName = when (sound) {
"campanaSuave" -> "alarm_campana_suave.wav"
"pulsoDigital" -> "alarm_pulso_digital.wav"
else -> "alarm_amanecer.wav"
}
return "flutter_assets/assets/audio/$fileName"
}
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
override fun onDestroy() {
stopAlarm(activeAlarmId)
if (instance === this) instance = null
super.onDestroy()
}
companion object {
private const val TAG = "PluriWave"
private const val CHANNEL_ID = "pluriwave_alarm_fire_v3"
private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native"
private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire"
private const val LEGACY_CHANNEL_FIRE_V2 = "pluriwave_alarm_fire_v2"
private const val CHANNELS_PREFS = "pluriwave_alarm_channels"
private const val KEY_CHANNELS_MIGRATED_V3 = "channels_migrated_v3"
private const val NOTIFICATION_ID = 92841
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
const val ACTION_STOP_ACTIVE = "es.freetimelab.pluriwave.alarm.STOP_ACTIVE_NATIVE"
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
/**
* Same-process companion snapshot (Decision 1): `MainActivity` reads
* this synchronously (no service round-trip) to build a verified stop
* result. Always written together with the instance-scoped
* [activeAlarmId] through the paired [setActiveIds] helper (feedback
* item, READ-6) -- always the id ACTUALLY ringing, never a
* caller-supplied one. Set in [startAlarm]; cleared in TWO documented
* sites -- [stopEverything] (confirmed stop/teardown) AND
* [startAlarm]'s own startForeground-failure catch (feedback item,
* READ-5), which never reaches [stopEverything] but must still clear
* the ids for the ring that never actually started.
*/
@Volatile
var activeRingingId: String? = null
/**
* Same-process companion reference (feedback item 1, RISK-1/RES-1/REL-2):
* set in [onCreate], cleared in [onDestroy]. Lets [stopActiveVerified]
* call [stopEverything] synchronously instead of trusting an async
* startService dispatch to have completed before reporting a result.
*/
@Volatile
private var instance: PluriWaveAlarmService? = null
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
private const val FADE_TICK_MILLIS = 50L
private const val FADE_RANGE_DB = 40.0f
/**
* DeskClock-style exponential fade curve (AOSP AsyncRingtonePlayer /
* VolumeShaper reference shape -- reimplemented here on a plain
* Handler tick since MediaPlayer.setVolume takes a linear [0,1] gain
* and this service targets API levels below VolumeShaper's API 26
* floor). Volume rises from near-silence to [ceiling] over [fadeMs]
* along a DECIBEL ramp, not a linear amplitude ramp, so the rise
* SOUNDS smooth: human loudness perception is logarithmic, and a
* linear amplitude ramp sounds like it "arrives late" and jumps at
* the end. At elapsedMs<=0 the gain is -40dB (~1% of ceiling); at
* elapsedMs>=fadeMs the gain is 0dB (exactly ceiling). Pure
* function -- no side effects -- so it is safe to call from a timer
* tick, a prepare-time recompute, or a construction-time seed alike.
*/
private fun computeFadeVolume(elapsedMs: Long, fadeMs: Long, ceiling: Float): Float {
if (fadeMs <= 0) return ceiling.coerceIn(0f, 1f)
val fraction = (elapsedMs.toFloat() / fadeMs.toFloat()).coerceIn(0f, 1f)
val gainDb = fraction * FADE_RANGE_DB - FADE_RANGE_DB
val curve = Math.pow(10.0, (gainDb / 20.0).toDouble()).toFloat()
return (ceiling * curve).coerceIn(0f, 1f)
}
fun start(context: Context, source: Intent) {
ensureChannel(context)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
}
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service start failed", error)
}
}
fun stop(context: Context, alarmId: String) {
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
}
try {
context.startService(intent)
Log.d(TAG, "alarm.service stop action requested id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
try {
context.stopService(intent)
} catch (fallbackError: Throwable) {
Log.e(TAG, "alarm.service stop fallback failed id=$alarmId", fallbackError)
}
}
}
/** Id-agnostic fail-safe stop (Decision 1): silences whatever is ringing. */
fun stopActive(context: Context) {
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP_ACTIVE
}
try {
context.startService(intent)
Log.d(TAG, "alarm.service stopActive action requested")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stopActive request failed", error)
try {
context.stopService(intent)
} catch (fallbackError: Throwable) {
Log.e(TAG, "alarm.service stopActive fallback failed", fallbackError)
}
}
}
/**
* Same-process VERIFIED stop (feedback item 1, RISK-1/RES-1/REL-2):
* fixes the hollow verification where [stopActive]'s async
* startService dispatch made the result a literal `true` decided
* before teardown ran. When a live [instance] exists, invokes
* [stopEverything] on it SYNCHRONOUSLY (the MethodChannel caller and
* this service both run on the main thread of the SAME process, so
* no round trip is needed) and returns whether teardown actually
* cleared [activeRingingId]. Falls back to the async [stopActive]
* dispatch only when no instance is alive -- nothing can be ringing
* without a live instance, so [activeRingingId] is already null and
* the fallback trivially succeeds.
*/
fun stopActiveVerified(context: Context): Boolean {
val current = instance
if (current != null) {
current.stopEverything()
return activeRingingId == null
}
stopActive(context)
return activeRingingId == null
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
migrateLegacyChannels(context, manager)
// Re-create each time (not early-returning when present) so the
// localized name/description refresh after a locale change. Android
// updates name + description on an existing channel; importance and
// sound stay fixed from first creation. setSound(null, null) is
// REQUIRED for silence: omitting the call leaves the platform
// DEFAULT notification sound on the channel (same reason the
// pre-notice channel calls it explicitly). This channel must be
// silent (Requirement: Fire notification posts with no sound) --
// the native MediaPlayer on STREAM_ALARM is the only audible
// source, so a channel sound would double it.
val channel = NotificationChannel(
CHANNEL_ID,
AlarmNotificationStrings.fireChannelName(context),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = AlarmNotificationStrings.fireChannelDescription(context)
setSound(null, null)
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
// Android locks channel sound/importance at creation time, so the
// only way to apply a changed shape (USAGE_ALARM in v2, silent in v3)
// on existing installs is deleting the legacy channels and recreating
// under a new versioned id. Runs once, guarded by a flag;
// deleteNotificationChannel is a safe no-op for an id that was never
// created (fresh installs) or already deleted (re-runs).
private fun migrateLegacyChannels(context: Context, manager: NotificationManager) {
val prefs = context.createDeviceProtectedStorageContext()
.getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE)
if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V3, false)) return
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) }
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) }
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE_V2) }
prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V3, true).apply()
Log.d(TAG, "alarm.service legacy notification channels migrated to v3")
}
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
}
}
@@ -0,0 +1,28 @@
package es.freetimelab.pluriwave
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
class PluriWaveBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_LOCKED_BOOT_COMPLETED,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_USER_UNLOCKED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" -> {
Log.d(TAG, "alarm.bootReceiver action=${intent.action}")
AlarmScheduler(context).reschedulePersistedAlarms()
}
else -> Log.w(TAG, "alarm.bootReceiver unknown action=${intent.action}")
}
}
companion object {
private const val TAG = "PluriWave"
}
}
@@ -0,0 +1,325 @@
package es.freetimelab.pluriwave
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import android.util.Log
import android.webkit.MimeTypeMap
import java.io.File
import java.io.FileNotFoundException
/**
* Publishes the radio-recordings folder as a storage root the system file
* manager can browse, WITHOUT moving a single file out of app-private storage.
*
* Why this exists: the recordings live under
* `getApplicationDocumentsDirectory()/grabaciones`
* (`/data/user/0/es.freetimelab.pluriwave/app_flutter/grabaciones`). The Android
* sandbox forbids any other app -- including the system Files app -- from
* reading that path, so no `ACTION_VIEW` on a `file://` or `FileProvider` URI
* can ever open it. A `DocumentsProvider` is the only supported way to expose
* private files to the document framework: we stay the owner of the bytes and
* the system asks US for them, one document at a time.
*
* The root is browsable, readable, writable, renameable and deletable so the
* user can do whatever they want with their recordings (copy out, share, delete,
* open in another player) straight from the file manager.
*
* Static-review-only component: it runs in the app process but is driven
* entirely by the platform's document framework, so it has no Dart unit tests.
* See MainActivity.viewDirectory for the intents that open it.
*/
class RecordingsDocumentsProvider : DocumentsProvider() {
companion object {
private const val TAG = "PluriWave"
/** Root id and document id of the exposed folder itself. */
const val ROOT_ID = "recordings"
/**
* Remembers the folder Flutter is actually recording into. Written on
* every open-folder request so a user-configured path is honoured, and
* read back by [rootDirectory] when the platform enumerates roots (which
* can happen with no Activity alive).
*/
private const val PREFS = "pluriwave_recordings_root"
private const val KEY_PATH = "path"
/**
* Mirrors path_provider's `getApplicationDocumentsDirectory()` on
* Android (`context.getDir("flutter", MODE_PRIVATE)`) plus the
* `grabaciones` subfolder appended by
* `ServicioGrabacionRadio.directorioEfectivo()`. Used until Flutter has
* reported the effective path at least once.
*/
private fun defaultDirectory(context: Context): File =
File(context.getDir("flutter", Context.MODE_PRIVATE), "grabaciones")
fun authority(context: Context): String = "${context.packageName}.recordings"
/** `ACTION_VIEW` target that opens the file manager at this root. */
fun rootUri(context: Context): Uri =
DocumentsContract.buildRootUri(authority(context), ROOT_ID)
/** `ACTION_VIEW` target for the root folder as a document. */
fun rootDocumentUri(context: Context): Uri =
DocumentsContract.buildDocumentUri(authority(context), ROOT_ID)
/** `EXTRA_INITIAL_URI` target for the `ACTION_OPEN_DOCUMENT_TREE` fallback. */
fun rootTreeUri(context: Context): Uri =
DocumentsContract.buildTreeDocumentUri(authority(context), ROOT_ID)
/**
* Points the published root at [path] and tells the framework to
* refresh, so a folder change in Settings is reflected in the file
* manager. No-op when the path is unchanged.
*/
fun rememberRoot(context: Context, path: String) {
val app = context.applicationContext
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (prefs.getString(KEY_PATH, null) == path) return
prefs.edit().putString(KEY_PATH, path).apply()
try {
app.contentResolver.notifyChange(
DocumentsContract.buildRootsUri(authority(app)),
null
)
} catch (error: Throwable) {
Log.w(TAG, "recordings_provider notifyChange failed", error)
}
}
/** The directory currently published as [ROOT_ID], created if missing. */
fun rootDirectory(context: Context): File {
val app = context.applicationContext
val stored = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_PATH, null)
?.takeIf { it.isNotBlank() }
val directory = if (stored != null) File(stored) else defaultDirectory(app)
if (!directory.exists()) directory.mkdirs()
return directory
}
private val ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_DOCUMENT_ID,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
)
private val DOCUMENT_COLUMNS = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_SIZE,
Document.COLUMN_LAST_MODIFIED,
Document.COLUMN_FLAGS,
)
}
/**
* [DocumentsProvider.getContext] is nullable only before `onCreate`.
* Not named requireContext: ContentProvider.requireContext() is API 30 and
* minSdk is 24.
*/
private fun resolveContext(): Context =
requireNotNull(context) { "provider context unavailable" }
override fun onCreate(): Boolean = true
override fun queryRoots(projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: ROOT_COLUMNS)
val context = resolveContext()
// Ensure the folder exists before the file manager lists an empty root.
rootDirectory(context)
cursor.newRow().apply {
add(Root.COLUMN_ROOT_ID, ROOT_ID)
add(Root.COLUMN_DOCUMENT_ID, ROOT_ID)
// The file manager renders title as the primary label and summary
// below it, so the brand identifies the source and the localized
// folder name says what it holds.
add(Root.COLUMN_TITLE, appLabel(context))
add(Root.COLUMN_SUMMARY, AlarmNotificationStrings.recordingsRootTitle(context))
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(
Root.COLUMN_FLAGS,
Root.FLAG_LOCAL_ONLY or
Root.FLAG_SUPPORTS_CREATE or
Root.FLAG_SUPPORTS_IS_CHILD
)
}
return cursor
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
addRow(cursor, resolve(documentId), documentId)
return cursor
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?,
): Cursor {
val cursor = MatrixCursor(projection ?: DOCUMENT_COLUMNS)
val parent = resolve(parentDocumentId)
// Newest recording first: it is the one the user just made.
val children = parent.listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
for (child in children) {
addRow(cursor, child, documentIdFor(child))
}
return cursor
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean =
documentId != parentDocumentId &&
documentId.startsWith(
if (parentDocumentId == ROOT_ID) "$ROOT_ID/" else "$parentDocumentId/"
)
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = resolve(documentId)
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode))
}
override fun createDocument(
parentDocumentId: String,
mimeType: String,
displayName: String,
): String {
val parent = resolve(parentDocumentId)
val target = uniqueChild(parent, displayName)
val created =
if (Document.MIME_TYPE_DIR == mimeType) target.mkdir() else target.createNewFile()
if (!created) {
throw FileNotFoundException("could not create $displayName in $parentDocumentId")
}
notifyParent(parentDocumentId)
return documentIdFor(target)
}
override fun deleteDocument(documentId: String) {
val file = resolve(documentId)
if (!file.deleteRecursively()) {
throw FileNotFoundException("could not delete $documentId")
}
notifyParent(parentDocumentIdOf(documentId))
}
override fun renameDocument(documentId: String, displayName: String): String {
val file = resolve(documentId)
val target = File(file.parentFile, displayName)
if (target.exists() || !file.renameTo(target)) {
throw FileNotFoundException("could not rename $documentId to $displayName")
}
notifyParent(parentDocumentIdOf(documentId))
return documentIdFor(target)
}
override fun getDocumentType(documentId: String): String = mimeTypeOf(resolve(documentId))
private fun appLabel(context: Context): String =
context.applicationInfo.loadLabel(context.packageManager).toString()
private fun addRow(cursor: MatrixCursor, file: File, documentId: String) {
val isDirectory = file.isDirectory
var flags =
if (isDirectory) Document.FLAG_DIR_SUPPORTS_CREATE else Document.FLAG_SUPPORTS_WRITE
flags = flags or Document.FLAG_SUPPORTS_DELETE or Document.FLAG_SUPPORTS_RENAME
cursor.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, documentId)
add(
Document.COLUMN_DISPLAY_NAME,
if (documentId == ROOT_ID) {
AlarmNotificationStrings.recordingsRootTitle(resolveContext())
} else {
file.name
}
)
add(Document.COLUMN_MIME_TYPE, mimeTypeOf(file))
add(Document.COLUMN_SIZE, file.length())
add(Document.COLUMN_LAST_MODIFIED, file.lastModified())
add(Document.COLUMN_FLAGS, flags)
}
}
/**
* Maps a document id back to a file, refusing anything that escapes the
* published root -- a caller-supplied id must never reach a sibling of the
* recordings folder via `..` segments.
*/
private fun resolve(documentId: String): File {
val root = rootDirectory(resolveContext())
if (documentId == ROOT_ID) return root
if (!documentId.startsWith("$ROOT_ID/")) {
throw FileNotFoundException("unknown document id $documentId")
}
val relative = documentId.removePrefix("$ROOT_ID/")
val target = File(root, relative).canonicalFile
val rootPath = root.canonicalPath
if (target.path != rootPath && !target.path.startsWith("$rootPath${File.separator}")) {
throw FileNotFoundException("document id escapes the root: $documentId")
}
if (!target.exists()) throw FileNotFoundException("missing document $documentId")
return target
}
private fun documentIdFor(file: File): String {
val rootPath = rootDirectory(resolveContext()).canonicalPath
val filePath = file.canonicalPath
if (filePath == rootPath) return ROOT_ID
return "$ROOT_ID/${filePath.removePrefix("$rootPath${File.separator}").replace(File.separatorChar, '/')}"
}
private fun parentDocumentIdOf(documentId: String): String =
documentId.substringBeforeLast('/', ROOT_ID).takeIf { it.isNotBlank() } ?: ROOT_ID
private fun notifyParent(parentDocumentId: String) {
try {
val ctx = resolveContext()
ctx.contentResolver.notifyChange(
DocumentsContract.buildChildDocumentsUri(authority(ctx), parentDocumentId),
null
)
} catch (error: Throwable) {
Log.w(TAG, "recordings_provider notifyChange failed parent=$parentDocumentId", error)
}
}
/** Appends ` (n)` before the extension until the name is free. */
private fun uniqueChild(parent: File, displayName: String): File {
var candidate = File(parent, displayName)
if (!candidate.exists()) return candidate
val dot = displayName.lastIndexOf('.')
val base = if (dot > 0) displayName.substring(0, dot) else displayName
val extension = if (dot > 0) displayName.substring(dot) else ""
var index = 1
while (candidate.exists()) {
candidate = File(parent, "$base ($index)$extension")
index++
}
return candidate
}
private fun mimeTypeOf(file: File): String {
if (file.isDirectory) return Document.MIME_TYPE_DIR
val extension = file.extension.lowercase()
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
?: "application/octet-stream"
}
}
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M7,18h2L9,6L7,6v12zM3,14h2v-4L3,10v4zM11,20h2L13,4h-2v16zM19,10v4h2v-4h-2zM15,18h2L17,6h-2v12z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,3 @@
<automotiveApp>
<uses name="media"/>
</automotiveApp>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
network_security_config.xml
Permite tráfico HTTP cleartext para streams de radio que no soporten HTTPS.
Fix para: "Cleartext HTTP traffic to [host] not permitted" en ExoPlayer.
-->
<network-security-config>
<!-- Permitir HTTP cleartext para streams de radio -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<!-- Certificados del sistema (CA reconocidas) -->
<certificates src="system"/>
<!-- Certificados de usuario (para desarrollo) -->
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="files"
path="." />
<!--
path_provider's getApplicationDocumentsDirectory() maps to
context.getDir("flutter") -> <data>/app_flutter, a sibling of files/ that
no FileProvider tag covers directly. Without this root,
getUriForFile() throws for every radio recording and "open last
recording" fails. FileProvider canonicalizes roots, so the ../ hop
resolves to <data>/app_flutter.
-->
<files-path
name="app_flutter"
path="../app_flutter/" />
<cache-path
name="cache"
path="." />
<external-files-path
name="external_files"
path="." />
<external-cache-path
name="external_cache"
path="." />
</paths>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
# أهلاً بك في PluriWave
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
## راديو مباشر
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
- استكشف المحطات القريبة واكتشف محطات جديدة.
- رتّب القوائم حسب الاسم أو الجودة.
## موسيقى بطريقتك
- احفظ المفضلات ونظّمها في مجموعات.
- اضبط المعادل العام أو إعدادات كل محطة.
- استخدم مؤقّت النوم بمدد مخصّصة.
## التسجيلات
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
- حدّد الحجم الأقصى للملف لتبقى بأمان.
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
## منبّهات موسيقية
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave-এ স্বাগতম
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
## লাইভ রেডিও
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
- তালিকা নাম বা মান অনুযায়ী সাজান।
## আপনার মতো করে সঙ্গীত
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
## রেকর্ডিং
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
## মিউজিক অ্যালার্ম
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
+27
View File
@@ -0,0 +1,27 @@
# Willkommen bei PluriWave
PluriWave ist Ihr Premium-Weltradio: Live-Sender, organisierte Favoriten, Aufnahmen, Equalizer und Musikalarme in einer sorgfältig gestalteten Erfahrung.
## Live-Radio
- Suche nach Sendern nach Name, Land, Sprache und Qualität.
- Entdecke Sender in der Nähe und finde neue Radios.
- Sortiere Listen nach Name oder Qualität.
## Musik auf deine Art
- Speichere Favoriten und organisiere sie in Gruppen.
- Stelle den globalen Equalizer oder Sender-Presets ein.
- Nutze den Sleep-Timer mit eigenen Laufzeiten.
## Aufnahmen
- Nimm Radio auf, ohne den Original-Stream neu zu komprimieren.
- Begrenze die maximale Dateigröße für mehr Sicherheit.
- Öffne den Aufnahmeordner zum Teilen, Verschieben oder Bearbeiten von Dateien.
## Musikalarme
- Erstelle einmalige, tägliche oder Wochentags-Alarme.
- Wähle einen Lieblingssender und einen sicheren internen Ton.
- Nutze Feiertage, "nächste Ausführung überspringen" und Snooze.
+27
View File
@@ -0,0 +1,27 @@
# Welcome to PluriWave
PluriWave is your premium world radio: live stations, organized favorites, recordings, equalizer and musical alarms in a carefully crafted experience.
## Live radio
- Search stations by name, country, language and quality.
- Explore nearby stations and discover new radio.
- Sort lists by name or quality.
## Music your way
- Save favorites and organize them into groups.
- Tune the global equalizer or per-station presets.
- Use the sleep timer with custom durations.
## Recordings
- Record radio without recompressing the original stream.
- Limit maximum file size to stay safe.
- Open the recordings folder to share, move or edit files.
## Musical alarms
- Create one-time, daily or weekday alarms.
- Choose a favorite station and a safe internal sound.
- Use holidays, skip-next execution and snooze.
+27
View File
@@ -0,0 +1,27 @@
# Bienvenido a PluriWave
PluriWave es tu radio mundial premium: emisoras en directo, favoritos organizados, grabaciones, ecualizador y alarmas musicales en una experiencia cuidada.
## Radio en vivo
- Buscá emisoras por nombre, país, idioma y calidad.
- Explorá emisoras cercanas y descubrí radios nuevas.
- Ordená listas por nombre o calidad.
## Música a tu manera
- Guardá favoritos y organizalos en grupos.
- Ajustá el ecualizador global o los presets por emisora.
- Usá el temporizador de sueño con duraciones personalizadas.
## Grabaciones
- Grabá radio sin recomprimir el stream original.
- Limitá el tamaño máximo del archivo para evitar sustos.
- Abrí la carpeta de grabaciones para compartir, mover o editar archivos.
## Alarmas musicales
- Creá alarmas únicas, diarias o por días de semana.
- Elegí una emisora favorita y un sonido interno seguro.
- Usá vacaciones, omitir la próxima ejecución y posponer.
+27
View File
@@ -0,0 +1,27 @@
# Bienvenue dans PluriWave
PluriWave est votre radio mondiale premium : stations en direct, favoris organisés, enregistrements, égaliseur et alarmes musicales dans une expérience soignée.
## Radio en direct
- Recherchez des stations par nom, pays, langue et qualité.
- Explorez les stations proches et découvrez de nouvelles radios.
- Triez les listes par nom ou qualité.
## Votre musique, votre style
- Enregistrez vos favoris et organisez-les en groupes.
- Réglez l'égaliseur global ou des préréglages par station.
- Utilisez le minuteur de sommeil avec des durées personnalisées.
## Enregistrements
- Enregistrez la radio sans recompresser le flux d'origine.
- Limitez la taille maximale des fichiers pour rester serein.
- Ouvrez le dossier des enregistrements pour partager, déplacer ou modifier des fichiers.
## Alarmes musicales
- Créez des alarmes uniques, quotidiennes ou en semaine.
- Choisissez une station favorite et un son interne sûr.
- Utilisez les vacances, le saut de la prochaine exécution et le snooze.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave में आपका स्वागत है
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
## लाइव रेडियो
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
- पास के स्टेशन देखें और नए रेडियो खोजें।
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
## संगीत आपके तरीके से
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
## रिकॉर्डिंग
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
## संगीत अलार्म
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
+27
View File
@@ -0,0 +1,27 @@
# Selamat datang di PluriWave
PluriWave adalah radio dunia premium Anda: stasiun langsung, favorit terorganisir, rekaman, equalizer, dan alarm musik dalam pengalaman yang dirancang rapi.
## Radio langsung
- Cari stasiun berdasarkan nama, negara, bahasa, dan kualitas.
- Jelajahi stasiun terdekat dan temukan radio baru.
- Urutkan daftar berdasarkan nama atau kualitas.
## Musik sesuai cara Anda
- Simpan favorit dan atur ke dalam grup.
- Atur equalizer global atau preset per stasiun.
- Gunakan sleep timer dengan durasi kustom.
## Rekaman
- Rekam radio tanpa mengompresi ulang stream asli.
- Batasi ukuran file maksimum agar tetap aman.
- Buka folder rekaman untuk berbagi, memindahkan, atau mengedit file.
## Alarm musik
- Buat alarm sekali, harian, atau hari kerja.
- Pilih stasiun favorit dan suara internal yang aman.
- Gunakan hari libur, lewati eksekusi berikutnya, dan snooze.
+27
View File
@@ -0,0 +1,27 @@
# Benvenuto in PluriWave
PluriWave è la tua radio mondiale premium: stazioni live, preferiti organizzati, registrazioni, equalizzatore e sveglie musicali in un'esperienza curata.
## Radio live
- Cerca stazioni per nome, paese, lingua e qualità.
- Esplora le stazioni vicine e scopri nuove radio.
- Ordina le liste per nome o qualità.
## Musica a modo tuo
- Salva i preferiti e organizzali in gruppi.
- Regola l'equalizzatore globale o i preset per stazione.
- Usa il timer di spegnimento con durate personalizzate.
## Registrazioni
- Registra la radio senza ricomprimere il flusso originale.
- Limita la dimensione massima dei file per stare tranquillo.
- Apri la cartella registrazioni per condividere, spostare o modificare i file.
## Sveglie musicali
- Crea sveglie singole, giornaliere o nei giorni feriali.
- Scegli una stazione preferita e un suono interno sicuro.
- Usa ferie, salto della prossima esecuzione e snooze.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave へようこそ
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
## ライブラジオ
- 名前、国、言語、音質で局を検索できます。
- 近くの局を探して新しいラジオを見つけられます。
- リストを名前または音質で並べ替えできます。
## あなた好みの音楽体験
- お気に入りを保存してグループで整理できます。
- 全体イコライザーや局ごとのプリセットを調整できます。
- 時間を指定できるスリープタイマーを使えます。
## 録音
- 元のストリームを再圧縮せずに録音できます。
- 最大ファイルサイズを制限して安全に使えます。
- 録音フォルダーを開いて共有・移動・編集できます。
## 音楽アラーム
- 1回のみ、毎日、平日のアラームを作成できます。
- お気に入り局と安全な内蔵サウンドを選べます。
- 休日設定、次回スキップ、スヌーズに対応しています。
+27
View File
@@ -0,0 +1,27 @@
# Bem-vindo ao PluriWave
PluriWave é seu rádio mundial premium: estações ao vivo, favoritos organizados, gravações, equalizador e alarmes musicais em uma experiência caprichada.
## Rádio ao vivo
- Procure estações por nome, país, idioma e qualidade.
- Explore estações próximas e descubra novas rádios.
- Ordene listas por nome ou qualidade.
## Música do seu jeito
- Salve favoritos e organize em grupos.
- Ajuste o equalizador global ou presets por estação.
- Use o timer de sono com durações personalizadas.
## Gravações
- Grave rádio sem recomprimir o stream original.
- Limite o tamanho máximo dos arquivos para evitar problemas.
- Abra a pasta de gravações para compartilhar, mover ou editar arquivos.
## Alarmes musicais
- Crie alarmes únicos, diários ou de dias úteis.
- Escolha uma estação favorita e um som interno seguro.
- Use feriados, pular próxima execução e soneca.
+27
View File
@@ -0,0 +1,27 @@
# Добро пожаловать в PluriWave
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
## Прямое радио
- Ищите станции по названию, стране, языку и качеству.
- Изучайте ближайшие станции и открывайте новое радио.
- Сортируйте списки по названию или качеству.
## Музыка по-вашему
- Сохраняйте избранное и организуйте его по группам.
- Настраивайте глобальный эквалайзер или пресеты для станций.
- Используйте таймер сна с нужной длительностью.
## Записи
- Записывайте радио без повторного сжатия исходного потока.
- Ограничивайте максимальный размер файла для безопасности.
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
## Музыкальные будильники
- Создавайте разовые, ежедневные или будничные будильники.
- Выбирайте любимую станцию и безопасный встроенный звук.
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
+27
View File
@@ -0,0 +1,27 @@
# 欢迎使用 PluriWave
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
## 直播电台
- 按名称、国家、语言和音质搜索电台。
- 探索附近电台,发现新的广播内容。
- 按名称或音质排序列表。
## 按你的方式听音乐
- 保存收藏并按分组整理。
- 调整全局均衡器或单电台预设。
- 使用可自定义时长的睡眠定时器。
## 录音
- 录制电台时不重新压缩原始流。
- 限制最大文件大小,更安全省心。
- 打开录音文件夹以分享、移动或编辑文件。
## 音乐闹钟
- 创建一次性、每日或工作日闹钟。
- 选择喜爱的电台和安全的内置提示音。
- 支持假期、跳过下次执行和贪睡。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · منبّهات وملفات أكثر موثوقية
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
## التحسينات
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
## উন্নতি
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Zuverlässigere Alarme und Dateien
Zusammenfassung: Wir haben die Android-Alarmbasis verstärkt und das Öffnen eines Ordners klar vom Ändern seines Pfads getrennt.
## Verbesserungen
- Neue native Grundlage für Alarme mit sicherem internem Ton.
- Bessere Diagnose der Android-Berechtigung für exakte Alarme.
- Alarme, die in derselben Minute erstellt werden, werden wegen Sekunden nicht mehr verworfen.
- Das Alarmpanel unterscheidet aktive Alarme von Alarmen ohne gültige nächste Ausführung.
- Ordner öffnen versucht jetzt den gespeicherten Pfad zu öffnen; Pfad ändern ist separat.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · More reliable alarms and files
Summary: we reinforced the Android alarm foundation and clearly separated opening a folder from changing its path.
## Improvements
- New native foundation for alarms with a safe internal sound.
- Better Android exact-alarm permission diagnostics.
- Alarms created in the same minute are no longer discarded because of seconds.
- The alarms panel distinguishes active alarms from alarms without a valid next execution.
- Open folder now tries to open the saved path; change path is separate.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarmas y archivos más fiables
Resumen: reforzamos la base de alarmas Android y separamos claramente abrir carpeta de cambiar ruta.
## Mejoras
- Nueva base nativa para alarmas con sonido interno seguro.
- Mejor diagnóstico de permisos Android para alarmas exactas.
- Las alarmas creadas en el mismo minuto ya no se descartan por segundos.
- El panel de alarmas distingue entre alarmas activas y alarmas sin próxima ejecución válida.
- Abrir carpeta ahora intenta abrir la ruta guardada; cambiar ruta queda separado.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarmes et fichiers plus fiables
Résumé : nous avons renforcé la base des alarmes Android et séparé clairement l'ouverture d'un dossier du changement de chemin.
## Améliorations
- Nouvelle base native pour les alarmes avec un son interne sûr.
- Meilleur diagnostic des permissions Android pour les alarmes exactes.
- Les alarmes créées dans la même minute ne sont plus ignorées à cause des secondes.
- Le panneau d'alarmes distingue les alarmes actives de celles sans prochaine exécution valide.
- Ouvrir le dossier tente désormais d'ouvrir le chemin enregistré ; changer le chemin est séparé.
+12
View File
@@ -0,0 +1,12 @@
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
## सुधार
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
- Android exact-alarm अनुमति के बेहतर निदान।
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarm dan file lebih andal
Ringkasan: kami memperkuat fondasi alarm Android dan memisahkan dengan jelas antara membuka folder dan mengubah jalurnya.
## Peningkatan
- Fondasi native baru untuk alarm dengan suara internal yang aman.
- Diagnostik izin exact-alarm Android yang lebih baik.
- Alarm yang dibuat pada menit yang sama tidak lagi dibuang karena detik.
- Panel alarm membedakan alarm aktif dari alarm tanpa eksekusi berikutnya yang valid.
- Buka folder sekarang mencoba membuka jalur tersimpan; ubah jalur dipisahkan.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Allarmi e file più affidabili
Riepilogo: abbiamo rafforzato la base degli allarmi Android e separato chiaramente l'apertura di una cartella dalla modifica del suo percorso.
## Miglioramenti
- Nuova base nativa per gli allarmi con suono interno sicuro.
- Diagnostica migliore dei permessi Android per gli allarmi esatti.
- Gli allarmi creati nello stesso minuto non vengono più scartati a causa dei secondi.
- Il pannello allarmi distingue gli allarmi attivi da quelli senza prossima esecuzione valida.
- Apri cartella ora prova ad aprire il percorso salvato; cambia percorso è separato.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · より信頼できるアラームとファイル
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
## 改善点
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
- Android の正確なアラーム権限診断を改善。
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarmes e arquivos mais confiáveis
Resumo: reforçamos a base de alarmes do Android e separamos claramente abrir pasta de mudar caminho.
## Melhorias
- Nova base nativa para alarmes com som interno seguro.
- Melhor diagnóstico de permissões Android para alarmes exatos.
- Alarmes criados no mesmo minuto não são mais descartados por causa dos segundos.
- O painel de alarmes distingue alarmes ativos de alarmes sem próxima execução válida.
- Abrir pasta agora tenta abrir o caminho salvo; mudar caminho fica separado.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Более надежные будильники и файлы
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
## Улучшения
- Новая нативная основа будильников с безопасным встроенным звуком.
- Улучшена диагностика разрешений Android для точных будильников.
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · 更可靠的闹钟与文件
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
## 改进
- 闹钟采用新的原生基础,配有安全的内置提示音。
- 改进 Android 精确闹钟权限诊断。
- 同一分钟创建的闹钟不再因秒数被丢弃。
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,5 @@
# PluriWave Night Ocean asset sheet prompt
Generated with built-in image_gen for the Night Ocean Broadcast redesign.
Contents: app mark, station fallback artworks, aurora/waveform banner, and navigation glyph assets using teal/amber/cream over navy with no purple/magenta dominance.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,3 @@
PluriWave AAA mockup generated with image_gen.
Visual direction: midnight-ocean glass, teal/cyan audio waves, coral sunrise accents, warm gold broadcast particles, accessible high contrast, no purple-dominant palette.
Launcher/app icon intentionally preserved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,5 @@
# PluriWave award mockup prompt
Generated with built-in image_gen as the visual target for the premium redesign.
Focus: five mobile screens, dark aurora glassmorphism, cyan/violet/magenta gradients, premium iconography, accessible hierarchy, Home/Search/Favorites/Now Playing/Settings.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,5 @@
# PluriWave Night Ocean mockup prompt
Generated with built-in image_gen after user feedback rejecting purple-heavy futuristic UI.
Direction: Night Ocean Broadcast ? midnight navy and petrol teal base, mint action states, warm amber live/accent, cream text/surfaces, practical radio streaming UX with immediate Now Playing flow.
+27
View File
@@ -0,0 +1,27 @@
# Alarmas Android en PluriWave
PluriWave programa las alarmas con `AlarmManager.setAlarmClock`, porque es el camino Android pensado para despertadores visibles y de alta fiabilidad. Flutter conserva la configuración, la UI, la emisora y los fallbacks; Android se encarga de despertar la app en el momento exacto.
## Flujo
1. Flutter calcula la próxima ejecución según tipo, días, vacaciones y omisiones.
2. `ServicioAlarmasAndroid` envía la programación al `MethodChannel pluriwave/alarm_scheduler`.
3. `AlarmScheduler` registra:
- alarma principal con `setAlarmClock`;
- preaviso silencioso 30 minutos antes con `setExactAndAllowWhileIdle`.
4. `PluriWaveAlarmReceiver` abre la app cuando suena la alarma.
5. Flutter muestra `PantallaAlarmaSonando`, intenta reproducir la emisora y activa audio interno si la radio falla o tarda demasiado.
## Permisos
- `SCHEDULE_EXACT_ALARM`: necesario en Android 12+ para exactitud.
- `POST_NOTIFICATIONS`: necesario en Android 13+ para el preaviso silencioso.
- `WAKE_LOCK` y foreground media playback ya están declarados para la reproducción.
## Fallbacks
Si la emisora no existe, falla o no empieza a reproducir en unos segundos, la pantalla usa sonidos internos incluidos en `assets/audio/`. Esto evita una alarma silenciosa por problemas de red o de radio.
## Vacaciones y omisiones
Las vacaciones se guardan en Flutter. Las alarmas configuradas para pausar en vacaciones saltan automáticamente esos rangos y muestran la próxima fecha válida. El preaviso permite omitir la siguiente ejecución abriendo la app y aplicando la misma lógica de omisión persistente.
+30
View File
@@ -0,0 +1,30 @@
# Arquitectura de alarmas con pantalla apagada
## Diagnóstico
El flujo anterior hacía que Android recibiese la alarma con `AlarmManager`, pero el sonido real dependía de que se abriese `MainActivity` y de que Flutter llegase a pintar `PantallaAlarmaSonando`. Con pantalla apagada, Doze o restricciones del fabricante, ese arranque de UI puede retrasarse hasta que el usuario enciende la pantalla.
## Decisión
La alarma debe sonar desde Android nativo en cuanto llega `ACTION_FIRE`. Flutter pasa a ser la interfaz de control para detener, posponer y hacer handoff a la radio de la app, pero no el único origen del sonido.
## Flujo recomendado
1. `AlarmScheduler` programa la alarma con `setAlarmClock` y fallback exact/inexact.
2. `PluriWaveAlarmReceiver` recibe `ACTION_FIRE`.
3. El receiver arranca `PluriWaveAlarmService` como foreground service.
4. El servicio toma un `PARTIAL_WAKE_LOCK`, muestra notificación foreground y reproduce audio con `USAGE_ALARM`.
5. La UI Flutter se abre por full-screen intent si Android lo permite.
6. Al detener/posponer desde Flutter, se manda comando nativo para parar el servicio.
## Referencias
- Android alarms: https://developer.android.com/develop/background-work/services/alarms
- Foreground service restrictions: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
- AOSP DeskClock AlarmService: https://android.googlesource.com/platform/packages/apps/DeskClock/+/ac260c0096605526f772af7eec73d6a51dc6de32/src/com/android/deskclock/alarms/AlarmService.java
## Notas
- El audio local interno es el fallback más fiable para pantalla apagada.
- La radio remota puede fallar por red, DNS, TLS o timeout; por eso debe existir fallback interno.
- Si un fabricante bloquea incluso servicios arrancados desde alarma, habrá que guiar al usuario con permisos de batería/autostart.
+280
View File
@@ -0,0 +1,280 @@
# Llevar PluriWave a Android Auto
Guía para un desarrollador que **nunca publicó una app en Play Store** y quiere que
PluriWave —instalada en el teléfono— se pueda **navegar y controlar desde la pantalla
del coche** vía Android Auto.
> **Alcance de esta guía.** Hablamos de **Android Auto proyectado**: la app corre en el
> móvil y se proyecta al coche. NO es *Android Automotive OS* (donde la app se instala
> dentro del sistema del vehículo). PluriWave es una app de audio → categoría
> **"media app"**. Todo lo de abajo es para esa combinación.
---
## TL;DR (lo importante primero)
1. **El 60% ya está hecho.** PluriWave usa `audio_service`, que ya expone un
`MediaBrowserService` + `MediaSession` (lo que Android Auto exige). No hay que
reescribir el motor de audio.
2. **Falta lo que un coche necesita de más que un teléfono:** un **árbol navegable**
de emisoras (para que el coche muestre una lista) y una **declaración en el manifest**
para que Android Auto descubra la app.
3. **Trabajo real de código:** ~1 archivo XML nuevo + 1 línea en el manifest +
implementar 3 métodos en `PluriWaveAudioHandler` (`getChildren`, `getMediaItem`,
`playFromMediaId`).
4. **Publicación:** Android Auto añade una **revisión extra de Google** contra las
*car app quality guidelines*. Es más estricta y más lenta que la de una app normal.
Tiempo realista para un principiante: **12 semanas** (código un par de días, el resto
es testing con el emulador de coche y la revisión de Google).
---
## Parte 0 — Cómo funciona (modelo mental)
Un coche con Android Auto **no ejecuta tu UI de Flutter**. En su lugar, le pide a tu app
dos cosas a través de un servicio estándar de Android:
| El coche pregunta | Tu app responde | En Android esto es |
|-------------------|-----------------|--------------------|
| "¿Qué contenido tenés para mostrar?" | Una lista de ítems (carpetas + emisoras) | `MediaBrowserService``getChildren()` |
| "El usuario tocó ESTE ítem, reproducilo" | Arrancás el stream | `MediaSession``playFromMediaId()` |
| "Mostrame play/pausa/título/carátula" | El estado actual | `PlaybackState` + `MediaItem` |
`audio_service` implementa el `MediaBrowserService` y el `MediaSession` por vos. Tu único
trabajo es **rellenar las respuestas** (la lista de emisoras y cómo reproducir cada una).
La UI del coche la dibuja **Android Auto**, no vos. Vos solo aportás datos y audio.
---
## Parte 1 — Qué ya tiene PluriWave (punto de partida)
Verificado en el código actual:
| Pieza | Dónde | Estado |
|-------|-------|--------|
| Dependencia `audio_service` `^0.18.15` | `pubspec.yaml` | ✅ |
| `MediaBrowserService` declarado en el manifest | `android/app/src/main/AndroidManifest.xml:47-54` | ✅ |
| `MediaButtonReceiver` (controles físicos/notificación) | `AndroidManifest.xml:62-68` | ✅ |
| Inicialización del handler | `lib/main.dart:38` (`AudioService.init`) | ✅ |
| Config del servicio | `lib/main.dart:21` (`AudioServiceConfig`) | ✅ |
| Handler propio | `lib/servicios/servicio_audio.dart:127` (`PluriWaveAudioHandler extends BaseAudioHandler`) | ✅ |
| Reproducir un ítem | `servicio_audio.dart:433` (`playMediaItem`) | ✅ |
| Mapear `MediaItem``Emisora` | `servicio_audio.dart:705` (`_emisoraDesdeMediaItem`) | ✅ |
| `foregroundServiceType="mediaPlayback"` | `AndroidManifest.xml:49` | ✅ |
**Ventaja clave de la versión 0.18:** el handler corre en el **mismo isolate** que la app,
así que `getChildren()` puede leer directamente tu lista de emisoras/favoritos del estado
de la app. No hay que sincronizar entre isolates.
### Lo que NO está (el hueco a rellenar)
| Falta | Consecuencia hoy |
|-------|------------------|
| `res/xml/automotive_app_desc.xml` | Android Auto **no descubre** la app |
| `<meta-data com.google.android.gms.car.application>` en el manifest | idem |
| Override de `getChildren()` / `getMediaItem()` | El coche no tiene **ninguna lista** que mostrar |
| Override de `playFromMediaId()` | Tocar una emisora en el coche **no reproduce** nada |
| `MediaItem`s con carátula (`artUri`) por emisora | Google **rechaza** apps de media sin título+thumbnail por ítem |
Hoy PluriWave solo sabe reproducir un `MediaItem` que le pasa **su propia UI de Flutter**
(`playMediaItem`). El coche necesita el camino inverso: **pedir la lista** y **arrancar por id**.
---
## Parte 2 — Quick path (los pasos, en orden)
### Paso 1 · Declarar la app ante Android Auto
Crear `android/app/src/main/res/xml/automotive_app_desc.xml`:
```xml
<automotiveApp>
<uses name="media"/>
</automotiveApp>
```
Añadir dentro de `<application>` en `AndroidManifest.xml` (junto al resto de `<meta-data>`):
```xml
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
```
> Con esto Android Auto ya "ve" la app, pero seguirá vacía hasta el Paso 2.
### Paso 2 · Construir el árbol navegable (el trabajo de fondo)
En `PluriWaveAudioHandler` (`lib/servicios/servicio_audio.dart`) implementar estos métodos.
Firmas **verificadas** contra el código de `audio_service`:
```dart
// Devuelve los hijos de una "carpeta". El root usa AudioService.browsableRootId.
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]);
// Metadatos de un ítem concreto (por si el coche los pide sueltos).
Future<MediaItem?> getMediaItem(String mediaId);
// El usuario tocó un ítem en la pantalla del coche → reproducirlo.
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]);
// (Opcional) búsqueda por voz "pon Radio X".
Future<List<MediaItem>> playFromSearch(String query, [Map<String, dynamic>? extras]);
```
Diseño de árbol sugerido para PluriWave:
```
root (AudioService.browsableRootId)
├── Favoritos (playable: false → carpeta)
│ ├── Emisora A (playable: true)
│ └── Emisora B (playable: true)
├── Todas las emisoras (playable: false)
│ └── ...
└── Mis emisoras (playable: false) // las custom del usuario
└── ...
```
Reglas de un `MediaItem`:
| Campo | Carpeta | Emisora reproducible |
|-------|---------|----------------------|
| `id` | id estable de la categoría | id estable de la emisora |
| `title` | nombre visible | nombre de la emisora **(obligatorio)** |
| `playable` | `false` | `true` |
| `artUri` | opcional | **carátula/logo (obligatorio para pasar la revisión)** |
Lógica a reutilizar: ya tenés `_emisoraDesdeMediaItem` (`servicio_audio.dart:705`) y
`playMediaItem` (`servicio_audio.dart:433`). En `playFromMediaId(id)` resolvés el id →
`Emisora` → construís el `MediaItem` real → llamás al mismo `playMediaItem` interno. **No
dupliques la lógica de reproducción**, enchufala.
### Paso 3 · Carátulas accesibles
`artUri` tiene que ser una URL/҇URI que el sistema pueda cargar (http(s) o `content://`).
Las emisoras que ya tienen logo remoto sirven directo. Para emisoras sin logo, definí una
carátula por defecto (asset empaquetado servido vía `content://` o un placeholder remoto).
### Paso 4 · (Opcional pero recomendado) Content style
Android Auto puede pintar los ítems como **lista** o **grid**. Se controla con hints en el
`extras`/config del root (constantes `CONTENT_STYLE_*` de la spec de MediaBrowser).
Para una app de radio, **grid** para emisoras (se ven los logos) queda mejor. Es pulido,
no bloquea la publicación.
---
## Parte 3 · Probar sin coche (DHU — Desktop Head Unit)
No necesitás un coche para testear. Google da un emulador de la pantalla del coche.
**Quick path del testeo:**
1. En **Android Studio → SDK Manager → SDK Tools**, instalá **Android Auto Desktop Head Unit**.
2. En el **teléfono**: instalá la app *Android Auto*, entrá en sus ajustes y tocá 10 veces
la versión para activar **modo desarrollador**; ahí activá **"Head unit server"**.
3. Conectá el teléfono por USB y lanzá el DHU:
```bash
cd "$ANDROID_HOME/extras/google/auto"
./desktop-head-unit # (desktop-head-unit.exe en Windows)
```
4. En la ventana del DHU deberías ver PluriWave en la sección de **media**. Navegá el árbol
y reproducí una emisora.
**Checklist de humo en el DHU:**
- [ ] La app aparece en la lista de apps de media del coche.
- [ ] Se ve el árbol (Favoritos / Todas / Mis emisoras).
- [ ] Cada emisora muestra **título + carátula**.
- [ ] Tocar una emisora **arranca el audio**.
- [ ] Play / pausa / stop responden desde la pantalla del coche.
- [ ] Al pausar en el coche, la app del teléfono refleja el mismo estado (y viceversa).
---
## Parte 4 · Publicar en Play Store (lo específico de un primer publicador)
Publicar una app **con Android Auto** no es igual que una app normal: dispara una
**revisión adicional** de Google contra las *car app quality guidelines*.
### Requisitos de calidad que Google verifica (media apps)
| Requisito | Qué significa para PluriWave |
|-----------|------------------------------|
| Integración con MediaSession | Ya lo da `audio_service` ✅ |
| Soportar play/pausa **o** stop | Ya lo tenés ✅ |
| **Título + thumbnail por cada ítem** | ← esto es lo que hay que asegurar (Paso 3) |
| Poder llegar a la vista de reproducción desde el browsing | Se cumple con el árbol bien armado |
| **Al menos 1 screenshot real, sin editar, de la experiencia en coche** | Sacala del DHU |
### Pasos en Play Console
1. **Cuenta de desarrollador** (pago único de ~25 USD, primera vez).
2. Subí primero a un **track de pruebas cerrado**, NO directo a producción.
> Importante: si el build va en un track de **testing** y no cumple, Google te avisa
> pero **igual lo aprueba** para ese track. Si el mismo build va a **producción** y no
> cumple, lo **rechaza**. Por eso: cerrado → arreglás → producción.
3. Completá la **ficha de la tienda** + el cuestionario de contenido/privacidad
(obligatorio para cualquier app nueva).
4. Subí la **screenshot de la experiencia en coche** (del DHU).
5. Enviá a revisión. La revisión de coche puede tardar **de unas horas hasta 7 días**
(a veces más), bastante más que una app solo-móvil.
### Gotchas para PluriWave concretamente
- **Muchos permisos sensibles.** El manifest pide localización, `RECORD_AUDIO`,
`SCHEDULE_EXACT_ALARM`, `SYSTEM_EXEMPTED`, etc. La revisión de coche mira con lupa; tené
a mano la justificación de cada permiso (la sección de *foreground service* de Play
Console te va a pedir el porqué de `mediaPlayback`).
- **`targetSdk`** sale de `flutter.targetSdkVersion` (`android/app/build.gradle`). Play
exige un target reciente para apps nuevas; verificá que cumpla el mínimo del año antes de
subir.
- **Streams que fallan.** Google prueba reproducir. Si una emisora del árbol está caída, da
mala impresión. Exponé en el árbol emisoras fiables (favoritos del usuario, o un set
curado) y manejá el error de stream con gracia (ya tenés `controlador_reconexion.dart`).
---
## Checklist maestro
**Código**
- [ ] `res/xml/automotive_app_desc.xml` creado (`<uses name="media"/>`).
- [ ] `<meta-data com.google.android.gms.car.application>` en el manifest.
- [ ] `getChildren()` devuelve el árbol (carpetas + emisoras).
- [ ] `getMediaItem()` resuelve un id suelto.
- [ ] `playFromMediaId()` reutiliza `playMediaItem` interno.
- [ ] Cada emisora expone `title` + `artUri`.
**Testing**
- [ ] Funciona en el DHU (navegar + reproducir + play/pausa).
- [ ] Estado sincronizado coche ↔ teléfono.
**Publicación**
- [ ] Cuenta de desarrollador creada.
- [ ] Screenshot de la experiencia en coche subida.
- [ ] Subido primero a track cerrado.
- [ ] Justificación de permisos preparada.
- [ ] Enviado a revisión.
---
## Referencias
- [Media apps for cars — overview (Android Developers)](https://developer.android.com/training/cars/media)
- [Add support for Android Auto to your media app](https://developer.android.com/training/cars/media/auto)
- [Car app quality guidelines](https://developer.android.com/docs/quality-guidelines/car-app-quality)
- [Distribute to cars (Play Console)](https://developer.android.com/training/cars/distribute)
- [audio_service (pub.dev)](https://pub.dev/packages/audio_service)
- [audio_service — repo y ejemplos (GitHub)](https://github.com/ryanheise/audio_service)
---
## Próximo paso sugerido
Empezar por el **Paso 1 + Paso 2 con un árbol mínimo** (solo "Favoritos" con 23 emisoras
hardcodeadas) y verlo en el **DHU**. Cuando eso reproduzca en el emulador de coche, recién
ahí ampliar el árbol y pulir carátulas. Es el bucle de feedback más corto para no
programar a ciegas.

Some files were not shown because too many files have changed in this diff Show More