21 Commits
Author SHA1 Message Date
ShanaiaBot 8c4f15528c chore: bump version to 1.3.4+162 [ci skip] 2026-09-06 00:31:48 +02:00
FreeTLab cbc54e915b fix(eq): entregar los decibelios que pide el usuario, sin estirarlos
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m28s
La app promete decibelios en cuatro sitios y no los entregaba en ninguno. El
slider abarca un +/-12 fijo, escribe el numero con su unidad debajo de cada
banda, y `equalizerBandValue` le dice literalmente "decibelios" a TalkBack. El
modelo documenta las bandas como dB y los presets de fabrica estan escritos en
dB. just_audio documenta `setGain` en decibelios y multiplica por 1000 para
llegar a milibelios sin normalizar nada: `minDecibels`/`maxDecibels` son la
CAPACIDAD del dispositivo, no una escala a la que normalizar.

Pese a eso, la ganancia se estiraba por `maxDecibels/12`. Un +6 dB llegaba como
+10 en un movil de rango ancho.

El estiramiento nunca fue una decision de diseño

Antes de a9202c6 el codigo era `setGain(preset.bandas[i])`, decibelios
literales. Ese commit metio la normalizacion sin docstring, sin test y sin nota
de diseño, y traia un fallo: 0 dB caia en el punto medio del rango, asi que un
preset PLANO realzaba. 3449e2c corrigio exactamente eso y nada mas -- su propio
mensaje dice que el objetivo era "0 dB es siempre 0" -- heredando el
estiramiento sin discutirlo. No hay ADR, spec ni comentario que lo justifique.

Lo que de verdad rompia: la portabilidad

Lo que se persiste y se exporta son los dB del usuario, sin escalar; el escalado
ocurre solo al escribir en el efecto nativo. Asi que el mismo backup suena
distinto en cada telefono, y la interfaz informa de una restauracion perfecta
mientras el audio no lo es. Peor con el rango asimetrico habitual de Android
([-12, +19]): los realces se multiplican por 1.58 y los cortes por 1.0, de modo
que el preset no solo sube de nivel, CAMBIA DE FORMA. Jazz [3, -1, -1.5, 2, 4]
se entregaba como [4.75, -1, -1.5, 3.17, 6.33]. Con presets por dispositivo, el
mismo preset se deformaba distinto en el altavoz y en el Bluetooth.

No es un clamp a secas

`db.clamp(minDecibels, maxDecibels)` habria reintroducido el fallo de 3449e2c:
en un dispositivo que reporte [+3, +19], el cero se convierte en +3 y el preset
plano vuelve a realzar. La ventana se fuerza a contener el cero, asi que se
conservan todas las invariantes ganadas -- 0 siempre es 0, el signo nunca se
invierte, el resultado nunca escapa del rango nativo, un dispositivo sin margen
en un lado no puede realzar por ese lado -- y solo desaparece el estiramiento.

Que se oye distinto: en un movil de +/-12 dB, identico a hoy. En uno de rango
ancho los realces bajan, hasta un 40% menos en dB en uno de +/-20. Los cortes
apenas se mueven, porque la forma habitual es [-12, +N] y el lado negativo ya
iba practicamente 1:1. A cambio, los seis presets de fabrica suenan por fin
igual en cualquier telefono.

Ningun preset guardado necesita migracion: lo almacenado siempre fueron los dB
del usuario.

Suite completa: 1534 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00
FreeTLab 86dd20b184 fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma
Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00
FreeTLab 6b91ad88e8 fix: el ecualizador del coche aplica el preset real y en el orden correcto
Reportado desde el uso real: desde el movil el ecualizador va bien, pero el
boton de Android Auto a veces no hace nada y a veces suena como si se aplicara
una doble ecualizacion.

El preset del handler nunca se sembraba desde disco

`_presetActual` arrancaba en `flat` a fuego. registrarHandler sembraba el flag
de encendido pero no el preset, asi que en un motor donde la interfaz del
telefono nunca corrio -- el que arranca Android Auto -- el toggle del coche
aplicaba `flat`, o lo que hubiera quedado, en vez del preset del usuario. Es la
misma clase de fallo que ya se corrigio para el flag: aquel recibio un puerto
headless y el preset se quedo fuera. Ahora tiene el suyo, con la misma forma:
opcional, el fallo se traza y cae al valor por defecto, nunca propaga.

La siembra respeta un preset ya elegido por EstadoEcualizador, que es mas rico
que la clave principal, para que la lectura de disco en vuelo no lo pise.

El efecto se habilitaba antes de escribir las ganancias

La ruta era setEnabled -> setEnabled -> ganancias: `aplicarPreset` volvia a
llamar a setEnabled por su cuenta. Entre la habilitacion y la escritura sonaban
las ganancias anteriores, y ese hueco es lo que se percibia como doble
ecualizacion. Ahora una funcion pura devuelve los pasos en orden y ambas rutas
la recorren: ganancias primero, habilitacion despues.

Las ganancias NO se resetean al apagar, y es deliberado: setEnabled(false)
puentea el efecto sin liberarlo ni limpiar sus niveles, y la ruta de encendido
los reescribe enteros antes de habilitar, asi que no queda ninguna ventana de
ganancia rancia que un reset pudiera cerrar.

El boton desaparecia en cada cambio de emisora

`_recrearPlayer` bajaba `_eqDisponible` sin republicar controles, asi que cada
cambio de emisora emitia al menos un estado sin la accion de EQ. Peor: las
llamadas nativas estan detras de ese flag, de modo que un toggle en esa ventana
cambiaba el icono sin tocar el audio. Ahora el unico que lo escribe es
`_activarEcualizador`.

Mantenerlo optimista exigia quitar de la ruta del toggle el `await
_eq.parameters`, que es un Completer que solo se completa cuando el reproductor
se engancha: esperarlo dejaba el boton pendiente durante toda la carga, y para
siempre si la carga fallaba. Se cachean los parametros al activarse.

Los fallos nativos dejan de ser mudos

El `catch (_) {}` ocultaba que la llamada nativa habia fallado y dejaba el icono
afirmando un estado que el audio no tenia. Ahora se traza, y un fallo al
habilitar revierte el flag, republica los controles y no persiste.

EstadoEcualizador adopta lo que el motor acepto en vez de asumir que su peticion
prospero: sin eso, el telefono escribia en disco un valor que el handler acababa
de rechazar, reabriendo la divergencia que el dueño unico habia cerrado.

Suite completa: 1515 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00
ShanaiaBot 05f70af7f1 chore: bump version to 1.3.3+161 [ci skip] 2026-09-04 13:46:34 +02:00
FreeTLab c30bbacbbc ci: no fallar la compilacion cuando falta el secreto de Google Play [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
El paso que prepara las credenciales hacia `exit 1` si no encontraba
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON, y ese secreto no se ha configurado nunca: TODA
compilacion de PRO terminaba en rojo por una automatizacion que jamas llego a
activarse, mientras los AAB se subian a Play a mano. Un rojo permanente entrena
a ignorar los rojos, y entonces el dia que falle algo de verdad tampoco se mira.

Ahora el paso se omite con un aviso y expone `disponible`, del que dependen los
dos pasos siguientes. No se pierde nada: el AAB ya esta compilado, firmado y
subido a ftl-builds antes de llegar aqui. El dia que se configure el secreto,
los tres pasos se reactivan solos sin tocar el workflow.

El aviso de Telegram deja de afirmar "Publicado en Google Play" cuando la subida
se ha omitido. Un mensaje que dice que se publico algo que no se publico es peor
que no mandar mensaje.
2026-09-04 13:45:41 +02:00
ShanaiaBot 192a3aca0e chore: bump version to 1.3.3+160 [ci skip] 2026-09-04 13:33:58 +02:00
FreeTLab ab3554b746 chore(release): alinear PRO con la version probada 1.3.3 [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 30s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 3m12s
PRO venia arrastrando su propia linea de version (1.3.1) mientras main iba por
1.3.3, asi que el mismo codigo tenia dos numeros segun la rama. Lo que se ha
probado en el coche es 1.3.3+160, y ese es el numero que deben ver los testers:
cuando alguien reporte un fallo, la version que diga tiene que coincidir con la
que se valido.

Se fija 1.3.3+159 porque el CI incrementa el numero de build ANTES de compilar,
de modo que el artefacto publicado sale como 1.3.3+160. El marcador
[version set] impide que el paso de bump suba tambien el patch, que es el
comportamiento por defecto en PRO.

El codigo de version 160 esta libre en Play: lo mas alto subido alli es 157, y
los 158, 159 y 160 de main nunca salieron del portal de builds.
2026-09-04 13:33:05 +02:00
FreeTLab 8a71bc237f feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-04 13:26:00 +02:00
FreeTLab 8e155cc0ac fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on
stop button makes the entire app useless", citado contra las Android for Cars
App Quality Guidelines. La causa no era el boton de parar.

Maquina de estados del transporte

_cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo
comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de
fuente se encolan incrementando la revision al encolar, no al ejecutar, asi
que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que
las entradas obsoletas reescribieran loading sobre el idle que stop() acababa
de publicar. Estado final: loading para siempre sobre una sesion que
audio_service ya habia desactivado. Ahora la guarda de revision es la primera
sentencia del metodo.

pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual
despues de pulsar pausa; se revalida la intencion antes de llamar a play().
Se anade un suelo de estado que cierra cualquier loading o buffering sin carga
viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando,
para no convertir un tunel en un error. El presupuesto hasta el primer mensaje
baja a menos de diez segundos y los reintentos ya no borran el mensaje visible.

Tier gratuito en el coche

El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando
no habia premium, y un revisor con instalacion limpia siempre es tier
gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId
retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con
emisoras reales y reproducibles, compiladas en el binario para que existan en
frio, y la puerta de entitlement acota contenido en vez de bloquear acciones.
Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la
ultima emisora, que fallaba tambien a los clientes de pago.

Localizacion

El locale del handler solo lo fijaba un widget que el motor headless nunca
construye, asi que todo error del coche salia en castellano. Se resuelve desde
el locale de plataforma. Se traducen las once etiquetas del arbol que estaban
a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si
vuelve a aparecer texto visible fuera del sistema de traduccion.

Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por
mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los
5 avisos preexistentes.
2026-09-04 13:26:00 +02:00
FreeTLab 575ba793ae fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-09-04 13:26:00 +02:00
ShanaiaBot a5572d2cbd chore: bump version to 1.3.1+157 [ci skip] 2026-08-28 23:53:03 +02:00
FreeTLab 98b24d84cd Merge branch 'PRO' of https://git.freetimelab.es/FreeTLab/pluriwave into PRO
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 19s
2026-08-28 23:52:27 +02:00
FreeTLab 72c5777508 ci: name build artifacts by branch and version code [version set]
Every build of a given semver was published as `pluriwave-v1.3.0.aab` into
the same folder, so main and PRO overwrote each other and three different
builds became indistinguishable once downloaded — the browser saves them as
"(1)", "(2)" and the version code is only visible by unzipping the bundle.

That cost two rejected uploads to Play Console for reusing a version code.
Artifacts are now `pluriwave-<branch>-v<semver>+<build>.<ext>", which
identifies itself weeks later and outside this repo.
2026-08-28 23:52:16 +02:00
ShanaiaBot b69041f32a chore: bump version to 1.3.0+156 [ci skip] 2026-08-28 23:40:45 +02:00
FreeTLab 9681a47e83 merge: alarm-import recovery, dismissible paywall and complete config export [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m1s
Same three fixes already merged to main (e57f7bb, a2bed18, 4ca2813):
alarms actually come back after a backup import and get re-scheduled
natively, the premium sheet can be dismissed, and the equalizer on/off
toggle finally travels with the backup.

[version set] keeps the 1.3.0 release name; CI advances the build number.

# Conflicts:
#	pubspec.yaml
2026-08-28 23:38:15 +02:00
FreeTLab 4ca2813267 feat(ecualizador): include equalizer on/off toggle in export/import
The backup envelope carried favorites, EQ presets, alarms and the
multi-device toggle but not EstadoEcualizador's own on/off flag, so
restoring a backup on another device silently kept that device's
existing toggle state instead of the source device's.

Bumps the backup schema to v4 (additive over v3): the flag is only
written when explicitly provided, so old exports stay at v2/v3.
Importing an old backup without the field leaves the current toggle
untouched rather than defaulting it. Applying the imported value
reuses EstadoEcualizador.cambiarActivo so it persists and pushes to
the live audio engine exactly like a manual toggle.
2026-08-28 23:07:01 +02:00
FreeTLab a2bed18937 fix(paywall): add dismiss controls and honest premium copy
The premium sheet had no close affordance or way to defer, and its
copy only said "Función Premium" without stating what it unlocks.
Adds a header close (X) button and a "not now" secondary action so
dismissal is never harder than purchasing, and replaces the bare
title with a concrete, honest breakdown of the 5 things premium
unlocks (no ads, Android Auto, station recording, alarm vacation
ranges, unlimited alarms) plus the one-time-purchase framing. The
phone equalizer is never listed, since it stays free for everyone.
New l10n keys added to all 13 locales.
2026-08-28 22:47:53 +02:00
FreeTLab e57f7bb17b fix(alarmas): reload and re-sync alarms after backup import
Importing a backup wrote the alarm/vacation/exception block straight to
SharedPreferences but never told EstadoAlarmas about it, so the UI kept
showing the pre-import alarms, a later edit could persist that stale
state back over the imported one, and imported alarms were never
(re)scheduled with the Android native layer. The backup screen now
calls EstadoAlarmas.cargarPersistidasSinRecalcular() followed by
refrescarProgramacion() after a successful import, extracted into a
directly-testable aplicarImportacionConfig() function.
2026-08-28 22:47:38 +02:00
ShanaiaBot fdddd95199 chore: bump version to 1.3.2+155 [ci skip] 2026-08-28 20:07:12 +02:00
ShanaiaBot 524b8f0035 chore: bump version to 1.3.0+154 [ci skip] 2026-08-28 19:59:13 +02:00
90 changed files with 12495 additions and 880 deletions
+42 -9
View File
@@ -237,11 +237,24 @@ jobs:
- name: Build AAB release
run: flutter build appbundle --release
# El nombre lleva RAMA y CÓDIGO DE VERSIÓN, no solo el semver.
#
# Antes, cada build de 1.3.0 se llamaba `pluriwave-v1.3.0.aab` y caía en
# la misma carpeta, así que main y PRO se pisaban y tres builds distintos
# eran indistinguibles una vez descargados: el navegador los guarda como
# "(1)", "(2)"... y ya no se sabe cuál es cuál. Eso costó subir a Play
# Console un código de versión ya usado, dos veces.
#
# Con `pluriwave-PRO-v1.3.0+156.aab` el archivo se identifica solo,
# incluso semanas después y fuera de este repo.
- name: Publicar en ftl-builds (Zimaboard)
run: |
VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk"
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
BRANCH="${CURRENT_REF#refs/heads/}"
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
@@ -252,28 +265,43 @@ jobs:
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}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
# La publicacion automatica en Google Play es OPCIONAL.
#
# Este paso hacia `exit 1` cuando faltaba el secreto, asi que TODA
# compilacion de PRO terminaba en rojo por una funcion que nunca llego a
# activarse: el secreto no se configuro nunca y las subidas a Play se han
# hecho siempre a mano. Un rojo permanente entrena a ignorar los rojos, y
# entonces el dia que falle algo de verdad tampoco se mira.
#
# Ahora se omite con un aviso. El AAB ya esta compilado, firmado y subido
# a ftl-builds por el paso anterior, asi que no se pierde nada. El dia que
# se configure el secreto, los tres pasos se activan solos.
- name: Preparar credenciales de Google Play
id: credenciales_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
echo "disponible=no" >> "$GITHUB_OUTPUT"
echo "AVISO: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON."
echo "Se omite la publicacion en Google Play; sube el AAB a mano."
exit 0
fi
mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
echo "disponible=si" >> "$GITHUB_OUTPUT"
- name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' }}
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
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' }}
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
env:
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
@@ -291,8 +319,13 @@ jobs:
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
# Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
# se omite si falta el secreto, y un aviso que dice "publicado"
# cuando no se publico es peor que no avisar.
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
elif [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0AEn builds.freetimelab.es · sube el AAB a Play a mano"
else
MSG="${MSG}%0APublicado en builds.freetimelab.es"
fi
@@ -10,7 +10,6 @@ import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.media.audiofx.Visualizer
import android.app.AlarmManager
@@ -26,6 +25,7 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity
import es.freetimelab.pluriwave.fileactions.FileActionsHandler
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
@@ -269,10 +269,31 @@ class MainActivity : AudioServiceActivity() {
}
activeInstance = this
// fix/android-auto-musica-local: los cuatro metodos SAF que solo
// necesitan un ContentResolver viven en FileActionsHandler, dentro del
// paquete plugin `packages/pluriwave_file_actions`. Alli
// PluriWaveFileActionsPlugin los registra en TODOS los engines via
// GeneratedPluginRegistrant -- incluido el headless que audio_service
// crea para Android Auto, donde este configureFlutterEngine nunca
// corre.
//
// Este engine SI tiene Activity, asi que instala UN solo handler para
// todo el canal, superconjunto del del plugin: primero delega en el
// handler compartido (misma y unica implementacion) y, si este no
// reconoce el metodo, atiende sus propios metodos ligados a la
// Activity (picker SAF e intents de la carpeta de grabaciones).
//
// El orden esta garantizado: GeneratedPluginRegistrant corre DENTRO
// del constructor de FlutterEngine, y configureFlutterEngine solo
// puede ejecutarse despues, con el engine ya construido. Este handler
// siempre pisa al del plugin en una Activity, nunca al reves.
val fileActionsHandler = FileActionsHandler(applicationContext)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
fileActionsChannel
).setMethodCallHandler { call, result ->
if (fileActionsHandler.manejar(call, result)) return@setMethodCallHandler
when (call.method) {
"openDirectory" -> {
val path = call.argument<String>("path")
@@ -327,53 +348,11 @@ class MainActivity : AudioServiceActivity() {
pendingMusicFolderResult = null
}
}
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
// ---- android-auto-local-music-phase2 (static review only) ----
"readAudioMetadataBatch" -> {
val treeUri = call.argument<String>("treeUri")
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
Log.d(
tag,
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any?>>())
} else {
result.success(readAudioMetadataBatch(treeUri, documentIds))
}
}
// listAudioChildren / resolvePlayableUri /
// hasPersistedPermission / readAudioMetadataBatch los
// atiende FileActionsHandler arriba (item 3): no necesitan
// Activity, asi que tienen que poder registrarse tambien en
// un engine que no la tiene.
else -> result.notImplemented()
}
}
@@ -423,241 +402,6 @@ class MainActivity : AudioServiceActivity() {
super.onActivityResult(requestCode, resultCode, data)
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only — Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* audio MIME types at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) — used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
contentResolver.persistedUriPermissions.any { it.uri == parsed && it.isReadPermission }
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
/**
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
* static review only — Design "Interfaces / Contracts"): for each of
* [documentIds], extracts title/artist/bitrate/sample-rate and the
* embedded picture via [extraerMetadatosPista]. Never throws across the
* channel boundary — a malformed [treeUri] (or any other unexpected
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
* already isolated inside [extraerMetadatosPista].
*/
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
return try {
val parsedTree = Uri.parse(treeUri)
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
} catch (error: Throwable) {
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
emptyList()
}
}
/**
* Extracts one [documentId]'s embedded metadata via
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
* review only — mirrors [listAudioChildren]/[resolvePlayableUri]'s
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
* public constant below API 31) is gated behind
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
* available since API 10 and read unconditionally. A resolvable
* embedded picture is handed to [cachearArteEmbebido]; art-cache
* failures degrade that single field to `null` without failing the
* whole entry. On ANY failure for this [documentId] (unsupported
* format, permission edge case, corrupt file), the row degrades to an
* all-null-but-`documentId` entry instead of throwing —
* `retriever.release()` always runs via `finally`.
*/
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
val retriever = MediaMetadataRetriever()
return try {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
retriever.setDataSource(this, documentUri)
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
val bitrate = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull()
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
// public constant exists on this minSdk, so the raw key is
// used directly, guarded by the version check above.
retriever.extractMetadata(38)?.toIntOrNull()
} else {
null
}
val artUri = try {
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
error
)
null
}
mapOf(
"documentId" to documentId,
"titulo" to titulo,
"artista" to artista,
"bitrate" to bitrate,
"sampleRate" to sampleRate,
"artUri" to artUri
)
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
error
)
mapOf(
"documentId" to documentId,
"titulo" to null,
"artista" to null,
"bitrate" to null,
"sampleRate" to null,
"artUri" to null
)
} finally {
try {
retriever.release()
} catch (_: Throwable) {
// release() failing is not actionable — the retriever is
// being discarded regardless.
}
}
}
/**
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
* static review only — Design ADR-1). Writes [picture] bytes to
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
* file already exists, so re-parsing the same track reuses it), returns
* the `content://` URI served via the EXISTING
* `${applicationId}.fileprovider` authority
* (`AndroidManifest.xml:97-105`, `pluriwave_file_paths.xml`'s
* `cache-path path="."` — confirmed present, zero manifest changes
* needed) and trims `pluriwave_art/` via [trimArtCache]. `hash` uses
* SHA-256 hex because a raw `documentId` may contain `:`/`/`, which are
* illegal in filenames on most filesystems.
*/
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
return try {
val artDir = File(cacheDir, "pluriwave_art").apply { mkdirs() }
val artFile = File(artDir, hashDocumentId(documentId))
if (!artFile.exists()) {
artFile.writeBytes(picture)
}
trimArtCache(artDir)
FileProvider.getUriForFile(this, "$packageName.fileprovider", artFile).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
null
}
}
private fun hashDocumentId(documentId: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
* total, deleting the OLDEST-by-mtime entries first. Kept as a
* trivially reviewable loop — these files are native-owned, so
* round-tripping names to Dart to pick deletions would add channel
* chatter with no testability gain (the `delete()` is native
* regardless, per ADR-1's rationale).
*/
private fun trimArtCache(artDir: File) {
val maxArchivos = 256
val maxBytes = 32L * 1024 * 1024
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
?: return
var totalBytes = archivos.sumOf { it.length() }
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
val masViejo = archivos.removeAt(archivos.size - 1)
totalBytes -= masViejo.length()
masViejo.delete()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+51 -8
View File
@@ -98,6 +98,13 @@ class EstadoEcualizador extends ChangeNotifier {
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
/// the handler's current EQ state against our cached copy and adopt it on
/// divergence.
///
/// Since eq-estado-unico this is a DISPLAY concern only. The handler owns
/// the flag and persists it itself, so this subscription no longer closes
/// a persistence gap — it just keeps the phone's toggle showing what the
/// engine is really doing. It also cannot be the fix on its own: it exists
/// only while an [EstadoEcualizador] does, and the headless Android Auto
/// engine that produced the bug report never builds one.
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
PresetEcualizador get presetActual => _presetActual;
@@ -370,8 +377,16 @@ class EstadoEcualizador extends ChangeNotifier {
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
/// handler's own `_actualizarControlesEq()` re-push, which would tick
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
/// Only a local field write, [servicio] persistence and [notifyListeners]
/// happen here, so a divergence is resolved in a single pass.
/// Only a local field write and [notifyListeners] happen here, so a
/// divergence is resolved in a single pass.
///
/// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item
/// B). `PluriWaveAudioHandler` writes its own toggle through the port
/// `registrarHandler` injects, so the value is saved on every engine
/// rather than only on one that happens to have built a widget tree. This
/// method could never have been the owner of that fact: it only runs while
/// an [EstadoEcualizador] exists, and on the headless Android Auto engine
/// behind the bug report none ever does.
///
/// Wrapped in try/catch like every other handler-facing read in this
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
@@ -387,13 +402,10 @@ class EstadoEcualizador extends ChangeNotifier {
if (!activoDiverge && !presetDiverge) return;
if (activoDiverge) {
// Display-only adopt: the handler already persisted this value
// through its own write port before it ever reached us. See the
// doc above.
_activo = activoHandler;
// Closes the persistence gap: `PluriWaveAudioHandler` never
// persists anything itself (it must stay headless-constructible,
// with zero SharedPreferences/Provider access) — [servicio] is the
// only owner of EQ persistence, so a car/notification toggle must
// be saved HERE or it is lost on the next process restart.
await servicio.guardarActivo(activoHandler);
}
if (presetDiverge) {
_presetActual = presetHandler;
@@ -651,12 +663,29 @@ class EstadoEcualizador extends ChangeNotifier {
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
/// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about.
///
/// The handler can also REFUSE the change: when the native `setEnabled`
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
/// flag back and skips its persistence write, so the value we optimistically
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
/// learn that: on divergence we adopt the handler's real value and return
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
/// to disk that would resurrect it on the next start. The supersede check
/// runs FIRST so a newer tap still owns the outcome; the read-back only
/// speaks for a call nobody overtook.
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
notifyListeners();
await audio.setEcualizadorActivo(activo);
if (_activo != activo) return;
final aceptado = audio.ecualizadorActivo;
if (aceptado != activo) {
_activo = aceptado;
notifyListeners();
return;
}
if (activo) {
await audio.aplicarPreset(_presetActual);
if (_activo != activo) return;
@@ -695,12 +724,21 @@ class EstadoEcualizador extends ChangeNotifier {
/// Replaces the whole EQ configuration (backup import path): persists it,
/// re-applies the preset effective for the current station and notifies.
///
/// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When
/// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT
/// toggle is left untouched: an absent flag must never flip the user's live
/// setting to an arbitrary value. When non-null, applies it through
/// [cambiarActivo], the same path a manual toggle uses, so the import
/// persists it AND pushes it to the live audio engine instead of just
/// updating [_activo] in memory.
Future<void> importarConfiguracion({
required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
bool? activo,
}) async {
_presetPrincipal = principal;
_presetsEmisoraMap
@@ -735,6 +773,11 @@ class EstadoEcualizador extends ChangeNotifier {
final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual);
if (activo != null) {
await cambiarActivo(activo);
}
notifyListeners();
}
+2 -2
View File
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
@@ -181,7 +181,7 @@ class EstadoEntitlement extends ChangeNotifier {
// Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind.
notificarDesbloqueoAuto();
invalidarArbolAuto();
}
}
+42 -32
View File
@@ -338,24 +338,6 @@ class EstadoRadio extends ChangeNotifier {
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
@@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
// Issue 4's write used to live here as well. It is gone: the handler
// persists every station itself from `_cambiarFuente`, which is the
// same source change that moved `audio.emisoraActual` and is the
// reason this branch runs at all. Writing again here would make the
// key's final value depend on how two independent fire-and-forget
// chains interleave on a fast station switch.
}
notifyListeners();
});
@@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier {
}
_emisoraSeleccionada = emisora;
notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
// below reaches for this very station — see
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
// left the key with TWO fire-and-forget writers whose relative order
// decides the value after a fast A -> B switch, and this one cannot see
// the revision guard that already cancels a superseded change.
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
@@ -807,9 +795,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v3 — portabilidad completa
/// con presets por dispositivo y matriz multi-device).
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
/// Genera el JSON de toda la configuración (v4 — portabilidad completa
/// con presets por dispositivo, matriz multi-device y el toggle
/// on/off del ecualizador).
/// La forma del sobre vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
@@ -837,6 +826,8 @@ class EstadoRadio extends ChangeNotifier {
presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
// v4 extension — equalizer global on/off toggle.
ecualizadorActivo: ecualizador.activo,
);
}
@@ -850,10 +841,11 @@ class EstadoRadio extends ChangeNotifier {
/// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
/// v3 (+ presets por dispositivo, presets matriz, toggle multi-device)
/// y v4 (+ toggle on/off del ecualizador).
Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1;
if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
if (version > 4) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await _resolverPrefs();
@@ -873,7 +865,12 @@ class EstadoRadio extends ChangeNotifier {
final favRaw = data['favoritos'] as List? ?? [];
for (final raw in favRaw) {
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
await favoritos.agregar(emisora);
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
// los grupos restaurados arriba volvían como cascarones vacíos y todas
// las emisoras aterrizaban en «Sin asignar».
await favoritos.restaurarFavorito(emisora);
}
// ── Emisoras custom ───────────────────────────────────────────────────
@@ -932,12 +929,20 @@ class EstadoRadio extends ChangeNotifier {
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
// v4 extension: equalizer on/off toggle. Read unconditionally — the key
// is simply absent on any pre-v4 backup, which resolves to `null` and
// leaves the user's CURRENT toggle untouched (see
// `EstadoEcualizador.importarConfiguracion` doc): an old backup must
// never flip a live setting it never carried.
final ecualizadorActivo = data['ecualizadorActivo'] as bool?;
await ecualizador.importarConfiguracion(
principal: presetPrincipal,
porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
activo: ecualizadorActivo,
);
// ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -945,7 +950,12 @@ class EstadoRadio extends ChangeNotifier {
final alarmasData = data['alarmas'];
if (alarmasData is Map<String, dynamic>) {
// Escribimos el bloque JSON tal como estaba en el dispositivo origen.
// ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso.
// EstadoAlarmas es un ChangeNotifier independiente y de larga vida
// que ya cargó sus alarmas en memoria: NO relee este storage por sí
// solo. El llamador (pantalla_ajustes_backup.dart) es responsable de
// invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido
// de `refrescarProgramacion()` tras un import exitoso; EstadoRadio
// se mantiene deliberadamente sin depender de EstadoAlarmas.
await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
}
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "استعادة المشتريات",
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
"premiumActivo": "النسخة المميزة مفعّلة"
"premiumActivo": "النسخة المميزة مفعّلة",
"premiumHojaTitulo": "افتح PluriWave Premium",
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "تسجيل المحطات",
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
"premiumAhoraNo": "ليس الآن",
"autoErrorEmisoraPremium": "هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.",
"autoErrorBusquedaSinResultados": "لم نعثر على تلك المحطة. جرّب اسمًا آخر.",
"autoCarpetaEscuchar": "الاستماع",
"autoCarpetaFavoritos": "المفضلة",
"autoCarpetaTodas": "كل المحطات",
"autoCarpetaMisEmisoras": "محطاتي",
"autoCarpetaMusicaLocal": "الموسيقى المحلية",
"autoMusicaLocalNoDisponible": "افتح PluriWave على هاتفك لقراءة موسيقاك",
"autoCargarMas": "المزيد…",
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
"autoReproducirCarpeta": "تشغيل المجلد",
"autoReproducirAleatorio": "تشغيل عشوائي",
"autoPistaSinNombre": "مقطع بلا اسم"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
"premiumActivo": "প্রিমিয়াম সক্রিয়"
"premiumActivo": "প্রিমিয়াম সক্রিয়",
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
"premiumAhoraNo": "এখন নয়",
"autoErrorEmisoraPremium": "এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।",
"autoErrorBusquedaSinResultados": "সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।",
"autoCarpetaEscuchar": "শুনুন",
"autoCarpetaFavoritos": "প্রিয়",
"autoCarpetaTodas": "সব স্টেশন",
"autoCarpetaMisEmisoras": "আমার স্টেশন",
"autoCarpetaMusicaLocal": "স্থানীয় সঙ্গীত",
"autoMusicaLocalNoDisponible": "আপনার গান পড়তে ফোনে PluriWave খুলুন",
"autoCargarMas": "আরও…",
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
"autoReproducirCarpeta": "ফোল্ডার চালান",
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
"autoPistaSinNombre": "নামহীন ট্র্যাক"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Käufe wiederherstellen",
"compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.",
"premiumActivo": "Premium aktiv"
"premiumActivo": "Premium aktiv",
"premiumHojaTitulo": "PluriWave Premium freischalten",
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Sender aufnehmen",
"premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker",
"premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)",
"premiumPagoUnico": "Einmalzahlung, für immer. Kein Abonnement.",
"premiumAhoraNo": "Nicht jetzt",
"autoErrorEmisoraPremium": "Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.",
"autoErrorBusquedaSinResultados": "Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.",
"autoCarpetaEscuchar": "Hören",
"autoCarpetaFavoritos": "Favoriten",
"autoCarpetaTodas": "Alle Sender",
"autoCarpetaMisEmisoras": "Meine Sender",
"autoCarpetaMusicaLocal": "Lokale Musik",
"autoMusicaLocalNoDisponible": "Öffne PluriWave auf dem Handy, um deine Musik zu lesen",
"autoCargarMas": "Mehr…",
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
"autoReproducirCarpeta": "Ordner abspielen",
"autoReproducirAleatorio": "Zufallswiedergabe",
"autoPistaSinNombre": "Unbenannter Titel"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Restore purchases",
"compraError": "We couldn't complete the purchase. Please try again.",
"restauracionSinCompras": "We didn't find any previous purchase on this account.",
"premiumActivo": "Premium active"
"premiumActivo": "Premium active",
"premiumHojaTitulo": "Unlock PluriWave Premium",
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Station recording",
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
"premiumPagoUnico": "One-time purchase, forever. Not a subscription.",
"premiumAhoraNo": "Not now",
"autoErrorEmisoraPremium": "This station is Premium. Open PluriWave on your phone to unlock it.",
"autoErrorBusquedaSinResultados": "We couldn't find that station. Try another name.",
"autoCarpetaEscuchar": "Listen",
"autoCarpetaFavoritos": "Favorites",
"autoCarpetaTodas": "All stations",
"autoCarpetaMisEmisoras": "My stations",
"autoCarpetaMusicaLocal": "Local music",
"autoMusicaLocalNoDisponible": "Open PluriWave on your phone to read your music",
"autoCargarMas": "More…",
"autoOrdenarPorCalidad": "Sort by quality",
"autoReproducirCarpeta": "Play folder",
"autoReproducirAleatorio": "Shuffle play",
"autoPistaSinNombre": "Untitled track"
}
+22 -1
View File
@@ -863,5 +863,26 @@
"restaurarCompras": "Restaurar compras",
"compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
"restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.",
"premiumActivo": "Premium activo"
"premiumActivo": "Premium activo",
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Grabación de emisoras",
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
"premiumPagoUnico": "Pago único, para siempre. No es una suscripción.",
"premiumAhoraNo": "Ahora no",
"autoErrorEmisoraPremium": "Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.",
"autoErrorBusquedaSinResultados": "No hemos encontrado esa emisora. Prueba con otro nombre.",
"autoCarpetaEscuchar": "Escuchar",
"autoCarpetaFavoritos": "Favoritos",
"autoCarpetaTodas": "Todas las emisoras",
"autoCarpetaMisEmisoras": "Mis emisoras",
"autoCarpetaMusicaLocal": "Música Local",
"autoMusicaLocalNoDisponible": "Abre PluriWave en el móvil para leer tu música",
"autoCargarMas": "Más…",
"autoOrdenarPorCalidad": "Ordenar por calidad",
"autoReproducirCarpeta": "Reproducir carpeta",
"autoReproducirAleatorio": "Reproducir aleatorio",
"autoPistaSinNombre": "Pista sin nombre"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Restaurer les achats",
"compraError": "Impossible de finaliser l'achat. Veuillez réessayer.",
"restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.",
"premiumActivo": "Premium actif"
"premiumActivo": "Premium actif",
"premiumHojaTitulo": "Débloquer PluriWave Premium",
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Enregistrement des stations",
"premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes",
"premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)",
"premiumPagoUnico": "Achat unique, pour toujours. Ce n'est pas un abonnement.",
"premiumAhoraNo": "Plus tard",
"autoErrorEmisoraPremium": "Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.",
"autoErrorBusquedaSinResultados": "Nous n'avons pas trouvé cette station. Essaie un autre nom.",
"autoCarpetaEscuchar": "Écouter",
"autoCarpetaFavoritos": "Favoris",
"autoCarpetaTodas": "Toutes les stations",
"autoCarpetaMisEmisoras": "Mes stations",
"autoCarpetaMusicaLocal": "Musique locale",
"autoMusicaLocalNoDisponible": "Ouvrez PluriWave sur votre téléphone pour lire votre musique",
"autoCargarMas": "Plus…",
"autoOrdenarPorCalidad": "Trier par qualité",
"autoReproducirCarpeta": "Lire le dossier",
"autoReproducirAleatorio": "Lecture aléatoire",
"autoPistaSinNombre": "Piste sans nom"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
"premiumActivo": "प्रीमियम सक्रिय"
"premiumActivo": "प्रीमियम सक्रिय",
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
"premiumAhoraNo": "अभी नहीं",
"autoErrorEmisoraPremium": "यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।",
"autoErrorBusquedaSinResultados": "वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।",
"autoCarpetaEscuchar": "सुनें",
"autoCarpetaFavoritos": "पसंदीदा",
"autoCarpetaTodas": "सभी स्टेशन",
"autoCarpetaMisEmisoras": "मेरे स्टेशन",
"autoCarpetaMusicaLocal": "लोकल संगीत",
"autoMusicaLocalNoDisponible": "अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें",
"autoCargarMas": "और…",
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
"autoReproducirAleatorio": "शफ़ल चलाएँ",
"autoPistaSinNombre": "बिना नाम का ट्रैक"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Pulihkan pembelian",
"compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.",
"restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.",
"premiumActivo": "Premium aktif"
"premiumActivo": "Premium aktif",
"premiumHojaTitulo": "Buka PluriWave Premium",
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Perekaman stasiun",
"premiumBeneficioVacaciones": "Rentang liburan untuk alarm",
"premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)",
"premiumPagoUnico": "Pembelian sekali bayar, untuk selamanya. Bukan langganan.",
"premiumAhoraNo": "Nanti saja",
"autoErrorEmisoraPremium": "Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.",
"autoErrorBusquedaSinResultados": "Kami tidak menemukan stasiun itu. Coba nama lain.",
"autoCarpetaEscuchar": "Dengarkan",
"autoCarpetaFavoritos": "Favorit",
"autoCarpetaTodas": "Semua stasiun",
"autoCarpetaMisEmisoras": "Stasiun saya",
"autoCarpetaMusicaLocal": "Musik lokal",
"autoMusicaLocalNoDisponible": "Buka PluriWave di ponsel untuk membaca musik Anda",
"autoCargarMas": "Lainnya…",
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
"autoReproducirCarpeta": "Putar folder",
"autoReproducirAleatorio": "Putar acak",
"autoPistaSinNombre": "Trek tanpa nama"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Ripristina acquisti",
"compraError": "Non è stato possibile completare l'acquisto. Riprova.",
"restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.",
"premiumActivo": "Premium attivo"
"premiumActivo": "Premium attivo",
"premiumHojaTitulo": "Sblocca PluriWave Premium",
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Registrazione delle stazioni",
"premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie",
"premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)",
"premiumPagoUnico": "Acquisto unico, per sempre. Non è un abbonamento.",
"premiumAhoraNo": "Non ora",
"autoErrorEmisoraPremium": "Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.",
"autoErrorBusquedaSinResultados": "Non abbiamo trovato quella stazione. Prova con un altro nome.",
"autoCarpetaEscuchar": "Ascolta",
"autoCarpetaFavoritos": "Preferiti",
"autoCarpetaTodas": "Tutte le emittenti",
"autoCarpetaMisEmisoras": "Le mie emittenti",
"autoCarpetaMusicaLocal": "Musica locale",
"autoMusicaLocalNoDisponible": "Apri PluriWave sul telefono per leggere la tua musica",
"autoCargarMas": "Altro…",
"autoOrdenarPorCalidad": "Ordina per qualità",
"autoReproducirCarpeta": "Riproduci cartella",
"autoReproducirAleatorio": "Riproduzione casuale",
"autoPistaSinNombre": "Traccia senza nome"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "購入を復元",
"compraError": "購入を完了できませんでした。もう一度お試しください。",
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
"premiumActivo": "プレミアム有効"
"premiumActivo": "プレミアム有効",
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "放送局の録音",
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
"premiumAhoraNo": "後で",
"autoErrorEmisoraPremium": "この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。",
"autoErrorBusquedaSinResultados": "その放送局は見つかりませんでした。別の名前をお試しください。",
"autoCarpetaEscuchar": "聴く",
"autoCarpetaFavoritos": "お気に入り",
"autoCarpetaTodas": "すべての局",
"autoCarpetaMisEmisoras": "マイ局",
"autoCarpetaMusicaLocal": "ローカルの音楽",
"autoMusicaLocalNoDisponible": "音楽を読み込むにはスマートフォンで PluriWave を開いてください",
"autoCargarMas": "もっと見る…",
"autoOrdenarPorCalidad": "音質順に並べ替え",
"autoReproducirCarpeta": "フォルダを再生",
"autoReproducirAleatorio": "シャッフル再生",
"autoPistaSinNombre": "名称未設定のトラック"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Restaurar compras",
"compraError": "Não foi possível concluir a compra. Tente novamente.",
"restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.",
"premiumActivo": "Premium ativo"
"premiumActivo": "Premium ativo",
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Gravação de emissoras",
"premiumBeneficioVacaciones": "Períodos de férias para os alarmes",
"premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)",
"premiumPagoUnico": "Pagamento único, para sempre. Não é uma assinatura.",
"premiumAhoraNo": "Agora não",
"autoErrorEmisoraPremium": "Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.",
"autoErrorBusquedaSinResultados": "Não encontrámos essa estação. Tente outro nome.",
"autoCarpetaEscuchar": "Ouvir",
"autoCarpetaFavoritos": "Favoritos",
"autoCarpetaTodas": "Todas as estações",
"autoCarpetaMisEmisoras": "As minhas estações",
"autoCarpetaMusicaLocal": "Música local",
"autoMusicaLocalNoDisponible": "Abra o PluriWave no telemóvel para ler a sua música",
"autoCargarMas": "Mais…",
"autoOrdenarPorCalidad": "Ordenar por qualidade",
"autoReproducirCarpeta": "Reproduzir pasta",
"autoReproducirAleatorio": "Reprodução aleatória",
"autoPistaSinNombre": "Faixa sem nome"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "Восстановить покупки",
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
"premiumActivo": "Премиум активен"
"premiumActivo": "Премиум активен",
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Запись радиостанций",
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
"premiumAhoraNo": "Не сейчас",
"autoErrorEmisoraPremium": "Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.",
"autoErrorBusquedaSinResultados": "Мы не нашли такую станцию. Попробуйте другое название.",
"autoCarpetaEscuchar": "Слушать",
"autoCarpetaFavoritos": "Избранное",
"autoCarpetaTodas": "Все станции",
"autoCarpetaMisEmisoras": "Мои станции",
"autoCarpetaMusicaLocal": "Локальная музыка",
"autoMusicaLocalNoDisponible": "Откройте PluriWave на телефоне, чтобы прочитать вашу музыку",
"autoCargarMas": "Ещё…",
"autoOrdenarPorCalidad": "Сортировать по качеству",
"autoReproducirCarpeta": "Воспроизвести папку",
"autoReproducirAleatorio": "Случайное воспроизведение",
"autoPistaSinNombre": "Трек без названия"
}
+22 -1
View File
@@ -904,5 +904,26 @@
"restaurarCompras": "恢复购买",
"compraError": "无法完成购买,请重试。",
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
"premiumActivo": "高级版已解锁"
"premiumActivo": "高级版已解锁",
"premiumHojaTitulo": "解锁 PluriWave Premium",
"premiumBeneficioSinAnuncios": "全应用无广告",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "电台录音",
"premiumBeneficioVacaciones": "闹钟的假期时间段",
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
"premiumAhoraNo": "以后再说",
"autoErrorEmisoraPremium": "该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。",
"autoErrorBusquedaSinResultados": "没有找到该电台。请换个名称再试。",
"autoCarpetaEscuchar": "收听",
"autoCarpetaFavoritos": "收藏",
"autoCarpetaTodas": "全部电台",
"autoCarpetaMisEmisoras": "我的电台",
"autoCarpetaMusicaLocal": "本地音乐",
"autoMusicaLocalNoDisponible": "请在手机上打开 PluriWave 以读取您的音乐",
"autoCargarMas": "更多…",
"autoOrdenarPorCalidad": "按音质排序",
"autoReproducirCarpeta": "播放文件夹",
"autoReproducirAleatorio": "随机播放",
"autoPistaSinNombre": "未命名曲目"
}
+126
View File
@@ -3367,6 +3367,132 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Premium activo'**
String get premiumActivo;
/// No description provided for @premiumHojaTitulo.
///
/// In es, this message translates to:
/// **'Desbloquea PluriWave Premium'**
String get premiumHojaTitulo;
/// No description provided for @premiumBeneficioSinAnuncios.
///
/// In es, this message translates to:
/// **'Sin publicidad en toda la app'**
String get premiumBeneficioSinAnuncios;
/// No description provided for @premiumBeneficioAndroidAuto.
///
/// In es, this message translates to:
/// **'Android Auto'**
String get premiumBeneficioAndroidAuto;
/// No description provided for @premiumBeneficioGrabacion.
///
/// In es, this message translates to:
/// **'Grabación de emisoras'**
String get premiumBeneficioGrabacion;
/// No description provided for @premiumBeneficioVacaciones.
///
/// In es, this message translates to:
/// **'Rangos de vacaciones para las alarmas'**
String get premiumBeneficioVacaciones;
/// No description provided for @premiumBeneficioAlarmasIlimitadas.
///
/// In es, this message translates to:
/// **'Alarmas ilimitadas (el plan gratuito permite hasta 5)'**
String get premiumBeneficioAlarmasIlimitadas;
/// No description provided for @premiumPagoUnico.
///
/// In es, this message translates to:
/// **'Pago único, para siempre. No es una suscripción.'**
String get premiumPagoUnico;
/// No description provided for @premiumAhoraNo.
///
/// In es, this message translates to:
/// **'Ahora no'**
String get premiumAhoraNo;
/// No description provided for @autoErrorEmisoraPremium.
///
/// In es, this message translates to:
/// **'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.'**
String get autoErrorEmisoraPremium;
/// No description provided for @autoErrorBusquedaSinResultados.
///
/// In es, this message translates to:
/// **'No hemos encontrado esa emisora. Prueba con otro nombre.'**
String get autoErrorBusquedaSinResultados;
/// No description provided for @autoCarpetaEscuchar.
///
/// In es, this message translates to:
/// **'Escuchar'**
String get autoCarpetaEscuchar;
/// No description provided for @autoCarpetaFavoritos.
///
/// In es, this message translates to:
/// **'Favoritos'**
String get autoCarpetaFavoritos;
/// No description provided for @autoCarpetaTodas.
///
/// In es, this message translates to:
/// **'Todas las emisoras'**
String get autoCarpetaTodas;
/// No description provided for @autoCarpetaMisEmisoras.
///
/// In es, this message translates to:
/// **'Mis emisoras'**
String get autoCarpetaMisEmisoras;
/// No description provided for @autoCarpetaMusicaLocal.
///
/// In es, this message translates to:
/// **'Música Local'**
String get autoCarpetaMusicaLocal;
/// No description provided for @autoMusicaLocalNoDisponible.
///
/// In es, this message translates to:
/// **'Abre PluriWave en el móvil para leer tu música'**
String get autoMusicaLocalNoDisponible;
/// No description provided for @autoCargarMas.
///
/// In es, this message translates to:
/// **'Más…'**
String get autoCargarMas;
/// No description provided for @autoOrdenarPorCalidad.
///
/// In es, this message translates to:
/// **'Ordenar por calidad'**
String get autoOrdenarPorCalidad;
/// No description provided for @autoReproducirCarpeta.
///
/// In es, this message translates to:
/// **'Reproducir carpeta'**
String get autoReproducirCarpeta;
/// No description provided for @autoReproducirAleatorio.
///
/// In es, this message translates to:
/// **'Reproducir aleatorio'**
String get autoReproducirAleatorio;
/// No description provided for @autoPistaSinNombre.
///
/// In es, this message translates to:
/// **'Pista sin nombre'**
String get autoPistaSinNombre;
}
class _AppLocalizationsDelegate
+67
View File
@@ -1863,4 +1863,71 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get premiumActivo => 'النسخة المميزة مفعّلة';
@override
String get premiumHojaTitulo => 'افتح PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
@override
String get premiumBeneficioVacaciones => 'فترات إجازة للمنبهات';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)';
@override
String get premiumPagoUnico => 'دفعة واحدة، للأبد. ليس اشتراكًا.';
@override
String get premiumAhoraNo => 'ليس الآن';
@override
String get autoErrorEmisoraPremium =>
'هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.';
@override
String get autoErrorBusquedaSinResultados =>
'لم نعثر على تلك المحطة. جرّب اسمًا آخر.';
@override
String get autoCarpetaEscuchar => 'الاستماع';
@override
String get autoCarpetaFavoritos => 'المفضلة';
@override
String get autoCarpetaTodas => 'كل المحطات';
@override
String get autoCarpetaMisEmisoras => 'محطاتي';
@override
String get autoCarpetaMusicaLocal => 'الموسيقى المحلية';
@override
String get autoMusicaLocalNoDisponible =>
'افتح PluriWave على هاتفك لقراءة موسيقاك';
@override
String get autoCargarMas => 'المزيد…';
@override
String get autoOrdenarPorCalidad => 'الترتيب حسب الجودة';
@override
String get autoReproducirCarpeta => 'تشغيل المجلد';
@override
String get autoReproducirAleatorio => 'تشغيل عشوائي';
@override
String get autoPistaSinNombre => 'مقطع بلا اسم';
}
+68
View File
@@ -1874,4 +1874,72 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
@override
String get premiumHojaTitulo => 'PluriWave Premium আনলক করুন';
@override
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
@override
String get premiumBeneficioVacaciones => 'অ্যালার্মের জন্য ছুটির সময়কাল';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)';
@override
String get premiumPagoUnico =>
'একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।';
@override
String get premiumAhoraNo => 'এখন নয়';
@override
String get autoErrorEmisoraPremium =>
'এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।';
@override
String get autoErrorBusquedaSinResultados =>
'সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।';
@override
String get autoCarpetaEscuchar => 'শুনুন';
@override
String get autoCarpetaFavoritos => 'প্রিয়';
@override
String get autoCarpetaTodas => 'সব স্টেশন';
@override
String get autoCarpetaMisEmisoras => 'আমার স্টেশন';
@override
String get autoCarpetaMusicaLocal => 'স্থানীয় সঙ্গীত';
@override
String get autoMusicaLocalNoDisponible =>
'আপনার গান পড়তে ফোনে PluriWave খুলুন';
@override
String get autoCargarMas => 'আরও…';
@override
String get autoOrdenarPorCalidad => 'মান অনুসারে সাজান';
@override
String get autoReproducirCarpeta => 'ফোল্ডার চালান';
@override
String get autoReproducirAleatorio => 'এলোমেলোভাবে চালান';
@override
String get autoPistaSinNombre => 'নামহীন ট্র্যাক';
}
+67
View File
@@ -1888,4 +1888,71 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get premiumActivo => 'Premium aktiv';
@override
String get premiumHojaTitulo => 'PluriWave Premium freischalten';
@override
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Sender aufnehmen';
@override
String get premiumBeneficioVacaciones => 'Urlaubszeiträume für Wecker';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)';
@override
String get premiumPagoUnico => 'Einmalzahlung, für immer. Kein Abonnement.';
@override
String get premiumAhoraNo => 'Nicht jetzt';
@override
String get autoErrorEmisoraPremium =>
'Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.';
@override
String get autoErrorBusquedaSinResultados =>
'Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.';
@override
String get autoCarpetaEscuchar => 'Hören';
@override
String get autoCarpetaFavoritos => 'Favoriten';
@override
String get autoCarpetaTodas => 'Alle Sender';
@override
String get autoCarpetaMisEmisoras => 'Meine Sender';
@override
String get autoCarpetaMusicaLocal => 'Lokale Musik';
@override
String get autoMusicaLocalNoDisponible =>
'Öffne PluriWave auf dem Handy, um deine Musik zu lesen';
@override
String get autoCargarMas => 'Mehr…';
@override
String get autoOrdenarPorCalidad => 'Nach Qualität sortieren';
@override
String get autoReproducirCarpeta => 'Ordner abspielen';
@override
String get autoReproducirAleatorio => 'Zufallswiedergabe';
@override
String get autoPistaSinNombre => 'Unbenannter Titel';
}
+68
View File
@@ -1867,4 +1867,72 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get premiumActivo => 'Premium active';
@override
String get premiumHojaTitulo => 'Unlock PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Station recording';
@override
String get premiumBeneficioVacaciones => 'Vacation ranges for alarms';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Unlimited alarms (the free plan allows up to 5)';
@override
String get premiumPagoUnico =>
'One-time purchase, forever. Not a subscription.';
@override
String get premiumAhoraNo => 'Not now';
@override
String get autoErrorEmisoraPremium =>
'This station is Premium. Open PluriWave on your phone to unlock it.';
@override
String get autoErrorBusquedaSinResultados =>
'We couldn\'t find that station. Try another name.';
@override
String get autoCarpetaEscuchar => 'Listen';
@override
String get autoCarpetaFavoritos => 'Favorites';
@override
String get autoCarpetaTodas => 'All stations';
@override
String get autoCarpetaMisEmisoras => 'My stations';
@override
String get autoCarpetaMusicaLocal => 'Local music';
@override
String get autoMusicaLocalNoDisponible =>
'Open PluriWave on your phone to read your music';
@override
String get autoCargarMas => 'More…';
@override
String get autoOrdenarPorCalidad => 'Sort by quality';
@override
String get autoReproducirCarpeta => 'Play folder';
@override
String get autoReproducirAleatorio => 'Shuffle play';
@override
String get autoPistaSinNombre => 'Untitled track';
}
+69
View File
@@ -1881,4 +1881,73 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get premiumActivo => 'Premium activo';
@override
String get premiumHojaTitulo => 'Desbloquea PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Grabación de emisoras';
@override
String get premiumBeneficioVacaciones =>
'Rangos de vacaciones para las alarmas';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarmas ilimitadas (el plan gratuito permite hasta 5)';
@override
String get premiumPagoUnico =>
'Pago único, para siempre. No es una suscripción.';
@override
String get premiumAhoraNo => 'Ahora no';
@override
String get autoErrorEmisoraPremium =>
'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.';
@override
String get autoErrorBusquedaSinResultados =>
'No hemos encontrado esa emisora. Prueba con otro nombre.';
@override
String get autoCarpetaEscuchar => 'Escuchar';
@override
String get autoCarpetaFavoritos => 'Favoritos';
@override
String get autoCarpetaTodas => 'Todas las emisoras';
@override
String get autoCarpetaMisEmisoras => 'Mis emisoras';
@override
String get autoCarpetaMusicaLocal => 'Música Local';
@override
String get autoMusicaLocalNoDisponible =>
'Abre PluriWave en el móvil para leer tu música';
@override
String get autoCargarMas => 'Más…';
@override
String get autoOrdenarPorCalidad => 'Ordenar por calidad';
@override
String get autoReproducirCarpeta => 'Reproducir carpeta';
@override
String get autoReproducirAleatorio => 'Reproducir aleatorio';
@override
String get autoPistaSinNombre => 'Pista sin nombre';
}
+70
View File
@@ -1894,4 +1894,74 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get premiumActivo => 'Premium actif';
@override
String get premiumHojaTitulo => 'Débloquer PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios =>
'Aucune publicité dans toute l\'application';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Enregistrement des stations';
@override
String get premiumBeneficioVacaciones =>
'Périodes de vacances pour les alarmes';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarmes illimitées (la version gratuite en autorise jusqu\'à 5)';
@override
String get premiumPagoUnico =>
'Achat unique, pour toujours. Ce n\'est pas un abonnement.';
@override
String get premiumAhoraNo => 'Plus tard';
@override
String get autoErrorEmisoraPremium =>
'Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.';
@override
String get autoErrorBusquedaSinResultados =>
'Nous n\'avons pas trouvé cette station. Essaie un autre nom.';
@override
String get autoCarpetaEscuchar => 'Écouter';
@override
String get autoCarpetaFavoritos => 'Favoris';
@override
String get autoCarpetaTodas => 'Toutes les stations';
@override
String get autoCarpetaMisEmisoras => 'Mes stations';
@override
String get autoCarpetaMusicaLocal => 'Musique locale';
@override
String get autoMusicaLocalNoDisponible =>
'Ouvrez PluriWave sur votre téléphone pour lire votre musique';
@override
String get autoCargarMas => 'Plus…';
@override
String get autoOrdenarPorCalidad => 'Trier par qualité';
@override
String get autoReproducirCarpeta => 'Lire le dossier';
@override
String get autoReproducirAleatorio => 'Lecture aléatoire';
@override
String get autoPistaSinNombre => 'Piste sans nom';
}
+68
View File
@@ -1867,4 +1867,72 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get premiumActivo => 'प्रीमियम सक्रिय';
@override
String get premiumHojaTitulo => 'PluriWave Premium अनलॉक करें';
@override
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
@override
String get premiumBeneficioVacaciones => 'अलार्म के लिए छुट्टी की अवधि';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)';
@override
String get premiumPagoUnico =>
'एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।';
@override
String get premiumAhoraNo => 'अभी नहीं';
@override
String get autoErrorEmisoraPremium =>
'यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।';
@override
String get autoErrorBusquedaSinResultados =>
'वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।';
@override
String get autoCarpetaEscuchar => 'सुनें';
@override
String get autoCarpetaFavoritos => 'पसंदीदा';
@override
String get autoCarpetaTodas => 'सभी स्टेशन';
@override
String get autoCarpetaMisEmisoras => 'मेरे स्टेशन';
@override
String get autoCarpetaMusicaLocal => 'लोकल संगीत';
@override
String get autoMusicaLocalNoDisponible =>
'अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें';
@override
String get autoCargarMas => 'और…';
@override
String get autoOrdenarPorCalidad => 'गुणवत्ता के अनुसार क्रमबद्ध करें';
@override
String get autoReproducirCarpeta => 'फ़ोल्डर चलाएँ';
@override
String get autoReproducirAleatorio => 'शफ़ल चलाएँ';
@override
String get autoPistaSinNombre => 'बिना नाम का ट्रैक';
}
+68
View File
@@ -1878,4 +1878,72 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get premiumActivo => 'Premium aktif';
@override
String get premiumHojaTitulo => 'Buka PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
@override
String get premiumBeneficioVacaciones => 'Rentang liburan untuk alarm';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarm tanpa batas (paket gratis mengizinkan hingga 5)';
@override
String get premiumPagoUnico =>
'Pembelian sekali bayar, untuk selamanya. Bukan langganan.';
@override
String get premiumAhoraNo => 'Nanti saja';
@override
String get autoErrorEmisoraPremium =>
'Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.';
@override
String get autoErrorBusquedaSinResultados =>
'Kami tidak menemukan stasiun itu. Coba nama lain.';
@override
String get autoCarpetaEscuchar => 'Dengarkan';
@override
String get autoCarpetaFavoritos => 'Favorit';
@override
String get autoCarpetaTodas => 'Semua stasiun';
@override
String get autoCarpetaMisEmisoras => 'Stasiun saya';
@override
String get autoCarpetaMusicaLocal => 'Musik lokal';
@override
String get autoMusicaLocalNoDisponible =>
'Buka PluriWave di ponsel untuk membaca musik Anda';
@override
String get autoCargarMas => 'Lainnya…';
@override
String get autoOrdenarPorCalidad => 'Urutkan menurut kualitas';
@override
String get autoReproducirCarpeta => 'Putar folder';
@override
String get autoReproducirAleatorio => 'Putar acak';
@override
String get autoPistaSinNombre => 'Trek tanpa nama';
}
+70
View File
@@ -1891,4 +1891,74 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get premiumActivo => 'Premium attivo';
@override
String get premiumHojaTitulo => 'Sblocca PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios =>
'Nessuna pubblicità in tutta l\'app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Registrazione delle stazioni';
@override
String get premiumBeneficioVacaciones =>
'Intervalli di vacanza per le sveglie';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Sveglie illimitate (il piano gratuito ne consente fino a 5)';
@override
String get premiumPagoUnico =>
'Acquisto unico, per sempre. Non è un abbonamento.';
@override
String get premiumAhoraNo => 'Non ora';
@override
String get autoErrorEmisoraPremium =>
'Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.';
@override
String get autoErrorBusquedaSinResultados =>
'Non abbiamo trovato quella stazione. Prova con un altro nome.';
@override
String get autoCarpetaEscuchar => 'Ascolta';
@override
String get autoCarpetaFavoritos => 'Preferiti';
@override
String get autoCarpetaTodas => 'Tutte le emittenti';
@override
String get autoCarpetaMisEmisoras => 'Le mie emittenti';
@override
String get autoCarpetaMusicaLocal => 'Musica locale';
@override
String get autoMusicaLocalNoDisponible =>
'Apri PluriWave sul telefono per leggere la tua musica';
@override
String get autoCargarMas => 'Altro…';
@override
String get autoOrdenarPorCalidad => 'Ordina per qualità';
@override
String get autoReproducirCarpeta => 'Riproduci cartella';
@override
String get autoReproducirAleatorio => 'Riproduzione casuale';
@override
String get autoPistaSinNombre => 'Traccia senza nome';
}
+65
View File
@@ -1812,4 +1812,69 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get premiumActivo => 'プレミアム有効';
@override
String get premiumHojaTitulo => 'PluriWave Premiumのロックを解除';
@override
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => '放送局の録音';
@override
String get premiumBeneficioVacaciones => 'アラームの休暇期間設定';
@override
String get premiumBeneficioAlarmasIlimitadas => 'アラーム数無制限(無料プランは5個まで)';
@override
String get premiumPagoUnico => '買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。';
@override
String get premiumAhoraNo => '後で';
@override
String get autoErrorEmisoraPremium =>
'この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。';
@override
String get autoErrorBusquedaSinResultados => 'その放送局は見つかりませんでした。別の名前をお試しください。';
@override
String get autoCarpetaEscuchar => '聴く';
@override
String get autoCarpetaFavoritos => 'お気に入り';
@override
String get autoCarpetaTodas => 'すべての局';
@override
String get autoCarpetaMisEmisoras => 'マイ局';
@override
String get autoCarpetaMusicaLocal => 'ローカルの音楽';
@override
String get autoMusicaLocalNoDisponible =>
'音楽を読み込むにはスマートフォンで PluriWave を開いてください';
@override
String get autoCargarMas => 'もっと見る…';
@override
String get autoOrdenarPorCalidad => '音質順に並べ替え';
@override
String get autoReproducirCarpeta => 'フォルダを再生';
@override
String get autoReproducirAleatorio => 'シャッフル再生';
@override
String get autoPistaSinNombre => '名称未設定のトラック';
}
+68
View File
@@ -1878,4 +1878,72 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get premiumActivo => 'Premium ativo';
@override
String get premiumHojaTitulo => 'Desbloqueie o PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Gravação de emissoras';
@override
String get premiumBeneficioVacaciones => 'Períodos de férias para os alarmes';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarmes ilimitados (o plano gratuito permite até 5)';
@override
String get premiumPagoUnico =>
'Pagamento único, para sempre. Não é uma assinatura.';
@override
String get premiumAhoraNo => 'Agora não';
@override
String get autoErrorEmisoraPremium =>
'Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.';
@override
String get autoErrorBusquedaSinResultados =>
'Não encontrámos essa estação. Tente outro nome.';
@override
String get autoCarpetaEscuchar => 'Ouvir';
@override
String get autoCarpetaFavoritos => 'Favoritos';
@override
String get autoCarpetaTodas => 'Todas as estações';
@override
String get autoCarpetaMisEmisoras => 'As minhas estações';
@override
String get autoCarpetaMusicaLocal => 'Música local';
@override
String get autoMusicaLocalNoDisponible =>
'Abra o PluriWave no telemóvel para ler a sua música';
@override
String get autoCargarMas => 'Mais…';
@override
String get autoOrdenarPorCalidad => 'Ordenar por qualidade';
@override
String get autoReproducirCarpeta => 'Reproduzir pasta';
@override
String get autoReproducirAleatorio => 'Reprodução aleatória';
@override
String get autoPistaSinNombre => 'Faixa sem nome';
}
+69
View File
@@ -1884,4 +1884,73 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get premiumActivo => 'Премиум активен';
@override
String get premiumHojaTitulo => 'Разблокировать PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios =>
'Никакой рекламы во всём приложении';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Запись радиостанций';
@override
String get premiumBeneficioVacaciones => 'Периоды отпуска для будильников';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Неограниченное количество будильников (бесплатный план позволяет до 5)';
@override
String get premiumPagoUnico =>
'Единоразовая покупка, навсегда. Это не подписка.';
@override
String get premiumAhoraNo => 'Не сейчас';
@override
String get autoErrorEmisoraPremium =>
'Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.';
@override
String get autoErrorBusquedaSinResultados =>
'Мы не нашли такую станцию. Попробуйте другое название.';
@override
String get autoCarpetaEscuchar => 'Слушать';
@override
String get autoCarpetaFavoritos => 'Избранное';
@override
String get autoCarpetaTodas => 'Все станции';
@override
String get autoCarpetaMisEmisoras => 'Мои станции';
@override
String get autoCarpetaMusicaLocal => 'Локальная музыка';
@override
String get autoMusicaLocalNoDisponible =>
'Откройте PluriWave на телефоне, чтобы прочитать вашу музыку';
@override
String get autoCargarMas => 'Ещё…';
@override
String get autoOrdenarPorCalidad => 'Сортировать по качеству';
@override
String get autoReproducirCarpeta => 'Воспроизвести папку';
@override
String get autoReproducirAleatorio => 'Случайное воспроизведение';
@override
String get autoPistaSinNombre => 'Трек без названия';
}
+64
View File
@@ -1797,4 +1797,68 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get premiumActivo => '高级版已解锁';
@override
String get premiumHojaTitulo => '解锁 PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => '全应用无广告';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => '电台录音';
@override
String get premiumBeneficioVacaciones => '闹钟的假期时间段';
@override
String get premiumBeneficioAlarmasIlimitadas => '无限闹钟(免费版最多支持5个)';
@override
String get premiumPagoUnico => '一次性付费,永久使用,不是订阅。';
@override
String get premiumAhoraNo => '以后再说';
@override
String get autoErrorEmisoraPremium =>
'该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。';
@override
String get autoErrorBusquedaSinResultados => '没有找到该电台。请换个名称再试。';
@override
String get autoCarpetaEscuchar => '收听';
@override
String get autoCarpetaFavoritos => '收藏';
@override
String get autoCarpetaTodas => '全部电台';
@override
String get autoCarpetaMisEmisoras => '我的电台';
@override
String get autoCarpetaMusicaLocal => '本地音乐';
@override
String get autoMusicaLocalNoDisponible => '请在手机上打开 PluriWave 以读取您的音乐';
@override
String get autoCargarMas => '更多…';
@override
String get autoOrdenarPorCalidad => '按音质排序';
@override
String get autoReproducirCarpeta => '播放文件夹';
@override
String get autoReproducirAleatorio => '随机播放';
@override
String get autoPistaSinNombre => '未命名曲目';
}
+130 -8
View File
@@ -10,12 +10,15 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
@@ -88,7 +91,7 @@ Future<void> main() async {
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
@@ -103,7 +106,7 @@ Future<void> main() async {
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway.
unawaited(aplicarPoliticaOrientacion());
@@ -151,6 +154,18 @@ Future<void> main() async {
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
// eq-estado-unico items A/B: the handler's own link to the equalizer's
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
// `prefs` instance resolved just above — no widget tree, no Provider — so
// it is available on EVERY engine, including the headless one Android Auto
// starts. Before this, the persisted value only reached the handler
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
// runs: the handler played with the equalizer forced on while disk and the
// phone UI both said off, and a toggle made in the car was lost on
// restart. Passed as two narrow function ports, mirroring the
// read-function convention used for the preset folder right above.
final ecualizador = ServicioEcualizador(prefs: prefs);
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches
@@ -179,7 +194,33 @@ Future<void> main() async {
// radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) {
registrarHandler(handler);
registrarHandler(
handler,
leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo,
// The PRESET's half of the same seam. Without it the handler enabled
// the equalizer with its hardcoded FLAT preset on any engine where the
// phone UI never ran — i.e. every headless Android Auto bind. There is
// no write port: `EstadoEcualizador` still owns saving presets (a car
// preset choice goes through it), so the handler only ever reads.
leerPresetPersistido: ecualizador.leerPresetPrincipal,
// Skip context («in which list am I»). Bound here, on the audio
// bootstrap path of EVERY engine, precisely because the headless
// Android Auto engine builds no widget tree and therefore no
// `EstadoRadio`: a context only the phone UI could write would be a
// context the car could never have.
leerContextoSalto: contextoSaltoPersistido,
guardarContextoSalto: guardarContextoSalto,
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
// reason as the skip context: `EstadoRadio` — which used to be its only
// writer — belongs to the widget tree, and the Android Auto engine
// builds none, so a session that happened only in the car never updated
// the key and the head unit was offered whatever the PHONE last played.
// The write port is now the key's single writer; the read port feeds the
// cold-start metadata seed and the bare-`play()` resume.
leerUltimaEmisora: ultimaEmisoraPersistida,
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention
@@ -189,7 +230,7 @@ Future<void> main() async {
unawaited(sesionAudio.configurar());
}
Widget construirApp() => _OrientacionResponsiveApp(
Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
@@ -268,20 +309,80 @@ Future<void> aplicarPoliticaOrientacion({
}
}
class _OrientacionResponsiveApp extends StatefulWidget {
const _OrientacionResponsiveApp({required this.child});
/// Whether the Android Auto browse tree must be invalidated right now
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
///
/// The trigger used to be `View.maybeOf(context) != null` inside
/// `didChangeDependencies`, latched once, on the premise that «a View means
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
/// wraps the tree in a `View` built from
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
/// on the headless `audio_service` engine — which demonstrably reaches
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
/// the first `didChangeDependencies`. The one-shot latch was spent at the
/// exact moment it could accomplish nothing (`_childrenSubjects` still
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
/// fire again, because `didChangeDependencies` does not re-run when an
/// Activity later attaches to that same cached engine.
///
/// Two conditions replace it, both required:
///
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
/// means «an Activity is attached and in the foreground». It reaches Dart
/// exclusively through `SystemChannels.lifecycle` (or
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
/// it, driven by the Activity's own `onResume`.
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
/// APPLICATION context and runs the Dart entrypoint immediately, with no
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
/// it on the headless engine.
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
/// worth spending, and it is also the belt to `resumed`'s braces: even if
/// a lifecycle event did somehow arrive during a headless cold start,
/// nothing has subscribed yet, so the latch survives for the moment an
/// Activity really does attach.
///
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
/// not send twenty `notifyChildrenChanged` storms to the car.
///
/// Pure, so the whole policy is testable without an engine.
@visibleForTesting
bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
///
/// Public only so a test can mount it and drive real lifecycle events
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
/// trigger shipped broken precisely because nothing could reach it.
@visibleForTesting
class OrientacionResponsiveApp extends StatefulWidget {
const OrientacionResponsiveApp({super.key, required this.child});
final Widget child;
@override
State<_OrientacionResponsiveApp> createState() =>
State<OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState();
}
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
with WidgetsBindingObserver {
ui.Display? _display;
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
/// coche se dispara UNA sola vez. Ver
/// [debeInvalidarArbolAutoAlReanudar].
bool _arbolAutoInvalidado = false;
@override
void initState() {
super.initState();
@@ -295,6 +396,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
unawaited(aplicarPoliticaOrientacion(display: _display));
}
/// `resumed` es lo único que significa de verdad «ya hay Activity
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
/// resolver la música local; Android Auto no vuelve a preguntar por su
/// cuenta, así que se lo decimos aquí. Ver
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!debeInvalidarArbolAutoAlReanudar(
estado: state,
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
yaInvalidado: _arbolAutoInvalidado,
)) {
return;
}
_arbolAutoInvalidado = true;
invalidarArbolAuto();
}
@override
void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display));
@@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../../estado/estado_alarmas.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// Applies a successfully-parsed backup to BOTH independent notifiers that
/// own pieces of it (fix/import-alarmas-y-paywall).
///
/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON
/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate
/// long-lived `ChangeNotifier` that loaded its alarms into memory at
/// construction and never re-reads storage on its own — `EstadoRadio` stays
/// deliberately free of a dependency on it. Without the two calls below the
/// imported block is invisible to the running app: the UI keeps showing the
/// pre-import alarms, a later edit would persist that stale in-memory list
/// OVER the imported one, and the imported alarms would never be
/// (re)scheduled with the Android native layer even after a restart.
///
/// Extracted as a top-level function (rather than inlined in `_importar`)
/// so this exact production sequence — not a reimplementation of it — is
/// directly unit-testable without depending on the `file_picker` platform
/// channel or the confirmation dialog.
Future<void> aplicarImportacionConfig(
EstadoRadio estado,
EstadoAlarmas alarmas,
Map<String, dynamic> json,
) async {
await estado.importarConfig(json);
// Re-reads from storage — clears ServicioAlarmas' in-memory cache so the
// just-imported alarms/vacations/exceptions (same JSON block, same
// notifier) replace the stale ones.
await alarmas.cargarPersistidasSinRecalcular();
// Recomputes next-run times against the (now fresh) imported data and
// re-syncs every alarm with the Android native scheduler.
await alarmas.refrescarProgramacion();
}
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's
@@ -102,8 +135,9 @@ class _CuerpoBackup extends StatelessWidget {
if (confirmar != true) return;
if (context.mounted) {
final estado = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final messenger = ScaffoldMessenger.of(context);
await estado.importarConfig(json);
await aplicarImportacionConfig(estado, alarmas, json);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)),
);
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart';
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
@@ -54,6 +55,15 @@ class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance.
// fix/android-auto-musica-local, item 4: acaba de aparecer música
// local donde antes no había. Android Auto cachea la raíz y no
// vuelve a preguntar por su cuenta, así que sin esto el coche seguía
// sin ofrecer «Música Local» hasta el siguiente re-bind — que puede
// no llegar en toda la sesión. Fuera del `context.mounted` de abajo:
// el árbol del coche no depende de que esta pantalla siga viva.
invalidarArbolAuto();
if (!context.mounted) return;
setState(() {
_carpetaActual = Future.value(uri);
+11 -5
View File
@@ -5,11 +5,17 @@ import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart';
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
/// "Timeout without re-init"): the vendored `audio_service` plugin's
/// self-bind has no native timeout and an unhandled `onConnectionSuspended`
/// case, so under bind contention (Android Auto cold start) the handshake
/// can hang forever. Top-level const so tests can reference the production
/// value without duplicating it.
/// "Timeout without re-init"): the `audio_service` plugin's self-bind has no
/// native timeout and an unhandled `onConnectionSuspended` case, so under
/// bind contention (Android Auto cold start) the handshake can hang forever.
/// Top-level const so tests can reference the production value without
/// duplicating it.
///
/// This doc called the plugin "vendored". It is not: `pubspec.lock` pins the
/// hosted pub.dev `audio_service` 0.18.18 and `pubspec.yaml` declares no
/// `dependency_overrides`. Anyone reading the sentence above would go looking
/// for a local copy to patch, and there is none — the behaviour described is
/// upstream's, so the workaround has to live here.
const timeoutArranqueAudio = Duration(seconds: 8);
/// Outcome of racing an `AudioService.init` future against
+4 -2
View File
@@ -6,8 +6,10 @@ import '../modelos/pista_local.dart';
/// instance rather than mutating in place, mirroring how
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without
/// the handler (which cannot be instantiated in unit tests — see this
/// module's sibling test file's doc comment).
/// the handler. (That last clause used to read "which cannot be instantiated
/// in unit tests"; it can — see `construirControlesTransporte`'s doc in
/// `servicio_audio.dart`. Keeping the queue logic out of the handler is
/// still worth it, but for design reasons, not for that one.)
class ColaLocal {
const ColaLocal({required this.pistas, this.indice = 0});
+278
View File
@@ -0,0 +1,278 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
/// The skip context's persistence key.
///
/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports
/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor
/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the
/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is
/// never constructed there — a context only that class could write would be a
/// context the car can never have.
///
/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly
/// instead of silently leaving every driver context-less after an update.
const claveContextoSalto = 'contexto_salto_v1';
/// Which LIST the driver is walking with the car's previous/next buttons.
///
/// The type is the durable part; the members are not. A group's contents
/// change between sessions (the phone renames it, empties it, deletes it), so
/// remembering the members would be remembering something that expires —
/// [resolverListaContexto] re-resolves against the LIVE lists every time.
enum TipoContextoSalto {
/// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId].
grupoFavoritos,
favoritos,
misEmisoras,
/// The `populares` catalogue snapshot.
todas,
/// The free tier's curated set (`emisorasDestacadas`). The ONLY type that
/// carries [ContextoSalto.uuidsOrdenados] — see that field.
destacadas,
}
/// The remembered playback context: the smallest thing that still identifies
/// the list on the other side of a process restart.
class ContextoSalto {
/// One favourites group, named by its stable id.
const ContextoSalto.grupo(String grupoId)
: tipo = TipoContextoSalto.grupoFavoritos,
grupoFavoritosId = grupoId,
uuidsOrdenados = const [];
const ContextoSalto.favoritos()
: tipo = TipoContextoSalto.favoritos,
grupoFavoritosId = null,
uuidsOrdenados = const [];
const ContextoSalto.misEmisoras()
: tipo = TipoContextoSalto.misEmisoras,
grupoFavoritosId = null,
uuidsOrdenados = const [];
const ContextoSalto.todas()
: tipo = TipoContextoSalto.todas,
grupoFavoritosId = null,
uuidsOrdenados = const [];
/// The free tier's set, FROZEN in [uuids] order.
const ContextoSalto.destacadas(List<String> uuids)
: tipo = TipoContextoSalto.destacadas,
grupoFavoritosId = null,
uuidsOrdenados = uuids;
final TipoContextoSalto tipo;
/// Set only for [TipoContextoSalto.grupoFavoritos].
final String? grupoFavoritosId;
/// The frozen order, set only for [TipoContextoSalto.destacadas].
///
/// Every other type resolves against a list that HAS a stable, user-owned
/// order (the favourites' `orden` column, the custom-stations file, the
/// catalogue snapshot), so freezing it would only mean ignoring a reorder
/// the user just made on the phone. The free set is the exception:
/// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida,
/// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous`
/// stops being the inverse of `next`. Freezing that order is the fix.
final List<String> uuidsOrdenados;
Map<String, dynamic> aMapa() => {
'tipo': tipo.name,
if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId,
if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados,
};
/// Parses a persisted map, or `null` when it is unusable.
///
/// Tolerant on purpose: this payload survives app updates, backups and
/// hand-edited preference files, and it is read from a steering-wheel
/// button. An unreadable context must mean "derive it again", never a
/// crash.
static ContextoSalto? desdeMapa(Map<String, dynamic> mapa) {
final tipoRaw = mapa['tipo'];
if (tipoRaw is! String) return null;
final tipo = TipoContextoSalto.values
.where((t) => t.name == tipoRaw)
.firstOrNull;
if (tipo == null) return null;
switch (tipo) {
case TipoContextoSalto.grupoFavoritos:
final grupoId = mapa['grupoId'];
// A group context with no group is not a context.
if (grupoId is! String || grupoId.isEmpty) return null;
return ContextoSalto.grupo(grupoId);
case TipoContextoSalto.favoritos:
return const ContextoSalto.favoritos();
case TipoContextoSalto.misEmisoras:
return const ContextoSalto.misEmisoras();
case TipoContextoSalto.todas:
return const ContextoSalto.todas();
case TipoContextoSalto.destacadas:
final uuids = mapa['uuids'];
if (uuids is! List) return null;
return ContextoSalto.destacadas(uuids.whereType<String>().toList());
}
}
@override
bool operator ==(Object other) =>
other is ContextoSalto &&
other.tipo == tipo &&
other.grupoFavoritosId == grupoFavoritosId &&
_mismosUuids(other.uuidsOrdenados, uuidsOrdenados);
@override
int get hashCode => Object.hash(
tipo,
grupoFavoritosId,
Object.hashAll(uuidsOrdenados),
);
@override
String toString() =>
'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, '
'uuids=${uuidsOrdenados.length})';
static bool _mismosUuids(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
/// Persists [contexto]. Never throws — a failed write costs the driver a
/// re-derivation, an exception would cost them the station change.
Future<void> guardarContextoSalto(
ContextoSalto contexto, {
SharedPreferences? prefs,
}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa()));
} catch (_) {
// Deliberately swallowed — see the doc above.
}
}
/// Reads the persisted context, or `null` when there is none, the payload is
/// unreadable, or prefs themselves fail.
///
/// Follows the same inject-or-`getInstance()` convention as
/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins
/// prefs without a platform channel.
Future<ContextoSalto?> contextoSaltoPersistido({
SharedPreferences? prefs,
}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
final raw = resueltas.getString(claveContextoSalto);
if (raw == null) return null;
final decodificado = jsonDecode(raw);
if (decodificado is! Map) return null;
return ContextoSalto.desdeMapa(Map<String, dynamic>.from(decodificado));
} catch (_) {
return null;
}
}
/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an
/// empty list when it no longer resolves at all.
///
/// Pure — no handler, no prefs — so every degradation rule below is testable
/// on its own. An empty result means "this memory has expired": the caller
/// derives a fresh context instead (and, failing that, leaves playback alone —
/// never jumps somewhere arbitrary mid-drive).
///
/// Degradation rules, all of them deliberate. The group chain is the owner's,
/// decided from real use in the car:
/// * remembered group ALIVE -> it is walked, even when the playing station
/// has LEFT it (the caller then takes the group's first station) and even
/// when it is down to a single member (skipping there simply leaves the
/// driver where they are — a one-station group is still a group).
/// * remembered group DELETED, or alive but EMPTY -> widen to all
/// favourites, whether or not the playing station is still one of them:
/// "if the whole group is gone, pick a station from the favourites".
/// * no favourites left -> empty, i.e. the no-stations behaviour.
/// * every OTHER context type still expires when the playing station left
/// its list (unfavourited, removed from the catalogue snapshot) — the
/// owner's decision was about the group chain only.
/// * [TipoContextoSalto.destacadas] alone honours
/// [ContextoSalto.uuidsOrdenados] — see that field for why.
List<Emisora> resolverListaContexto({
required ContextoSalto contexto,
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
required List<Emisora> destacadas,
required List<GrupoFavoritos> grupos,
}) {
bool contiene(List<Emisora> lista) =>
lista.any((e) => e.uuid == actual.uuid);
switch (contexto.tipo) {
case TipoContextoSalto.grupoFavoritos:
final grupoId = contexto.grupoFavoritosId;
if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) {
return const [];
}
final existe = grupos.any((g) => g.id == grupoId);
final miembros =
favoritos.where((e) => e.grupoFavoritosId == grupoId).toList();
if (existe && miembros.isNotEmpty) {
// A surviving group is honoured as-is. The station does NOT have to
// still be in it — the caller takes the group's first station rather
// than wandering off to another list.
return miembros;
}
// Group deleted (or alive but empty, which offers no station to take):
// widen to all favourites. Unlike the other context types this does not
// require the station to still BE a favourite — the caller takes the
// first one.
return favoritos;
case TipoContextoSalto.favoritos:
return contiene(favoritos) ? favoritos : const [];
case TipoContextoSalto.misEmisoras:
return contiene(misEmisoras) ? misEmisoras : const [];
case TipoContextoSalto.todas:
return contiene(todas) ? todas : const [];
case TipoContextoSalto.destacadas:
// The frozen order is authoritative. `actual` is resolvable from itself
// so a station frozen into the walk from a previous session still
// resolves even when it never belonged to the curated set.
final porUuid = <String, Emisora>{
for (final e in destacadas) e.uuid: e,
actual.uuid: actual,
};
final lista = <Emisora>[
for (final uuid in contexto.uuidsOrdenados)
if (porUuid[uuid] != null) porUuid[uuid]!,
];
return lista.any((e) => e.uuid == actual.uuid) ? lista : const [];
}
}
/// The free tier's frozen walk order: the curated set in its compiled-in
/// order, with [actual] prepended when it does not belong to it.
///
/// Prepending rather than dropping keeps both buttons alive for a station left
/// over from a premium session (or from `ultima_emisora_v1`): a walk the
/// playing station is not part of would make `emisoraVecina` return `null` and
/// both buttons would be dead.
List<String> uuidsCongeladosDestacadas({
required Emisora actual,
required List<Emisora> destacadas,
}) => [
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
...destacadas.map((e) => e.uuid),
];
+196
View File
@@ -0,0 +1,196 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
/// The last-played station's persistence key.
///
/// MUST stay byte-identical to `EstadoRadio._keyUltimaEmisora`
/// (`lib/estado/estado_radio.dart`), which is the only writer. It is
/// duplicated here rather than exported from there on purpose: this file has
/// to be readable from the headless Android Auto engine, where `EstadoRadio`
/// is never constructed, and importing a `ChangeNotifier` that pulls in the
/// whole app-state graph just to read one string constant would drag the
/// entire phone-side stack into a car bind. `emisoras_destacadas_test.dart`
/// pins the literal so a rename on either side fails loudly.
const claveUltimaEmisora = 'ultima_emisora_v1';
/// The stations a FREE-tier driver can browse and play in the car
/// (fix/auto-quality-guidelines, item 6).
///
/// Compiled into the binary, on purpose. Everything else the car could show
/// is empty on the bind a Play reviewer actually performs: a fresh install
/// is free tier (`esPremiumPersistido` is `getBool(...) ?? false`, no trial
/// key), `FuenteEmisorasAutoLocal.todas()` is literally
/// `_snapshotTodas ?? const []` until `EstadoRadio` pushes a network
/// snapshot that a headless bind never fetches, favourites and custom
/// stations are empty, and `ultima_emisora_v1` is absent. A curated const
/// list is the ONLY thing that can put real, playable rows in front of that
/// reviewer.
///
/// Deliberately small. This is not a catalogue — the catalogue is the
/// premium feature. Six rows is enough to prove the app works and short
/// enough to read at a glance from a driving position.
///
/// `favicon` is null for every entry on purpose: `artUriPara` then resolves
/// the on-brand bundled `station_art_*` drawable, so a browse row needs no
/// network at all to render its artwork.
///
/// `uuid`s are app-owned (`pw-destacada-*`), not Radio Browser uuids: these
/// rows must resolve identically whether or not the catalogue is reachable,
/// and a Radio Browser uuid we cannot re-fetch would be a promise this file
/// cannot keep.
const List<Emisora> emisorasDestacadas = [
Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
),
Emisora(
uuid: 'pw-destacada-france-inter',
nombre: 'France Inter',
url: 'https://icecast.radiofrance.fr/franceinter-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
),
Emisora(
uuid: 'pw-destacada-deutschlandfunk',
nombre: 'Deutschlandfunk',
url: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3',
pais: 'Germany',
codigoPais: 'DE',
idioma: 'german',
),
Emisora(
uuid: 'pw-destacada-kexp',
nombre: 'KEXP 90.3 FM',
url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
Emisora(
uuid: 'pw-destacada-radio-paradise',
nombre: 'Radio Paradise',
url: 'https://stream.radioparadise.com/mp3-128',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
Emisora(
uuid: 'pw-destacada-soma-groove-salad',
nombre: 'SomaFM Groove Salad',
url: 'https://ice1.somafm.com/groovesalad-128-mp3',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
];
/// The free tier's complete, ordered station set: the last station the user
/// actually played (when one is persisted) first, then [emisorasDestacadas],
/// deduplicated by `uuid`.
///
/// Last-played goes first because it is the single row a returning driver is
/// most likely to want, and because it is the only entry that can make the
/// free folder feel like *their* app rather than a demo. It is NOT appended
/// a second time when it already belongs to the curated set.
///
/// Follows `esPremiumPersistido({SharedPreferences? prefs})`'s
/// inject-or-`getInstance()` convention (`estado_entitlement.dart`), so a
/// test can pin prefs without a platform channel.
///
/// Never throws: a corrupt/foreign `ultima_emisora_v1` payload, or a
/// `SharedPreferences` failure, degrades to the curated set alone. This runs
/// inside `getChildren`, and a browse call that throws is a dead folder.
Future<List<Emisora>> resolverEmisorasDestacadas({
SharedPreferences? prefs,
}) async {
final ultima = await _ultimaEmisora(prefs: prefs);
if (ultima == null) return emisorasDestacadas;
return [
ultima,
...emisorasDestacadas.where((e) => e.uuid != ultima.uuid),
];
}
/// Whether [uuid] belongs to [destacadas] — the predicate every play-path
/// gate reads to tell "free content" from "the premium catalogue".
///
/// Pure, and takes the free universe rather than resolving it, so a caller
/// that already holds the list (every one of them does — it also needs it to
/// build the response) asks the question without a second prefs round trip.
///
/// A `null` or empty [uuid] is never free: `emisora:` with no tail is a
/// malformed id, and matching it against an entry with an empty uuid would be
/// a resolution hole rather than a feature.
bool esEmisoraGratuita(String? uuid, List<Emisora> destacadas) =>
uuid != null && uuid.isNotEmpty && destacadas.any((e) => e.uuid == uuid);
/// [esEmisoraGratuita] against the CURRENT free set, resolved here. For
/// callers that do not already hold the list.
Future<bool> esEmisoraGratuitaPorUuid(
String uuid, {
SharedPreferences? prefs,
}) async =>
esEmisoraGratuita(uuid, await resolverEmisorasDestacadas(prefs: prefs));
/// Reads the persisted last-played station, or `null` when there is none.
///
/// Public because the Android Auto "recent" browse root
/// (`AudioService.recentRootId`) needs exactly this one station and nothing
/// else: `onGetRoot` (`AudioService.java:817-821`) answers `recent` whenever
/// the head unit sends `EXTRA_RECENT`, which Android Auto does on every
/// reconnect, and the platform expects a SINGLE resume item there — not a
/// station list, and not an empty folder.
///
/// Tier-independent on purpose: this station is by definition one the user
/// has already played on this device, so offering to resume it is never
/// leaking premium content they have not already had.
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
_ultimaEmisora(prefs: prefs);
/// Writes [emisora] as the last-played station — the SINGLE writer of
/// [claveUltimaEmisora].
///
/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio`
/// because the key has to be written from the engine Android Auto starts,
/// which builds no widget tree and therefore never constructs `EstadoRadio`
/// at all: a session that happened only in the car used to leave the key
/// holding whatever the PHONE last played, so the head unit's resume row and
/// the free tier's featured folder were both stale on the next connect.
///
/// Deliberately NOT swallowing failures here: the handler port that calls it
/// traces and swallows (a persistence failure must never break playback),
/// and a silent `catch` in BOTH places would make a dead write channel
/// invisible from a car logcat.
Future<void> guardarUltimaEmisoraPersistida(
Emisora emisora, {
SharedPreferences? prefs,
}) async {
final resueltas = prefs ?? await SharedPreferences.getInstance();
await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap()));
}
/// Reads the persisted last-played station, or `null` when there is none,
/// the payload is unreadable, or prefs themselves fail.
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
final raw = resueltas.getString(claveUltimaEmisora);
if (raw == null) return null;
final emisora = Emisora.fromMap(jsonDecode(raw) as Map<String, dynamic>);
// A record with no uuid or no url cannot be turned into a playable
// `emisora:<uuid>` row, so it is worse than absent: it would occupy the
// first slot with a row that does nothing when tapped.
if (emisora.uuid.isEmpty || emisora.url.isEmpty) return null;
return emisora;
} catch (_) {
return null;
}
}
+68 -9
View File
@@ -1,5 +1,6 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -71,6 +72,24 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
return recortado.isEmpty ? nombreGenerico : recortado;
}
/// Three-valued answer to «¿hay música local usable?»
/// (fix/android-auto-musica-local).
///
/// Sustituye al `bool` anterior, que colapsaba dos causas MUY distintas en
/// el mismo `false`:
///
/// * [noConfigurada] — no hay URI persistida, o el nativo respondió que el
/// permiso ya no es válido (el usuario nunca eligió carpeta, o la
/// revocó). Es la única respuesta que justifica ocultar el nodo.
/// * [configurada] — hay URI persistida y el nativo confirma el permiso.
/// * [canalNoDisponible] — hay URI persistida pero el canal
/// `pluriwave/file_actions` NO tiene handler nativo, así que no se puede
/// saber nada del permiso. Es lo que ocurre en el motor Flutter headless
/// que `audio_service` levanta cuando Android Auto arranca la app sin
/// Activity: `MainActivity.configureFlutterEngine` (único sitio donde se
/// registra ese canal) nunca corre. NO significa «no hay carpeta».
enum EstadoCarpetaLocal { noConfigurada, configurada, canalNoDisponible }
/// Browse-source abstraction for the local-music branch of the Android Auto
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as
@@ -78,9 +97,11 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
/// browse domain, not a station source.
abstract class FuenteMusicaLocalAuto {
/// Whether a local-music root folder is picked AND its permission is
/// still valid. Never throws — a revoked/never-granted permission
/// degrades to `false` (Spec "Permission revoked or never granted").
Future<bool> hayCarpetaConfigurada();
/// still valid — o si esa pregunta no se puede contestar porque el canal
/// nativo no existe en este motor. Never throws: cualquier fallo degrada
/// a un valor de [EstadoCarpetaLocal], nunca a una excepción (Spec
/// "Permission revoked or never granted").
Future<EstadoCarpetaLocal> estadoCarpeta();
/// Immediate children of [documentId] (`''` = the tree root itself), one
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager
@@ -197,18 +218,56 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
}
@override
Future<bool> hayCarpetaConfigurada() async {
Future<EstadoCarpetaLocal> estadoCarpeta() async {
// Its OWN try, deliberately not merged with the channel one below.
//
// Never-throws restoration: the three-valued refactor moved this read
// outside the try, and the only caller (`getChildren`'s root branch)
// awaits it inline — so a prefs failure took the whole browse root down
// and emptied the car, against this method's own interface doc.
//
// Kept SEPARATE because a prefs failure and a channel failure both
// surface as `MissingPluginException`: one shared `on
// MissingPluginException` clause would answer `canalNoDisponible` —
// «hay carpeta pero no puedo comprobar el permiso» — for a store that
// never told us whether a folder exists at all. That would put an
// unreachable «Música Local» node in the car explaining a channel
// problem that is not happening, which is precisely the collapse the
// three-valued [EstadoCarpetaLocal] exists to prevent.
//
// `noConfigurada` is the honest answer here (the app cannot prove a
// folder was ever picked) and is what this path returned before the
// refactor, when the read still sat inside the catch-all below.
final String? uri;
try {
uri = await _uriPersistida();
} catch (e) {
debugPrint('[PluriWave][musica_local] no se pudo leer la URI local: $e');
return EstadoCarpetaLocal.noConfigurada;
}
if (uri == null || uri.isEmpty) return EstadoCarpetaLocal.noConfigurada;
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
'treeUri': uri,
});
return valido ?? false;
} catch (_) {
return valido == true
? EstadoCarpetaLocal.configurada
: EstadoCarpetaLocal.noConfigurada;
} on MissingPluginException catch (e) {
// El canal no tiene handler en ESTE motor. Antes esto caía en el
// mismo `catch (_)` que un permiso revocado y devolvía `false`, que
// es exactamente por lo que «Música Local» desaparecía del árbol de
// Android Auto cuando el coche arrancaba la app sin Activity.
debugPrint(
'[PluriWave][musica_local] hasPersistedPermission sin handler '
'nativo (motor sin Activity): $e',
);
return EstadoCarpetaLocal.canalNoDisponible;
} catch (e) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted"): never throw, degrade to "not configured".
return false;
debugPrint('[PluriWave][musica_local] hasPersistedPermission ERROR $e');
return EstadoCarpetaLocal.noConfigurada;
}
}
+399 -96
View File
@@ -10,6 +10,8 @@ import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'contexto_reproduccion.dart';
import 'emisoras_destacadas.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -200,10 +202,97 @@ abstract class FuenteEmisorasAuto {
}) {}
}
/// Every user-readable label of the Android Auto browse tree, already
/// resolved to one locale by the caller.
///
/// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can
/// read gets translated. This bundle replaces the previous
/// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key"
/// convention, which was defensible only while those labels sat deep inside
/// a premium tree and stopped being defensible the moment Google Play
/// reviewed the car surface on an English head unit.
///
/// It exists as a plain value object rather than an `AppLocalizations`
/// dependency so [ConstructorArbolAuto] stays a PURE builder — the same
/// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler,
/// which can resolve localizations headlessly through
/// `resolverLocalizacionesRespaldo`, builds one via
/// `etiquetasArbolAutoDesde` and hands it in.
///
/// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`,
/// …). Those are ranges of Latin letters, not prose — translating them would
/// make them lie about which filenames they contain.
class EtiquetasArbolAuto {
const EtiquetasArbolAuto({
required this.escuchar,
required this.favoritos,
required this.todasLasEmisoras,
required this.misEmisoras,
required this.musicaLocal,
required this.musicaLocalNoDisponible,
required this.cargarMas,
required this.ordenarPorCalidad,
required this.reproducirCarpeta,
required this.reproducirAleatorio,
required this.pistaSinNombre,
});
/// Fallback bundle for callers that have no localizations to hand: pure
/// builder tests, and any future non-car consumer.
///
/// It is NOT what the car shows. `ServicioAudio` always injects a bundle
/// resolved from `AppLocalizations`, in every browse and playback path
/// that can produce a label — `etiquetas_arbol_auto_test.dart` is the
/// guard that no NEW hardcoded label can be introduced alongside these.
static const respaldo = EtiquetasArbolAuto(
escuchar: 'Escuchar',
favoritos: 'Favoritos',
todasLasEmisoras: 'Todas las emisoras',
misEmisoras: 'Mis emisoras',
musicaLocal: 'Música Local',
musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música',
cargarMas: 'Más…',
ordenarPorCalidad: 'Ordenar por calidad',
reproducirCarpeta: 'Reproducir carpeta',
reproducirAleatorio: 'Reproducir aleatorio',
pistaSinNombre: 'Pista sin nombre',
);
/// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]).
final String escuchar;
/// Premium root folders.
final String favoritos;
final String todasLasEmisoras;
final String misEmisoras;
final String musicaLocal;
/// The non-playable row shown when the local-music folder cannot be read
/// from the car ([ConstructorArbolAuto.idLocalNoLista]).
final String musicaLocalNoDisponible;
/// Trailing "load more" row of every paged local-music view.
final String cargarMas;
/// Local-folder navigation and action rows.
final String ordenarPorCalidad;
final String reproducirCarpeta;
final String reproducirAleatorio;
/// Fallback title for a local file whose name is blank after stripping.
final String pistaSinNombre;
}
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
/// resolution. No platform dependency — fully testable without a running
/// car or a real `AudioHandler`.
class ConstructorArbolAuto {
const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo});
/// The already-localized labels this builder stamps onto every
/// user-readable `MediaItem` it produces.
final EtiquetasArbolAuto etiquetas;
/// Root folder ids (Design "media-id scheme"). The tree root itself is
/// identified by [AudioService.browsableRootId], not by a constant here —
/// the handler compares against it directly before calling [raiz].
@@ -211,6 +300,17 @@ class ConstructorArbolAuto {
static const idTodas = 'todas';
static const idMisEmisoras = 'mis_emisoras';
/// Root folder id for the FREE tier's only browsable folder
/// (fix/auto-quality-guidelines, item 8).
///
/// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and
/// [idEcualizador] it has its own dedicated children ([hijosDestacadas]),
/// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the
/// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is
/// empty on the bind a Play reviewer actually performs.
static const idDestacadas = 'destacadas';
/// Root folder id for the local-music browsable root (Design "media-id
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
@@ -229,6 +329,17 @@ class ConstructorArbolAuto {
/// hidden.
static const idEcualizador = 'ecualizador';
/// Non-playable "no puedo leer la carpeta desde aquí" item
/// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal]
/// cuando el canal nativo `pluriwave/file_actions` no está disponible en
/// este motor, así que abrir la carpeta tenía que dejar de mostrar una
/// lista vacía: vacío se lee como «no tengo música», que es justo la
/// conclusión equivocada. Este item dice qué pasa de verdad.
///
/// Colisión imposible con los prefijos `carpeta_local:` / `pista:` /
/// `emisora:` / `grupo:` — no lleva ninguno de ellos.
static const idLocalNoLista = 'musica_local_no_disponible';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -311,8 +422,10 @@ class ConstructorArbolAuto {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local, Ecualizador), all non-playable.
/// The root folders, all non-playable, and TIER-DEPENDENT: Favoritos,
/// Todas las emisoras, Mis emisoras and optionally Música Local for a
/// premium driver; the single [idDestacadas] folder for a free one (see
/// [premium] below).
///
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// There is NO `Ecualizador` folder. The car's only equalizer control is
@@ -331,41 +444,78 @@ class ConstructorArbolAuto {
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
/// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`,
/// keeping this builder itself synchronous and side-effect free.
/// is configured") — the caller lo deriva de
/// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
/// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a
/// propósito, para que el tier gratuito ni siquiera pague ese round trip
/// nativo), keeping this builder itself synchronous and side-effect free.
///
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
/// same visible folder labels for every tier — "keeps the same visible
/// folder labels for free users" is the explicit design choice, so a free
/// driver still sees a real, familiar menu rather than a wall of "Función
/// Premium" rows. The lock itself is enforced one level DOWN, at the
/// `getChildren` choke point (see [itemPremiumBloqueado] and
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these
/// folders as a free user reveals the lock there, never here.
/// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used
/// to be accepted and ignored, on the theory that "the root keeps the same
/// visible folder labels for free users" was friendlier than a reduced
/// menu. It was not: every one of those four folders dead-ended on a single
/// non-playable "Función Premium" row, and Google Play cited exactly that
/// against the Android for Cars App Quality Guidelines.
///
/// The free root is therefore ONE browsable folder, [idDestacadas], and the
/// premium-only folders are OMITTED rather than shown-and-blocked: a folder
/// a driver cannot use is worse than a folder that is not there.
///
/// It must stay at least one BROWSABLE item, never a bare playable one:
/// `audio_service` 0.18.18 discards `rootHints`
/// (`AudioService.java:817-826`), so this code cannot detect whether the
/// head unit accepts a `FLAG_PLAYABLE` root child, and the documented
/// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is
/// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on
/// such a unit.
///
/// Every label here comes from [etiquetas], already resolved to the head
/// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the
/// four premium folders.
///
/// The four premium ones used to be hardcoded Spanish, on the theory that
/// they were leaf rows deep inside a tree only a user who had already
/// chosen the app would reach. That was never a rule, only an untested
/// assumption, and it is retired: anything a user can read gets
/// translated. `escuchar` was localized first (it is 100% of what a free
/// Play reviewer sees), which is exactly why the rest had to follow.
///
/// [tituloDestacadas] stays as an explicit per-call override of
/// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle.
List<MediaItem> raiz({
required bool incluirMusicaLocal,
required bool premium,
}) => [
_carpeta(idFavoritos, 'Favoritos'),
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
];
String? tituloDestacadas,
}) =>
premium
? [
_carpeta(idFavoritos, etiquetas.favoritos),
_carpeta(idTodas, etiquetas.todasLasEmisoras),
_carpeta(idMisEmisoras, etiquetas.misEmisoras),
if (incluirMusicaLocal)
_carpeta(idMusicaLocal, etiquetas.musicaLocal),
]
: [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)];
/// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4):
/// the single non-playable item every non-root folder collapses to for a
/// free-tier user. Hardcoded Spanish label, matching every other car-tree
/// label in this file (never routed through `AppLocalizations` —
/// established convention, see [_tituloMasLocal]'s doc).
static const idPremiumInfo = 'premium:info';
/// The free tier's playable station rows (fix/auto-quality-guidelines,
/// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium
/// folders use, capped like every other folder.
///
/// Separate from [hijos] because that path is gated on [_idsCarpetas] and
/// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold
/// headless bind — which is precisely the bind this folder has to survive.
/// An empty [emisoras] returns `[]` rather than any placeholder row: a
/// non-playable row in the car tree is the thing Play cited.
List<MediaItem> hijosDestacadas(List<Emisora> emisoras) =>
emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
/// The single locked item shown for ANY non-root folder when the browsing
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a
/// crash (Spec "Free-tier user selects a locked item").
MediaItem itemPremiumBloqueado() => MediaItem(
id: idPremiumInfo,
title: 'Función Premium',
/// El item de [idLocalNoLista]. Rotulado con
/// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
/// head unit. No reproducible — seleccionarlo es un no-op.
MediaItem itemLocalNoDisponible() => MediaItem(
id: idLocalNoLista,
title: etiquetas.musicaLocalNoDisponible,
playable: false,
extras: _contentStyleLista,
);
@@ -464,20 +614,15 @@ class ConstructorArbolAuto {
return (documentId, pagina);
}
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
/// (Design ADR-5) — matches every other car-tree label in this file
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
static const _tituloMasLocal = 'Más…';
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
/// Rotulado con [EtiquetasArbolAuto.cargarMas].
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
MediaItem(
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
title: etiquetas.cargarMas,
playable: false,
extras: _contentStyleLista,
);
@@ -602,41 +747,41 @@ class ConstructorArbolAuto {
/// for small folders.
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
/// The "sort by quality" mode-entry `MediaItem` (Design ADR-4):
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
/// Hardcoded Spanish label, matching every other car-tree label in this
/// file — never routed through `AppLocalizations` (established
/// car-tree-label precedent, see [_tituloMasLocal]).
/// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
'Ordenar por calidad',
etiquetas.ordenarPorCalidad,
);
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
/// alphabetical-range label (e.g. `'A-F'`), matching every other
/// car-tree label in this file — never routed through `AppLocalizations`.
/// round-trips via [bucketLocalDesde].
///
/// [etiqueta] is an alphabetical RANGE (e.g. `'A-F'`), and it is the one
/// user-visible car-tree string that deliberately does NOT go through
/// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames
/// actually start with, so translating it would make it lie.
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
/// matching every other car-tree label in this file — never routed
/// through `AppLocalizations`.
/// The "play folder" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
/// [EtiquetasArbolAuto.reproducirCarpeta].
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
title: 'Reproducir carpeta',
title: etiquetas.reproducirCarpeta,
playable: true,
extras: _contentStyleGrid,
);
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
/// The "shuffle play" playable action item (Design ADR-5),
/// mirrors [_itemReproducirCarpeta].
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
title: 'Reproducir aleatorio',
title: etiquetas.reproducirAleatorio,
playable: true,
extras: _contentStyleGrid,
);
@@ -718,7 +863,7 @@ class ConstructorArbolAuto {
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
title: etiquetas.cargarMas,
playable: false,
extras: _contentStyleLista,
);
@@ -732,7 +877,7 @@ class ConstructorArbolAuto {
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
title: etiquetas.cargarMas,
playable: false,
extras: _contentStyleLista,
);
@@ -812,7 +957,7 @@ class ConstructorArbolAuto {
final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
: _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre);
final artUriMeta = meta?.artUri?.trim();
final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty)
@@ -929,24 +1074,56 @@ class ConstructorArbolAuto {
}
}
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any
/// other resolution. For the root itself this NEVER blocks (the root always
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays
/// visible for every tier). For any non-root [parentMediaId] and a free-tier
/// [premium], it returns the single locked item regardless of what the id
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from
/// before a downgrade is blocked exactly the same way as a legitimate
/// current folder id (android-auto-media spec "Free-Tier Browse Never
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the
/// caller should proceed with its normal resolution (root, or premium).
/// Whether [parentMediaId] is content the FREE tier is allowed to browse
/// (fix/auto-quality-guidelines, item 10): the browsable root itself, the
/// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:<uuid>`
/// whose uuid belongs to [destacadas].
///
/// Everything else — the catalogue folders, favourites, custom stations,
/// local music, the equalizer folder, group folders, local tracks, and any
/// station uuid that is not in the free set — is premium content.
///
/// Pure and id-shaped, with the free universe INJECTED, so the whole matrix
/// is testable without prefs or a handler.
bool idPermitidoEnFree(
String parentMediaId, {
required List<Emisora> destacadas,
}) {
if (parentMediaId == AudioService.browsableRootId) return true;
if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true;
if (!parentMediaId.startsWith(_prefijoEmisora)) return false;
final uuid = parentMediaId.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return false;
return destacadas.any((e) => e.uuid == uuid);
}
/// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren`
/// choke point, called BEFORE any other resolution.
///
/// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to
/// content-scoping. It used to answer ANY non-root id, for a free-tier user,
/// with a single non-playable "Función Premium" row — which is what Google
/// Play cited on version code 157 ("clicking on stop button makes the entire
/// app useless" was the headline, but the browse tree it was reviewed
/// against was four folders that each dead-ended on that row). A
/// non-playable row reachable from a head unit's CACHED tree is a citation
/// waiting to happen, so there is no longer any code path that can produce
/// one: the blocked branch returns the free tier's own playable stations.
///
/// Returns `null` when the caller should proceed with its normal resolution
/// (premium, or free-tier content the free tier owns).
///
/// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the
/// caller resolves it once per browse. Passing an empty list is legal and
/// yields an empty blocked response — still never a dead row.
List<MediaItem>? respuestaBloqueadaPorEntitlement({
required String parentMediaId,
required bool premium,
required List<Emisora> destacadas,
}) {
if (parentMediaId == AudioService.browsableRootId) return null;
if (premium) return null;
return [ConstructorArbolAuto().itemPremiumBloqueado()];
if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null;
return ConstructorArbolAuto().hijosDestacadas(destacadas);
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -958,17 +1135,21 @@ List<MediaItem>? respuestaBloqueadaPorEntitlement({
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
/// never called and no exception propagates (Spec "Unknown or stale media
/// id").
Future<void> reproducirPorMediaId(
///
/// RETURNS whether it actually dispatched (fix/auto-quality-guidelines,
/// item 12). The caller needs to tell "played" from "resolved to nothing"
/// so the second case can publish an explained error to the car instead of
/// leaving the driver with a tap that did nothing and said nothing.
Future<bool> reproducirPorMediaId(
String id, {
required FuenteEmisorasAuto fuente,
required Future<void> Function(MediaItem) reproducir,
}) async {
if (!id.startsWith(_prefijoEmisora)) return;
final uuid = id.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return;
final uuid = uuidDeMediaIdEmisora(id);
if (uuid == null) return false;
final emisora = await fuente.porUuid(uuid);
if (emisora == null) return;
if (emisora == null) return false;
final item = MediaItem(
id: emisora.url,
@@ -984,6 +1165,58 @@ Future<void> reproducirPorMediaId(
extras: {'uuid': emisora.uuid},
);
await reproducir(item);
return true;
}
/// The uuid inside an `emisora:<uuid>` media id, or `null` for any other
/// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a
/// folder id) and an empty tail both answer `null`.
///
/// Extracted (fix/auto-quality-guidelines, item 11) because the play-path
/// entitlement gate has to ask the same question `reproducirPorMediaId` asks,
/// one step earlier: "is this a station id, and which station?".
String? uuidDeMediaIdEmisora(String id) {
if (!id.startsWith(_prefijoEmisora)) return null;
final uuid = id.substring(_prefijoEmisora.length);
return uuid.isEmpty ? null : uuid;
}
/// A [FuenteEmisorasAuto] over nothing but the free tier's station set
/// (fix/auto-quality-guidelines, item 12).
///
/// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the
/// window between the headless Android Auto engine starting and `main.dart`
/// registering the real source. A tap arriving in that window used to return
/// in silence; the free set is compiled into the binary, so it can always be
/// answered.
///
/// Reports the free stations through [todas] (they are, from the car's point
/// of view, everything there is) and nothing through the curated lists, which
/// a headless bind could not populate anyway.
class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto {
FuenteEmisorasAutoDestacadas(this._destacadas);
final List<Emisora> _destacadas;
@override
Future<List<Emisora>> favoritos() async => const [];
@override
Future<List<Emisora>> misEmisoras() async => const [];
@override
Future<List<Emisora>> todas() async => _destacadas;
@override
Future<List<GrupoFavoritos>> grupos() async => const [];
@override
Future<Emisora?> porUuid(String uuid) async {
for (final emisora in _destacadas) {
if (emisora.uuid == uuid) return emisora;
}
return null;
}
}
/// Which list previous/next should walk for [actual]: the NARROWEST context
@@ -1014,6 +1247,52 @@ List<Emisora> listaParaSaltoEmisora({
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
final contexto = contextoParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
);
if (contexto == null) return const [];
switch (contexto.tipo) {
case TipoContextoSalto.grupoFavoritos:
return favoritos
.where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId)
.toList();
case TipoContextoSalto.favoritos:
return favoritos;
case TipoContextoSalto.misEmisoras:
return misEmisoras;
case TipoContextoSalto.todas:
return todas;
case TipoContextoSalto.destacadas:
// Never produced by [contextoParaSaltoEmisora] — the free set is
// resolved by the handler, which owns the entitlement read.
return const [];
}
}
/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised
/// — so it can be remembered across a process restart.
///
/// The car kills and restarts the engine on every reconnect, and a list of
/// stations is not something that survives that: its members change while the
/// app is dead. The NAME of the list does survive, which is what
/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against
/// whatever the lists hold next time.
///
/// [listaParaSaltoEmisora] is implemented on top of this so the walked list
/// and the remembered context can never disagree (pinned by a test that runs
/// both over the same scenarios).
///
/// Returns `null` when [actual] belongs to none of the three lists — the
/// caller then has no context to remember and leaves playback alone.
ContextoSalto? contextoParaSaltoEmisora({
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
Emisora? enLista(List<Emisora> lista) {
for (final e in lista) {
@@ -1031,13 +1310,13 @@ List<Emisora> listaParaSaltoEmisora({
if (grupo != GrupoFavoritos.sinAsignarId) {
final delGrupo =
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
if (delGrupo.length > 1) return delGrupo;
if (delGrupo.length > 1) return ContextoSalto.grupo(grupo);
}
return favoritos;
return const ContextoSalto.favoritos();
}
if (enLista(misEmisoras) != null) return misEmisoras;
if (enLista(todas) != null) return todas;
return const [];
if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras();
if (enLista(todas) != null) return const ContextoSalto.todas();
return null;
}
/// The station before or after [actual] in [lista], wrapping around at both
@@ -1164,25 +1443,22 @@ Future<void> seleccionarPresetEqPorMediaId(
if (!activo) await activarEcualizador(true);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
/// `'Música Local'`, etc.), none of which go through `AppLocalizations`.
const _tituloLocalFallback = 'Pista sin nombre';
/// Filename → display title (Design "Title = filename minus extension"):
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
/// dot, or the dot is the first character — e.g. a hidden file like
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
/// blank.
String _tituloDesdeNombre(String nombre) {
/// `.mp3`), falling back to [sinNombre] when the result would be blank.
///
/// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than
/// hardcoded: it is a title the driver reads, so it is translated like every
/// other car-tree label.
String _tituloDesdeNombre(String nombre, String sinNombre) {
final recortado = nombre.trim();
if (recortado.isEmpty) return _tituloLocalFallback;
if (recortado.isEmpty) return sinNombre;
final ultimoPunto = recortado.lastIndexOf('.');
final sinExtension =
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
final resultado = sinExtension.trim();
return resultado.isEmpty ? _tituloLocalFallback : resultado;
return resultado.isEmpty ? sinNombre : resultado;
}
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
@@ -1455,12 +1731,13 @@ Future<void> reproducirCarpetaLocal(
Future<MediaItem?> construirMediaItemColaLocal(
NodoLocal nodo, {
required FuenteMusicaLocalAuto fuente,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
if (contentUri == null || contentUri.isEmpty) return null;
return MediaItem(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId),
title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
album: 'PluriWave',
// Item 3: a queued local track had NO artUri at all before — reuses
// [artUriLocal] (the SAME on-brand rotation the browse tree's
@@ -1524,8 +1801,9 @@ Future<Map<String, MetadatosPista>> _metadatosDeConCache(
Future<List<MediaItem>?> hijosMusicaLocal(
String parentMediaId, {
required FuenteMusicaLocalAuto? fuente,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
final constructor = ConstructorArbolAuto();
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
// routing order is irrelevant to correctness (every prefix in this file
@@ -1584,13 +1862,24 @@ Future<List<MediaItem>?> hijosMusicaLocal(
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocales(
final items = await constructor.itemsLocales(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
);
// fix/android-auto-musica-local: si no salió NADA, el motivo importa.
// Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]`
// igual que una carpeta realmente vacía, y una carpeta vacía en el
// coche se lee como «no tengo música». El estado se consulta SOLO en
// ese caso vacío, así que la ruta normal no paga ningún round trip
// extra.
if (items.isEmpty &&
await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) {
return [constructor.itemLocalNoDisponible()];
}
return items;
} catch (_) {
return const [];
}
@@ -1605,11 +1894,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
/// Now Playing title consistent with what the user tapped without requiring
/// a second native round trip.
String _tituloDesdeDocumentId(String documentId) {
String _tituloDesdeDocumentId(String documentId, String sinNombre) {
final ultimaBarra = documentId.lastIndexOf('/');
final segmento =
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
return _tituloDesdeNombre(segmento);
return _tituloDesdeNombre(segmento, sinNombre);
}
/// Routing seam between a car-tapped `pista:<docId>` media id and the
@@ -1628,6 +1917,7 @@ Future<void> reproducirPistaLocal(
String id, {
required FuenteMusicaLocalAuto fuente,
required Future<void> Function(MediaItem) reproducir,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
if (!esPistaMediaId(id)) return;
final documentId = id.substring(_prefijoPista.length);
@@ -1638,7 +1928,7 @@ Future<void> reproducirPistaLocal(
final pista = PistaLocal(
documentId: documentId,
titulo: _tituloDesdeDocumentId(documentId),
titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre),
contentUri: contentUri,
);
@@ -1733,6 +2023,16 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
return _snapshotTodas ?? const [];
}
/// Resolves a station uuid across every list this source can reach.
///
/// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST
/// (fix/auto-quality-guidelines, item 7). It has to be searched at all
/// because on a cold headless bind the three lists above are all empty —
/// `todas()` is `_snapshotTodas ?? const []`, favourites and custom
/// stations have nothing persisted on a fresh install — so a curated
/// `emisora:<uuid>` resolved to `null` and tapping the row did NOTHING.
/// It is searched last so a live catalogue/favourite record for the same
/// uuid (richer metadata, the user's own group assignment) still wins.
@override
Future<Emisora?> porUuid(String uuid) async {
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
@@ -1741,6 +2041,9 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
if (emisora.uuid == uuid) return emisora;
}
}
for (final emisora in await resolverEmisorasDestacadas()) {
if (emisora.uuid == uuid) return emisora;
}
return null;
}
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -127,12 +127,19 @@ class ServicioComprasPlayBilling implements PuertoCompras {
void _alRecibirCompras(List<PurchaseDetails> compras) {
if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore completes without ever
// pushing a PurchaseDetails (Spec "Restore finds nothing") — there is
// no per-call correlation in this stream, so this fires on ANY empty
// batch. In practice `queryPastPurchases`/`restorePurchases` on an
// account with nothing to restore is the only source of an empty
// batch this stream would ever emit.
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
// (`in_app_purchase_android` does `_purchaseUpdatedController.add(
// pastPurchases)` unconditionally) — there is no per-call correlation
// in this stream, so this fires on ANY empty batch. In practice
// `restorePurchases` on an account with nothing to restore is the only
// source of an empty batch this stream would ever emit.
//
// Returning silently here (as this did before) left
// [TipoEventoCompra.noEncontrada] NEVER emitted, so
// `EstadoEntitlement._compraEnCurso` stayed `true` forever and
// `hoja_premium.dart` kept BOTH buttons disabled — restore AND buy.
// A paywall that cannot be paid.
_eventos.add(const EventoCompra(TipoEventoCompra.noEncontrada));
return;
}
for (final compra in compras) {
+39
View File
@@ -238,6 +238,45 @@ class ServicioEcualizador {
await prefs.setBool(_keyActivo, activo);
}
/// The persisted equalizer on/off flag, or `null` when the user has never
/// touched the toggle.
///
/// Deliberately narrower than [cargar] (eq-estado-unico item A): it reads
/// ONE key and runs none of the migrations, because its caller is
/// `registrarHandler`, on the audio bootstrap path of EVERY engine —
/// including the headless one Android Auto starts, where there is no
/// widget tree and `EstadoEcualizador` never exists. It must stay cheap
/// and it must never mutate anything.
///
/// `null` is preserved rather than collapsed to a default so that
/// `estadoEqInicial` — not this service — owns the "never persisted"
/// policy in exactly one place.
Future<bool?> leerActivo() async {
final prefs = await _resolverPrefs();
return prefs.getBool(_keyActivo);
}
/// The persisted principal preset, or `null` when the user has never saved
/// one.
///
/// The exact sibling of [leerActivo] and narrow for the same reason: its
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
/// engine — including the headless one Android Auto starts, where there is
/// no widget tree and `EstadoEcualizador` never exists to push a preset
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
/// and mutates nothing.
///
/// `null` (nothing saved, or an unreadable value) is preserved rather than
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
/// not this service — decides what "never persisted" means, and so a seed
/// with nothing to say does not overwrite anything.
Future<PresetEcualizador?> leerPresetPrincipal() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) return null;
return _leerPresetPrincipal(prefs);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
+35 -10
View File
@@ -7,26 +7,33 @@ import '../modelos/preset_ecualizador.dart';
/// Owns the backup (export/import) JSON serialization (S4-R4).
///
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
/// export stays at v2 for backward compat with the old app. State APPLICATION
/// (writing favorites, EQ, alarms back into the app) stays in
/// `eqMultiDeviceEnabled`. v4 extends v3 with `ecualizadorActivo` (the
/// equalizer's global ON/OFF toggle). When the version-N extension
/// parameters are all omitted the export stays at the lower version for
/// backward compat with older app builds. State APPLICATION (writing
/// favorites, EQ, alarms back into the app) stays in
/// `EstadoRadio.importarConfig` — this service only owns serialization,
/// parsing and the envelope shape.
class ServicioExportImport {
const ServicioExportImport();
/// Current backup schema version (v3multi-device EQ).
static const int versionActual = 3;
/// Current backup schema version (v4equalizer on/off toggle).
static const int versionActual = 4;
/// v3 version constant (multi-device EQ) kept for clarity.
static const int versionV3 = 3;
/// Legacy v2 version constant kept for clarity.
static const int versionV2 = 2;
/// Builds the export envelope.
///
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
/// [versionActual] (3) is written. When both are omitted the call behaves
/// identically to the original v2 format (version key stays 2) so old
/// backups keep round-tripping without version bumps.
/// When [presetsPorDispositivo] or [presetsMatriz] or
/// [eqMultiDeviceEnabled] are provided (non-null), at least [versionV3] (3)
/// is written. When [ecualizadorActivo] is ALSO provided (non-null),
/// [versionActual] (4) is written. Omitting all of them behaves identically
/// to the original v2 format (version key stays 2) so old backups keep
/// round-tripping without version bumps.
///
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// and passes through untouched (no re-parsing here).
@@ -45,14 +52,27 @@ class ServicioExportImport {
Map<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
// v4 extension — the equalizer's global ON/OFF toggle. Omitting it
// produces a v3 (or v2)-compatible export.
bool? ecualizadorActivo,
}) {
final tieneExtensionesV3 =
presetsPorDispositivo != null ||
presetsMatriz != null ||
eqMultiDeviceEnabled != null;
final tieneExtensionV4 = ecualizadorActivo != null;
final int version;
if (tieneExtensionV4) {
version = versionActual;
} else if (tieneExtensionesV3) {
version = versionV3;
} else {
version = versionV2;
}
final envelope = <String, dynamic>{
'version': tieneExtensionesV3 ? versionActual : versionV2,
'version': version,
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
// Favorites + groups (preserves grupo_id assignments per station).
// The protected "sin asignar" group is implicit and never exported.
@@ -88,6 +108,11 @@ class ServicioExportImport {
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
}
// v4 extension: only written when explicitly provided.
if (tieneExtensionV4) {
envelope['ecualizadorActivo'] = ecualizadorActivo;
}
return envelope;
}
+37
View File
@@ -213,6 +213,43 @@ class ServicioFavoritos {
);
}
/// Restaura un favorito tal como estaba en el dispositivo de origen,
/// preservando su `orden` y su `grupo_id`.
/// Usado exclusivamente por importarConfig, igual que [restaurarGrupo].
///
/// Existe porque [agregar] NO sirve como primitiva de restauración: es la
/// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un
/// `orden` al final de la lista, cosa correcta para una emisora recién
/// marcada (que de verdad no pertenece a ningún grupo) y destructiva para
/// una copia de seguridad, que trae ambos campos. Reusarla era la causa de
/// que los grupos volvieran vacíos tras restaurar.
///
/// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no
/// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo
/// que una copia editada a mano o restaurada a medias no puede dejar
/// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los
/// grupos ANTES de este bucle, así que en el camino normal siempre existen.
Future<void> restaurarFavorito(Emisora emisora) async {
final db = await _database;
final existe =
Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
[emisora.grupoFavoritosId],
),
) ??
0;
final restaurada =
existe > 0
? emisora
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId);
await db.insert(
'favoritos',
restaurada.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> eliminarGrupo(String id) async {
if (id == GrupoFavoritos.sinAsignarId) return;
final db = await _database;
+69 -1
View File
@@ -53,14 +53,42 @@ class HojaPremium extends StatelessWidget {
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.funcionPremium,
l10n.premiumHojaTitulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
// Explicit, obvious dismiss affordance (fix/import-alarmas-y-
// paywall): a purchase sheet the user cannot easily escape is
// a dark pattern and a Play policy risk. Reachable without
// buying or restoring, same weight as any other icon button.
IconButton(
key: const ValueKey('hoja-premium-cerrar'),
icon: const Icon(Icons.close_rounded),
tooltip: l10n.closeAction,
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
const SizedBox(height: 12),
// Concrete, honest value list — accuracy is non-negotiable here:
// these five are the ONLY things premium unlocks. The phone
// equalizer stays free for everyone and must NEVER appear here;
// only its Android Auto surface is affected, as a consequence of
// Auto itself being gated.
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
const SizedBox(height: 12),
Text(
l10n.premiumPagoUnico,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
@@ -143,9 +171,49 @@ class HojaPremium extends StatelessWidget {
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
),
],
],
),
),
);
}
}
/// One concrete, honest value-list row (fix/import-alarmas-y-paywall).
class _BeneficioPremium extends StatelessWidget {
const _BeneficioPremium({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle_rounded,
size: 18,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 8),
Expanded(
child: Text(texto, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
}
@@ -0,0 +1,44 @@
group = "es.freetimelab.pluriwave.fileactions"
version = "1.0-SNAPSHOT"
// Sin bloque `buildscript` a proposito: este modulo solo se construye desde
// `android/settings.gradle.kts` de la app, cuyo `pluginManagement` ya pone
// AGP 8.11.1 y Kotlin 2.2.20 en el classpath compartido. Declarar aqui otro
// classpath de AGP arriesga un choque de versiones con el de la app.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "es.freetimelab.pluriwave.fileactions"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
}
defaultConfig {
// Igual que `flutter.minSdkVersion` en Flutter 3.44 (FlutterExtension.kt).
minSdk = 24
}
}
dependencies {
// `androidx.core.content.FileProvider`, para servir la caratula embebida
// cacheada desde la autoridad `${applicationId}.fileprovider` que declara
// el manifiesto del modulo de app.
implementation("androidx.core:core-ktx:1.16.0")
}
@@ -0,0 +1 @@
rootProject.name = 'pluriwave_file_actions'
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,363 @@
package es.freetimelab.pluriwave.fileactions
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.content.FileProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/**
* Activity-FREE half of the `pluriwave/file_actions` channel
* (fix/android-auto-musica-local, item 3).
*
* These four methods -- `hasPersistedPermission`, `listAudioChildren`,
* `resolvePlayableUri`, `readAudioMetadataBatch` -- only ever needed a
* [ContentResolver][android.content.ContentResolver], which is an
* app-scoped API: they never touch an Activity, a window, or
* `startActivityForResult`. They were nevertheless trapped inside
* `MainActivity.configureFlutterEngine`, the ONE place in the whole repo
* that installed a handler on this channel.
*
* That is the reported bug: when Android Auto binds the MediaBrowserService
* before the phone app has been opened, `audio_service` builds a bare
* `FlutterEngine` with no Activity, `configureFlutterEngine` never runs, the
* channel has no handler at all, and every `invokeMethod` on it throws
* `MissingPluginException`. Dart could not tell that apart from "permission
* revoked" and silently dropped "Musica Local" from the car's browse tree.
*
* Living in a real plugin package is what makes them registerable on ANY
* engine: [PluriWaveFileActionsPlugin] is listed in
* `GeneratedPluginRegistrant`, which the `FlutterEngine(Context)` constructor
* runs by itself, headless engine included. An app-module class never could.
*
* `pickMusicFolder` and the recordings-folder intents are deliberately NOT
* here: they need `startActivityForResult` / `startActivity` plus an
* `onActivityResult` callback, so they stay on `MainActivity` in the app
* module, which delegates everything else to this same class -- so both
* engines answer the four SAF methods identically, from ONE implementation.
*/
class FileActionsHandler(private val context: Context) {
private val tag = "PluriWave"
/**
* Answers [call] if it is one of the Activity-free methods, replying
* through [result] and returning `true`. Returns `false` -- WITHOUT
* touching [result] -- for anything else, so `MainActivity` can fall
* through to its own Activity-bound methods on the same channel.
*/
fun manejar(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
"readAudioMetadataBatch" -> {
val treeUri = call.argument<String>("treeUri")
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
Log.d(
tag,
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any?>>())
} else {
result.success(readAudioMetadataBatch(treeUri, documentIds))
}
}
else -> return false
}
return true
}
/**
* Traza el rechazo de un metodo que exige Activity, para que el log
* distinga "no hay Activity aqui" de "el canal no existe". Lo usa
* [PluriWaveFileActionsPlugin] antes de responder `notImplemented()`.
*/
fun trazarNoDisponibleSinActividad(metodo: String) {
Log.d(tag, "file_actions.$metodo needs an Activity; not available here")
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only -- Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* audio MIME types at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) -- used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*
* Persisted URI grants are taken by the app, not by the Activity, so
* this answers identically on an engine with no Activity -- which is
* exactly why it belongs in this class.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
context.contentResolver.persistedUriPermissions.any {
it.uri == parsed && it.isReadPermission
}
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
/**
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
* static review only -- Design "Interfaces / Contracts"): for each of
* [documentIds], extracts title/artist/bitrate/sample-rate and the
* embedded picture via [extraerMetadatosPista]. Never throws across the
* channel boundary -- a malformed [treeUri] (or any other unexpected
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
* already isolated inside [extraerMetadatosPista].
*/
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
return try {
val parsedTree = Uri.parse(treeUri)
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
} catch (error: Throwable) {
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
emptyList()
}
}
/**
* Extracts one [documentId]'s embedded metadata via
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
* review only -- mirrors [listAudioChildren]/[resolvePlayableUri]'s
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
* public constant below API 31) is gated behind
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
* available since API 10 and read unconditionally. A resolvable
* embedded picture is handed to [cachearArteEmbebido]; art-cache
* failures degrade that single field to `null` without failing the
* whole entry. On ANY failure for this [documentId] (unsupported
* format, permission edge case, corrupt file), the row degrades to an
* all-null-but-`documentId` entry instead of throwing --
* `retriever.release()` always runs via `finally`.
*/
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
val retriever = MediaMetadataRetriever()
return try {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
retriever.setDataSource(context, documentUri)
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
val bitrate = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull()
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
// public constant exists on this minSdk, so the raw key is
// used directly, guarded by the version check above.
retriever.extractMetadata(38)?.toIntOrNull()
} else {
null
}
val artUri = try {
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
error
)
null
}
mapOf(
"documentId" to documentId,
"titulo" to titulo,
"artista" to artista,
"bitrate" to bitrate,
"sampleRate" to sampleRate,
"artUri" to artUri
)
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
error
)
mapOf(
"documentId" to documentId,
"titulo" to null,
"artista" to null,
"bitrate" to null,
"sampleRate" to null,
"artUri" to null
)
} finally {
try {
retriever.release()
} catch (_: Throwable) {
// release() failing is not actionable -- the retriever is
// being discarded regardless.
}
}
}
/**
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
* static review only -- Design ADR-1). Writes [picture] bytes to
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
* file already exists, so re-parsing the same track reuses it), returns
* the `content://` URI served via the EXISTING
* `${applicationId}.fileprovider` authority
* (`AndroidManifest.xml`, `pluriwave_file_paths.xml`'s
* `cache-path path="."`) and trims `pluriwave_art/` via [trimArtCache].
* `hash` uses SHA-256 hex because a raw `documentId` may contain
* `:`/`/`, which are illegal in filenames on most filesystems.
*/
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
return try {
val artDir = File(context.cacheDir, "pluriwave_art").apply { mkdirs() }
val artFile = File(artDir, hashDocumentId(documentId))
if (!artFile.exists()) {
artFile.writeBytes(picture)
}
trimArtCache(artDir)
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
artFile
).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
null
}
}
private fun hashDocumentId(documentId: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
* total, deleting the OLDEST-by-mtime entries first. Kept as a
* trivially reviewable loop -- these files are native-owned, so
* round-tripping names to Dart to pick deletions would add channel
* chatter with no testability gain (the `delete()` is native
* regardless, per ADR-1's rationale).
*/
private fun trimArtCache(artDir: File) {
val maxArchivos = 256
val maxBytes = 32L * 1024 * 1024
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
?: return
var totalBytes = archivos.sumOf { it.length() }
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
val masViejo = archivos.removeAt(archivos.size - 1)
totalBytes -= masViejo.length()
masViejo.delete()
}
}
companion object {
const val CHANNEL = "pluriwave/file_actions"
}
}
@@ -0,0 +1,76 @@
package es.freetimelab.pluriwave.fileactions
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
/**
* Registra `pluriwave/file_actions` en TODOS los FlutterEngine de la app.
*
* ## Por que un paquete plugin y no una clase del modulo de app
*
* `AudioServicePlugin.getFlutterEngine` (audio_service 0.18.18,
* `AudioServicePlugin.java:70-75`) construye el engine compartido con
* `new FlutterEngine(context.getApplicationContext())`. Ese constructor
* encadena hasta el maestro con `automaticallyRegisterPlugins = true`
* (verificado en el bytecode de `FlutterEngine`: `FlutterEngine(Context)` ->
* `FlutterEngine(Context, String[])` con `iconst_1`) y ejecuta
* `GeneratedPluginRegister.registerGeneratedPlugins(this)`, que reflexiona
* sobre `io.flutter.plugins.GeneratedPluginRegistrant`.
*
* Es decir: el engine headless registra PLUGINS por si mismo. Por eso
* `shared_preferences` y `just_audio` ya funcionan cuando Android Auto arranca
* la app con el movil bloqueado, y por eso un handler instalado unicamente en
* `MainActivity.configureFlutterEngine` no podia funcionar nunca ahi: sin
* Activity, `configureFlutterEngine` jamas se ejecuta, el canal se queda sin
* handler y cada `invokeMethod` lanza `MissingPluginException`. El nodo
* "Musica Local" desaparecia del arbol del coche.
*
* ## Reparto con MainActivity (decision deliberada)
*
* Este plugin atiende SOLO los cuatro metodos que no necesitan Activity
* ([FileActionsHandler.manejar]) y responde `notImplemented()` al resto, que es
* la respuesta correcta en un engine sin Activity: `pickMusicFolder`,
* `openDirectory`, `viewDirectory` y `openFile` no pueden funcionar sin una.
*
* En el engine CON Activity ambos escriben en el mismo canal, y gana el
* ultimo: el orden es estructural, no casual. `GeneratedPluginRegistrant` corre
* dentro del constructor de `FlutterEngine`, o sea antes de que el engine
* exista como argumento; `FlutterActivityAndFragmentDelegate.onAttach` llama a
* `host.configureFlutterEngine(flutterEngine)` despues, necesariamente con un
* engine ya construido. Asi que en una Activity siempre gana el handler
* combinado de `MainActivity`, que es el superconjunto: delega los cuatro
* metodos SAF en esta MISMA clase [FileActionsHandler] y añade los suyos.
*
* Se descarto hacer el plugin `ActivityAware` y moverle tambien los metodos con
* Activity: obligaria a trasladar ~400 lineas de malabares de `Intent`
* (FileProvider, DocumentsUI, fallbacks de `ACTION_VIEW`) mas el round trip de
* `onActivityResult`, todo ello sin cobertura de `flutter test`, para arreglar
* un bug que no los toca. El reparto de arriba deja UNA sola implementacion de
* la logica compartida, que era el objetivo real.
*/
class PluriWaveFileActionsPlugin : FlutterPlugin {
private var canal: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// applicationContext a proposito: los cuatro metodos solo usan el
// ContentResolver y la cacheDir del proceso, asi que sobreviven a
// cualquier Activity y valen igual en el engine headless.
val handler = FileActionsHandler(binding.applicationContext)
canal = MethodChannel(binding.binaryMessenger, FileActionsHandler.CHANNEL).apply {
setMethodCallHandler { call, result ->
if (!handler.manejar(call, result)) {
handler.trazarNoDisponibleSinActividad(call.method)
result.notImplemented()
}
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// Solo ocurre al destruir el engine, de modo que nunca puede pisar el
// handler combinado que instala MainActivity sobre este mismo canal.
canal?.setMethodCallHandler(null)
canal = null
}
}
@@ -0,0 +1,20 @@
/// Este paquete NO expone API Dart.
///
/// Existe por una sola razon estructural: un `MethodChannel` registrado desde
/// el modulo de aplicacion (`MainActivity.configureFlutterEngine`) solo vive en
/// el engine que tiene Activity. `audio_service` construye ademas un
/// FlutterEngine *headless* (`AudioServicePlugin.getFlutterEngine`, que llama a
/// `new FlutterEngine(context.getApplicationContext())`) cuando Android Auto
/// enlaza el `MediaBrowserService` con la app cerrada. Ese constructor invoca
/// `GeneratedPluginRegister.registerGeneratedPlugins`, que reflexiona sobre
/// `io.flutter.plugins.GeneratedPluginRegistrant`; es decir, registra los
/// PLUGINS, nunca una clase suelta del modulo de app.
///
/// Empaquetando el lado nativo aqui, `GeneratedPluginRegistrant` lo instala en
/// los dos engines sin tocar el manifiesto ni forkear `audio_service`.
///
/// Los llamantes Dart siguen usando `MethodChannel('pluriwave/file_actions')`
/// directamente (`lib/servicios/musica_local_auto.dart`,
/// `lib/estado/estado_grabacion.dart`), asi que este fichero se queda vacio a
/// proposito: cualquier fachada aqui seria una segunda forma de decir lo mismo.
library;
@@ -0,0 +1,23 @@
name: pluriwave_file_actions
description: >-
Canal nativo `pluriwave/file_actions` de PluriWave empaquetado como plugin
Flutter, para que quede registrado en TODOS los FlutterEngine de la app --
incluido el engine headless que audio_service crea cuando Android Auto
arranca el MediaBrowserService sin Activity.
version: 0.0.1
publish_to: 'none'
environment:
sdk: ^3.7.0
flutter: '>=3.3.0'
dependencies:
flutter:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: es.freetimelab.pluriwave.fileactions
pluginClass: PluriWaveFileActionsPlugin
+7
View File
@@ -629,6 +629,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pluriwave_file_actions:
dependency: "direct main"
description:
path: "packages/pluriwave_file_actions"
relative: true
source: path
version: "0.0.1"
provider:
dependency: "direct main"
description:
+10 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.3.1+154
version: 1.3.4+162
environment:
sdk: ^3.7.0
@@ -56,6 +56,15 @@ dependencies:
# In-app purchase
in_app_purchase: ^3.2.0
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
# headless que audio_service crea al arrancar desde Android Auto. Sin esto
# el canal solo existia en el engine de MainActivity y "Musica Local"
# desaparecia del arbol del coche. No expone API Dart: los llamantes siguen
# usando MethodChannel('pluriwave/file_actions').
pluriwave_file_actions:
path: packages/pluriwave_file_actions
# Song recognition (activar con AudD key)
# permission_handler: ^11.3.1
@@ -0,0 +1,207 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/main.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'helpers/handlers_audio.dart';
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
///
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
/// `didChangeDependencies`, con un latch de un solo uso y este comentario:
/// «Que exista una View significa que hay Activity». La premisa es FALSA.
///
/// `runApp` envuelve SIEMPRE el árbol en una `View` construida a partir de
/// `platformDispatcher.implicitView`, y lanza `StateError` si no la hay
/// (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). Así que
/// en el motor headless que `audio_service` levanta sin Activity —el mismo
/// que demostrablemente llega a `runApp`, ver la doc de
/// `aplicarPoliticaOrientacion`— `View.maybeOf(context)` ya es no-nulo en el
/// PRIMER `didChangeDependencies`.
///
/// Consecuencia: el latch se gastaba durante el arranque headless, justo en
/// el instante en que no podía conseguir nada (`_childrenSubjects` sigue
/// vacío, y `notificarHijosCambiaron` es `_childrenSubjects[id]?.add(...)`,
/// un no-op silencioso). Y no podía volver a dispararse nunca, porque
/// `didChangeDependencies` no se re-ejecuta cuando más tarde se adjunta una
/// Activity al MISMO motor cacheado. La vía de recuperación estaba muerta en
/// los dos motores.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
test('resumed + coche ya suscrito + latch libre invalida', () {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: AppLifecycleState.resumed,
hayCocheSuscrito: true,
yaInvalidado: false,
),
isTrue,
);
});
test('sin suscripción del coche NO invalida — y por tanto no gasta el '
'latch en el arranque headless', () {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: AppLifecycleState.resumed,
hayCocheSuscrito: false,
yaInvalidado: false,
),
isFalse,
reason:
'notificarHijosCambiaron solo empuja a un sujeto que ya existe, '
'así que invalidar antes de que el coche se suscriba a NADA es '
'demostrablemente un no-op',
);
});
test('ningún estado del ciclo de vida distinto de resumed invalida', () {
for (final estado in [
AppLifecycleState.detached,
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
]) {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: estado,
hayCocheSuscrito: true,
yaInvalidado: false,
),
isFalse,
reason:
'$estado no significa «hay una Activity adjunta en primer '
'plano»; solo resumed lo significa',
);
}
});
test('con el latch ya gastado no vuelve a invalidar (nada de tormenta '
'de notificaciones)', () {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: AppLifecycleState.resumed,
hayCocheSuscrito: true,
yaInvalidado: true,
),
isFalse,
);
});
});
group('OrientacionResponsiveApp — cableado real del disparador', () {
testWidgets('bajo pumpWidget/runApp SIEMPRE existe una View, que es '
'exactamente por qué el disparador anterior no valía', (tester) async {
await tester.pumpWidget(
const OrientacionResponsiveApp(child: SizedBox.shrink()),
);
expect(
View.maybeOf(tester.element(find.byType(SizedBox))),
isNotNull,
reason:
'wrapWithDefaultView envuelve el árbol en una View o lanza '
'StateError: no hay ningún motor bajo runApp sin View',
);
});
testWidgets('arranque headless: hay View desde el primer frame, pero sin '
'Activity ni coche suscrito el latch NO se gasta y sigue disponible '
'para cuando el coche por fin navegue', (tester) async {
final handler = crearHandler();
registrarHandler(handler);
var invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
await tester.pumpWidget(
const OrientacionResponsiveApp(child: SizedBox.shrink()),
);
await tester.pump();
expect(
invalidaciones,
0,
reason: 'el primer frame no prueba que haya Activity',
);
// Incluso si un evento de ciclo de vida llegara en frío: el coche no
// ha navegado nada todavía, así que no hay ningún sujeto al que
// empujar y el latch debe sobrevivir.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(invalidaciones, 0);
// Ahora el coche navega la raíz (esto es lo que crea el sujeto), y la
// siguiente vuelta a primer plano sí encuentra algo que invalidar.
handler.subscribeToChildren(AudioService.browsableRootId);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(invalidaciones, 1);
});
testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) '
'empuja de verdad por el stream de hijos de la raíz', (tester) async {
final handler = crearHandler();
registrarHandler(handler);
// El coche navegó la raíz durante el arranque headless: el sujeto
// existe y el head unit tiene el listado cacheado.
final eventos = <Map<String, dynamic>>[];
final sub = handler
.subscribeToChildren(AudioService.browsableRootId)
.listen(eventos.add);
addTearDown(sub.cancel);
await tester.pumpWidget(
const OrientacionResponsiveApp(child: SizedBox.shrink()),
);
await tester.pump();
expect(
eventos,
isEmpty,
reason: 'todavía no hay Activity, solo una View',
);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(
eventos,
hasLength(1),
reason:
'esta es la ÚNICA vía de recuperación cuando el registro del '
'canal pluriwave/file_actions falló en el motor headless',
);
// Y no una por cada rebote de ciclo de vida.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(eventos, hasLength(1));
});
});
group('hayCocheSuscritoAlArbol', () {
test('es false sin handler suscrito y true en cuanto el coche navega un '
'id', () async {
final handler = crearHandler();
registrarHandler(handler);
expect(hayCocheSuscritoAlArbol(), isFalse);
handler.subscribeToChildren(AudioService.browsableRootId);
expect(hayCocheSuscritoAlArbol(), isTrue);
});
});
}
+284
View File
@@ -0,0 +1,284 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Regression coverage for the data-loss bug (fix/import-alarmas-y-paywall):
/// `EstadoRadio.importarConfig` writes the imported alarm block straight to
/// SharedPreferences, but `EstadoAlarmas` is a separate long-lived
/// `ChangeNotifier` that loaded its alarms into memory at construction and
/// never re-reads on its own. These tests exercise the EXACT sequence the
/// real call site (`pantalla_ajustes_backup.dart`'s `_importar`) now runs
/// after a successful import: `EstadoRadio.importarConfig` followed by
/// `EstadoAlarmas.cargarPersistidasSinRecalcular()` +
/// `EstadoAlarmas.refrescarProgramacion()` — bypassing the file_picker
/// platform channel and the confirmation dialog, which are pure UI
/// plumbing already covered by `pantalla_ajustes_backup_test.dart`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
// A PRIVATE per-test file, never the shared `test/fixtures/` one:
// `EstadoRadio.importarConfig` unconditionally calls
// `_guardarEmisorasCustom()`, which WRITES to whatever
// `resolverArchivoCustom` resolves to — pointing that at the shared
// fixture previously clobbered its committed BOM on disk as a side
// effect of running this file's tests.
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_estado_alarmas_import_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
Future<File> archivoCustomVacio() async {
final file = File('${tempDir.path}/emisoras_custom.json');
if (!file.existsSync()) {
await file.writeAsString('[]');
}
return file;
}
Map<String, dynamic> jsonAlarma(AlarmaMusical a) => {
'id': a.id,
'nombre': a.nombre,
'activa': a.activa,
'hora': a.hora,
'minuto': a.minuto,
'tipoProgramacion': a.tipoProgramacion.name,
'diasSemana': a.diasSemana,
};
const alarmaVieja = AlarmaMusical(
id: 'vieja',
nombre: 'Alarma vieja (pre-import)',
hora: 6,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [1, 2, 3, 4, 5],
);
const alarmaImportada = AlarmaMusical(
id: 'importada',
nombre: 'Alarma importada',
hora: 8,
minuto: 15,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [6, 7],
);
/// Builds the pair the app wires together: `EstadoRadio` (owns
/// `importarConfig`) and `EstadoAlarmas` (owns the alarm reload +
/// re-scheduling this bugfix adds), sharing ONE `SharedPreferences`
/// instance exactly like the real app's provider tree does.
Future<
({
EstadoRadio radio,
EstadoAlarmas alarmas,
FakePuertoAlarmasAndroid android,
})
>
crearPar() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'alarmas_musicales_v1',
jsonEncode({
'alarmas': [jsonAlarma(alarmaVieja)],
'vacaciones': [],
'excepciones': [],
}),
);
final android = FakePuertoAlarmasAndroid();
final alarmas = EstadoAlarmas(
esPremium: () => true,
servicio: ServicioAlarmas(prefs: prefs),
android: android,
prefs: prefs,
iniciarAutomaticamente: false,
);
// Loads + native-syncs the pre-import alarm WITHOUT arming
// `inicializar()`'s periodic timers (irrelevant to this bugfix and a
// needless liability for a `flutter test` run).
await alarmas.refrescarProgramacion();
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
prefs: prefs,
iniciarAutomaticamente: false,
);
return (radio: radio, alarmas: alarmas, android: android);
}
Map<String, dynamic> backupCon({
required List<AlarmaMusical> alarmas,
List<Map<String, dynamic>> vacaciones = const [],
List<Map<String, dynamic>> excepciones = const [],
String ordenListas = 'nombre',
}) => {
'version': 2,
'gruposFavoritos': [],
'favoritos': [],
'emisorasCustom': [],
'presetsEcualizador': {},
'alarmas': {
'alarmas': alarmas.map(jsonAlarma).toList(),
'vacaciones': vacaciones,
'excepciones': excepciones,
},
'emisoraPreferidaUuid': null,
'ordenListas': ordenListas,
'timerSuenoPresetsSegundos': <int>[300, 600],
};
test('after import, EstadoAlarmas reflects the imported alarms, not the '
'pre-import ones', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada]),
);
expect(par.alarmas.alarmas.map((a) => a.id), ['importada']);
expect(par.alarmas.alarmas.single.nombre, 'Alarma importada');
});
test('native re-scheduling is triggered after an import', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
// Sanity: the pre-import alarm was already scheduled.
expect(par.android.programadas.map((a) => a.id), contains('vieja'));
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada]),
);
// The imported alarm was handed to the native Android bridge — this is
// what makes it actually ring, not just appear in the list.
expect(par.android.programadas.map((a) => a.id), contains('importada'));
});
test(
'vacation ranges and alarm exceptions in the same block come back too',
() async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
expect(par.alarmas.vacaciones, isEmpty);
expect(par.alarmas.excepciones, isEmpty);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(
alarmas: [alarmaImportada],
vacaciones: [
{
'id': 'vac1',
'nombre': 'Verano',
'inicio': '2026-07-01T00:00:00.000',
'fin': '2026-07-15T00:00:00.000',
'activo': true,
},
],
excepciones: [
{
'alarmaId': 'importada',
'ejecucion': '2026-08-30T08:15:00.000',
'tipo': 'skipNext',
},
],
),
);
expect(par.alarmas.vacaciones.map((v) => v.id), ['vac1']);
expect(par.alarmas.excepciones.map((e) => e.alarmaId), ['importada']);
},
);
test('a failed import (e.g. malformed/unsupported version) leaves existing '
'alarms untouched', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
final backupNoSoportado = backupCon(alarmas: [alarmaImportada])
..['version'] = 99;
// Runs the SAME production function the call site uses: a throw from
// `importarConfig` must propagate before either reload call runs.
await expectLater(
aplicarImportacionConfig(par.radio, par.alarmas, backupNoSoportado),
throwsA(anything),
);
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
expect(par.android.programadas.map((a) => a.id), ['vieja']);
});
test('a cancelled import (dialog declined, importarConfig never called) '
'leaves existing alarms untouched', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
// Simulates the user declining the confirm dialog: the call site
// returns before `importarConfig` and the two reload calls ever run.
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
expect(par.android.programadas.map((a) => a.id), ['vieja']);
});
test('regression: importing still restores preferences (ordenListas) '
'exactly as before', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada], ordenListas: 'nombre'),
);
expect(par.radio.ordenListas.name, 'nombre');
});
}
+147 -4
View File
@@ -1569,6 +1569,83 @@ void main() {
});
});
group(
'EstadoEcualizador — importarConfiguracion(activo:) '
'(equalizer on/off export/import gap)',
() {
test(
'activo: false turns the equalizer off — persisted AND pushed to '
'the live audio engine (reuses cambiarActivo, not a bare field set)',
() async {
final servicio = FakeServicioEcualizador(activo: true);
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
await eq.importarConfiguracion(
principal: PresetEcualizador.flat,
porEmisora: {},
activo: false,
);
expect(eq.activo, isFalse);
expect(audio.cambiosEcualizadorActivo, contains(false));
expect(servicio.config.activo, isFalse);
expect(servicio.guardarActivoLlamadas, 1);
eq.dispose();
},
);
test(
'activo: true turns the equalizer on — persisted AND pushed to '
'the live audio engine',
() async {
final servicio = FakeServicioEcualizador(activo: false);
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
await eq.importarConfiguracion(
principal: PresetEcualizador.flat,
porEmisora: {},
activo: true,
);
expect(eq.activo, isTrue);
expect(audio.cambiosEcualizadorActivo, contains(true));
expect(servicio.config.activo, isTrue);
expect(servicio.guardarActivoLlamadas, 1);
eq.dispose();
},
);
test(
'activo: null (old backup, no field) leaves the current toggle '
'untouched and does not persist anything for it',
() async {
final servicio = FakeServicioEcualizador(activo: false);
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
await eq.importarConfiguracion(
principal: PresetEcualizador.flat,
porEmisora: {},
// activo omitted — simulates a pre-v4 backup.
);
expect(eq.activo, isFalse);
expect(audio.cambiosEcualizadorActivo, isEmpty);
expect(servicio.guardarActivoLlamadas, 0);
eq.dispose();
},
);
},
);
group('EstadoEcualizador — bonded Bluetooth names', () {
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
@@ -1791,8 +1868,8 @@ void main() {
);
test(
'a handler-initiated toggle is persisted through ServicioEcualizador '
'(survives a restart)',
'a handler-initiated toggle is ADOPTED for display and NOT written '
'again from here (eq-estado-unico: the handler owns the write)',
() async {
final fakeAudio = FakeServicioAudio();
final fakeServicio = FakeServicioEcualizador(activo: true);
@@ -1802,8 +1879,21 @@ void main() {
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeServicio.config.activo, isFalse);
expect(fakeServicio.guardarActivoLlamadas, equals(1));
expect(
eq.activo,
isFalse,
reason: 'the phone toggle must show what the engine is really doing',
);
expect(
fakeServicio.guardarActivoLlamadas,
equals(0),
reason:
'persistence moved to PluriWaveAudioHandler itself, because '
'this resync only exists while an EstadoEcualizador does — and '
'on the headless Android Auto engine that produced the bug, '
'none ever does. A second write from here would be a second '
'owner of the same fact.',
);
eq.dispose();
},
);
@@ -1854,6 +1944,59 @@ void main() {
},
);
});
// ---------------------------------------------------------------------------
// The handler REJECTED the toggle (native setEnabled threw): the handler
// rolls its own flag back, so this class must not keep — nor persist — a
// value the engine refused.
// ---------------------------------------------------------------------------
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
test(
'adopta el valor real del handler y NO persiste el valor rechazado',
() async {
final fakeAudio = _FakeAudioEqRechazaConmutacion();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
fakeServicio.guardarActivoLlamadas = 0;
var avisos = 0;
eq.addListener(() => avisos++);
await eq.cambiarActivo(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The native call failed, so the handler kept the equalizer ON.
expect(fakeAudio.ecualizadorActivo, isTrue);
expect(
eq.activo,
isTrue,
reason: 'the UI must show what the engine really does',
);
expect(avisos, greaterThanOrEqualTo(1));
expect(
fakeServicio.guardarActivoLlamadas,
equals(0),
reason: 'a rejected value must never reach disk',
);
expect(fakeServicio.config.activo, isTrue);
eq.dispose();
},
);
});
}
/// Fake handler that REFUSES every on/off change: it records the call (the
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
/// rolling its flag back when the native `setEnabled` throws.
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
}
}
/// Fake whose [guardarActivo] stays pending until released, and releases the
+28
View File
@@ -159,6 +159,34 @@ void main() {
expect(estado.compraEnCurso, isFalse);
});
test('tras restaurar() sin compras el paywall NO queda bloqueado: se '
'puede volver a comprar', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
expect(estado.compraEnCurso, isTrue);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await Future<void>.delayed(Duration.zero);
// `compraEnCurso` deshabilita AMBOS botones de `hoja_premium.dart`
// (comprar y restaurar): si se queda pegado en `true`, el usuario ya no
// puede pagar nunca más.
expect(estado.compraEnCurso, isFalse);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
expect(compras.comprasIntentadas, 1);
});
test(
'un error en el flujo de compra no bloquea al pagador (fail-open)',
() async {
@@ -0,0 +1,171 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Closes the last remaining export/import gap: `EstadoEcualizador._activo`
/// (the equalizer's global ON/OFF toggle) was not part of the backup
/// envelope at all, so restoring a backup on another device silently kept
/// whatever that device's toggle happened to be. These tests exercise the
/// flag end to end through `EstadoRadio.exportarConfig`/`importarConfig`,
/// the real call sites `pantalla_ajustes_backup.dart` uses.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
// A PRIVATE per-test file, never the shared `test/fixtures/` one — see
// `estado_alarmas_import_test.dart` for why: `importarConfig`
// unconditionally writes to whatever `resolverArchivoCustom` resolves to.
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_eq_activo_export_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
Future<EstadoRadio> crearRadio({bool ecualizadorActivo = true}) async {
final prefs = await SharedPreferences.getInstance();
final archivoCustom = File('${tempDir.path}/emisoras_custom.json');
if (!archivoCustom.existsSync()) {
await archivoCustom.writeAsString('[]');
}
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(activo: ecualizadorActivo),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: () async => archivoCustom,
prefs: prefs,
iniciarAutomaticamente: false,
);
await radio.ecualizador.cargarPersistido();
return radio;
}
Map<String, dynamic> backupBase({
required int version,
bool? ecualizadorActivo,
}) {
final data = <String, dynamic>{
'version': version,
'gruposFavoritos': [],
'favoritos': [],
'emisorasCustom': [],
'presetsEcualizador': {},
'alarmas': null,
'emisoraPreferidaUuid': null,
'ordenListas': 'nombre',
'timerSuenoPresetsSegundos': <int>[300, 600],
};
if (ecualizadorActivo != null) {
data['ecualizadorActivo'] = ecualizadorActivo;
}
return data;
}
group('EstadoRadio export/import — equalizer on/off toggle (v4)', () {
test('exportarConfig includes the flag when the equalizer is ON', () async {
final radio = await crearRadio(ecualizadorActivo: true);
addTearDown(radio.dispose);
final exportado = await radio.exportarConfig();
expect(exportado['ecualizadorActivo'], isTrue);
expect(exportado['version'], 4);
});
test(
'exportarConfig includes the flag when the equalizer is OFF',
() async {
final radio = await crearRadio(ecualizadorActivo: false);
addTearDown(radio.dispose);
final exportado = await radio.exportarConfig();
expect(exportado['ecualizadorActivo'], isFalse);
expect(exportado['version'], 4);
},
);
test('importarConfig(activo: false) turns the equalizer off — persisted '
'and reflected in EstadoEcualizador.activo', () async {
final radio = await crearRadio(ecualizadorActivo: true);
addTearDown(radio.dispose);
expect(radio.ecualizador.activo, isTrue);
await radio.importarConfig(
backupBase(version: 4, ecualizadorActivo: false),
);
expect(radio.ecualizador.activo, isFalse);
});
test('importarConfig(activo: true) turns the equalizer on — persisted '
'and reflected in EstadoEcualizador.activo', () async {
final radio = await crearRadio(ecualizadorActivo: false);
addTearDown(radio.dispose);
expect(radio.ecualizador.activo, isFalse);
await radio.importarConfig(
backupBase(version: 4, ecualizadorActivo: true),
);
expect(radio.ecualizador.activo, isTrue);
});
test('importing an OLD backup (no ecualizadorActivo field) does not throw '
'and leaves the current toggle untouched', () async {
final radio = await crearRadio(ecualizadorActivo: false);
addTearDown(radio.dispose);
expect(radio.ecualizador.activo, isFalse);
await radio.importarConfig(backupBase(version: 2));
expect(radio.ecualizador.activo, isFalse);
});
test(
'full round-trip: export -> import restores the equalizer toggle',
() async {
final origen = await crearRadio(ecualizadorActivo: false);
addTearDown(origen.dispose);
final exportado = await origen.exportarConfig();
final destino = await crearRadio(ecualizadorActivo: true);
addTearDown(destino.dispose);
expect(destino.ecualizador.activo, isTrue);
await destino.importarConfig(exportado);
expect(destino.ecualizador.activo, isFalse);
},
);
test('regression: other fields still round-trip exactly as before '
'(ordenListas, timerSuenoPresetsSegundos)', () async {
final origen = await crearRadio();
addTearDown(origen.dispose);
await origen.guardarTimerSuenoPresetsSegundos([120, 900]);
final exportado = await origen.exportarConfig();
final destino = await crearRadio();
addTearDown(destino.dispose);
await destino.importarConfig(exportado);
expect(destino.ordenListas.name, origen.ordenListas.name);
});
});
}
@@ -0,0 +1,177 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Copia de seguridad — ROUND TRIP de los grupos de favoritos y de la
/// asignación emisora -> grupo.
///
/// Reportado desde el uso real: al restaurar una copia en otro dispositivo
/// los grupos volvían VACÍOS y todas las emisoras aparecían en «Sin
/// asignar». El sobre exportado siempre llevó ambas cosas (`gruposFavoritos`
/// desde v2, y `grupo_id` dentro de cada entrada de `favoritos`, porque es
/// una clave intrínseca de `Emisora.toMap()`); lo que fallaba era la
/// APLICACIÓN del estado: `importarConfig` reusaba `ServicioFavoritos.agregar`,
/// la primitiva de «marcar como favorita», que fuerza `sin_asignar` y un
/// `orden` nuevo a propósito.
///
/// Por eso estos tests prueban el VIAJE COMPLETO (origen -> exportar ->
/// destino limpio -> importar), no la forma del sobre: la forma ya estaba
/// bien y aun así el usuario perdía sus grupos.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_export_grupos_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
var contadorArchivos = 0;
Future<EstadoRadio> crearRadio() async {
final prefs = await SharedPreferences.getInstance();
// Un archivo POR instancia: `importarConfig` escribe siempre en el que
// resuelva `resolverArchivoCustom`, y origen y destino no pueden
// compartirlo.
final archivoCustom = File(
'${tempDir.path}/emisoras_custom_${contadorArchivos++}.json',
);
if (!archivoCustom.existsSync()) {
await archivoCustom.writeAsString('[]');
}
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: () async => archivoCustom,
prefs: prefs,
iniciarAutomaticamente: false,
);
await radio.ecualizador.cargarPersistido();
return radio;
}
Emisora emisora(String uuid, String nombre) => Emisora(
uuid: uuid,
nombre: nombre,
url: 'https://example.com/$uuid.mp3',
);
group('EstadoRadio export/import — grupos de favoritos', () {
test('round trip: los grupos y la asignación de CADA emisora sobreviven '
'al viaje origen -> copia -> destino limpio', () async {
final origen = await crearRadio();
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
await origen.toggleFavorito(emisora('rock-2', 'Rock Dos'));
await origen.toggleFavorito(emisora('jazz-1', 'Jazz Uno'));
await origen.toggleFavorito(emisora('suelta', 'Sin grupo'));
await origen.crearGrupoFavoritos('Rock');
await origen.crearGrupoFavoritos('Jazz');
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
final jazz = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Jazz');
await origen.asignarGrupoFavorito('rock-1', rock.id);
await origen.asignarGrupoFavorito('rock-2', rock.id);
await origen.asignarGrupoFavorito('jazz-1', jazz.id);
final copia = await origen.exportarConfig();
final destino = await crearRadio();
await destino.importarConfig(copia);
// Los grupos vuelven, con su nombre y su orden.
final gruposDestino = destino.gruposFavoritos;
expect(
gruposDestino.map((g) => g.id),
containsAll(<String>[rock.id, jazz.id]),
);
expect(gruposDestino.firstWhere((g) => g.id == rock.id).nombre, 'Rock');
expect(gruposDestino.firstWhere((g) => g.id == jazz.id).nombre, 'Jazz');
// Y la asignación de CADA emisora vuelve con ellos.
String grupoDe(String uuid) =>
destino.listaFavoritos.firstWhere((e) => e.uuid == uuid)
.grupoFavoritosId;
expect(grupoDe('rock-1'), rock.id);
expect(grupoDe('rock-2'), rock.id);
expect(grupoDe('jazz-1'), jazz.id);
expect(grupoDe('suelta'), GrupoFavoritos.sinAsignarId);
});
test('una copia ANTIGUA sin `gruposFavoritos` importa limpiamente y deja '
'intactos los grupos que ya existen en el dispositivo', () async {
final destino = await crearRadio();
await destino.crearGrupoFavoritos('Mío');
final propio = destino.gruposFavoritos.firstWhere(
(g) => g.nombre == 'Mío',
);
await destino.toggleFavorito(emisora('local-1', 'Local Uno'));
await destino.asignarGrupoFavorito('local-1', propio.id);
// v1: ni `gruposFavoritos` ni `alarmas` ni preferencias. La regla del
// sobre es que un campo AUSENTE no toca ese estado.
await destino.importarConfig(<String, dynamic>{
'version': 1,
'favoritos': <Map<String, dynamic>>[],
'emisorasCustom': <Map<String, dynamic>>[],
'presetsEcualizador': <String, dynamic>{},
});
expect(destino.gruposFavoritos.any((g) => g.id == propio.id), isTrue);
expect(
destino.gruposFavoritos.firstWhere((g) => g.id == propio.id).nombre,
'Mío',
);
expect(
destino.listaFavoritos.firstWhere((e) => e.uuid == 'local-1')
.grupoFavoritosId,
propio.id,
);
});
test('los grupos importados quedan visibles SIN reiniciar: importarConfig '
'recarga la lista en memoria y notifica', () async {
final origen = await crearRadio();
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
await origen.crearGrupoFavoritos('Rock');
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
await origen.asignarGrupoFavorito('rock-1', rock.id);
final copia = await origen.exportarConfig();
final destino = await crearRadio();
var notificaciones = 0;
destino.addListener(() => notificaciones++);
await destino.importarConfig(copia);
expect(notificaciones, greaterThan(0));
expect(destino.gruposFavoritos.any((g) => g.id == rock.id), isTrue);
expect(
destino.listaFavoritos.single.grupoFavoritosId,
rock.id,
reason:
'la vista de favoritos agrupa por `grupoFavoritosId`: si la lista '
'en memoria no se recarga tras restaurar los grupos, la pantalla '
'sigue mostrando todo en «Sin asignar» hasta reiniciar',
);
});
});
}
+39 -29
View File
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -792,21 +793,16 @@ void main() {
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
final estadoUno = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
// The record is now written by the audio handler's `_cambiarFuente`
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
// `ultima_emisora_v1` and the only one that also exists on the headless
// Android Auto engine — `EstadoRadio` used to write it too and no
// longer does. Seeded through that same production function here, so
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
// a real payload instead of one a fake invented. The write itself is
// covered end to end in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
await guardarUltimaEmisoraPersistida(emisora);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
@@ -848,9 +844,18 @@ void main() {
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'también se recuerda para la próxima instancia', () async {
'deja de estar ensombrecida por la seleccion previa del telefono',
() async {
// The PERSISTENCE half of this scenario moved to the handler, which is
// the only writer that exists on a car-only session — it is covered by
// «playFromMediaId desde el coche persiste ESA emisora» in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
// `EstadoRadio` still owns here, and what this test now pins, is the
// shadowing fix: a car selection bypasses `reproducir()`, so without
// the `estadoStream` listener `_emisoraSeleccionada` would keep
// shadowing the car's station on the `emisoraActual` getter.
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
@@ -859,7 +864,16 @@ void main() {
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estado.inicializar();
final desdeElTelefono = emisoraDemo(
uuid: 'phone-picked',
nombre: 'Elegida en el telefono',
);
unawaited(estado.reproducir(desdeElTelefono));
audio.completar(desdeElTelefono.uuid);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
@@ -867,18 +881,14 @@ void main() {
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
expect(
estado.emisoraActual?.uuid,
desdeCoche.uuid,
reason:
'the car changed the station without going through reproducir(); '
'the phone UI must follow it instead of keeping the previous '
'selection on screen',
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
}
+50 -1
View File
@@ -171,7 +171,45 @@ class FakeServicioFavoritos extends ServicioFavoritos {
@override
Future<void> agregar(Emisora emisora) async {
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
// FIEL a producción (`ServicioFavoritos.agregar`): esta es la primitiva de
// «marcar como favorita», y fuerza `sin_asignar` además de un `orden`
// nuevo. El doble NO lo hacía, así que cualquier test de import escrito
// contra él salía verde mientras el dispositivo real perdía la asignación
// de grupo. Para RESTAURAR una copia existe `restaurarFavorito`.
_favoritos.add(
emisora.copyWith(
orden: _favoritos.length,
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
),
);
}
@override
Future<void> restaurarFavorito(Emisora emisora) async {
// Fiel a producción: preserva `orden` y `grupo_id`, cayendo a
// `sin_asignar` cuando el grupo de la copia no existe.
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
final existe = _grupos.any((g) => g.id == emisora.grupoFavoritosId);
_favoritos.add(
existe
? emisora
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId),
);
}
@override
Future<void> restaurarGrupo(GrupoFavoritos grupo) async {
// Sin este override la llamada caía en la implementación REAL de sqflite
// y explotaba con «databaseFactory not initialized»; solo pasaba
// desapercibido porque todos los tests de import existentes mandaban
// `gruposFavoritos: []`.
if (grupo.esSinAsignar) return;
final index = _grupos.indexWhere((g) => g.id == grupo.id);
if (index == -1) {
_grupos.add(grupo);
} else {
_grupos[index] = grupo;
}
}
@override
@@ -414,6 +452,17 @@ class FakeServicioEcualizador extends ServicioEcualizador {
);
}
/// Overridden like every other write below: without this, calls fall
/// through to the real `ServicioEcualizador.guardarConfiguracion`, which
/// hits real SharedPreferences and needs a Flutter test binding — a
/// footgun for any test that reaches `EstadoEcualizador.importarConfiguracion`
/// (the backup-import path) through this Fake without also wiring
/// `TestWidgetsFlutterBinding`/mock prefs.
@override
Future<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
_config = config;
}
@override
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Test-isolation seam for [PluriWaveAudioHandler].
///
/// A handler nobody releases keeps running after the test that built it: its
/// terminal-state floor timer, its `ControladorReconexion` backoff (1/2/4/8/16
/// s, longer than most of the tests that arm it) and anything still queued on
/// its source-change chain. When one of those finally performs a source change
/// it calls `_crearPlayer()`, which reads the CURRENT
/// [PluriWaveAudioHandler.fabricaReproductorPrueba] — so a dead handler builds
/// a double bound to a LATER test's script and drives it, incrementing that
/// test's counters for work it never asked for.
///
/// That is why `servicio_audio_transporte_test.dart` behaved differently run
/// alone and run inside the whole suite. A suite that passes under those
/// conditions passes by luck, and luck runs out on a broken build exactly when
/// it matters.
///
/// Usage — call ONCE at the top of `main()` and build every handler through
/// the returned function:
///
/// ```dart
/// final crearHandler = registrarHandlersLiberables();
/// ...
/// final handler = crearHandler();
/// ```
///
/// The `tearDown` it registers covers every group in the file.
PluriWaveAudioHandler Function() registrarHandlersLiberables() {
final creados = <PluriWaveAudioHandler>[];
tearDown(() async {
// Released in reverse creation order so a handler built on top of an
// earlier one is torn down first. `liberar` is idempotent, so a test that
// already released its own handler is fine.
for (final handler in creados.reversed) {
await handler.liberar();
}
creados.clear();
});
return () {
final handler = PluriWaveAudioHandler();
creados.add(handler);
return handler;
};
}
Binary file not shown.
+20
View File
@@ -46,6 +46,20 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
('pt', 'welcomeBullet2Title'),
('ru', 'welcomeBullet2Title'),
('zh', 'welcomeBullet2Title'),
(
'ar',
'premiumBeneficioAndroidAuto',
), // "Android Auto" -- Google product name (fix/import-alarmas-y-paywall)
('bn', 'premiumBeneficioAndroidAuto'),
('de', 'premiumBeneficioAndroidAuto'),
('fr', 'premiumBeneficioAndroidAuto'),
('hi', 'premiumBeneficioAndroidAuto'),
('id', 'premiumBeneficioAndroidAuto'),
('it', 'premiumBeneficioAndroidAuto'),
('ja', 'premiumBeneficioAndroidAuto'),
('pt', 'premiumBeneficioAndroidAuto'),
('ru', 'premiumBeneficioAndroidAuto'),
('zh', 'premiumBeneficioAndroidAuto'),
// ---------------------------------------------------------------------
// Pure symbols / placeholders -- no translatable text at all.
@@ -286,4 +300,10 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
'restaurarCompras',
), // iap-freemium-unlock new key -- "Restaurar compras" is the standard
// Portuguese store wording and coincides with es word for word.
(
'pt',
'autoCarpetaFavoritos',
), // fix/auto-quality-guidelines car-tree label -- "Favoritos" is the same
// word in pt and es, exactly like the already-listed ('pt',
// 'favoritesTitle') above, which carries this very value.
};
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'
show registrarInvalidacionArbolAuto;
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -65,4 +68,81 @@ void main() {
expect(find.text('No folder selected'), findsOneWidget);
});
/// fix/android-auto-musica-local, item 4 — the browse-tree invalidation
/// after a successful folder pick had ZERO coverage and no testability
/// excuse: this file already mounts the screen, `registrarInvalidacionArbolAuto`
/// already takes a fake hook, and `pickMusicFolder` mocks exactly like
/// `hasPersistedPermission` does in `musica_local_auto_test.dart`.
///
/// It matters because Android Auto CACHES the browse root and never asks
/// again on its own: without the call, a driver who picks a folder on the
/// phone keeps getting a car with no «Música Local» entry for the rest of
/// the session.
group('invalidación del árbol de Android Auto tras elegir carpeta', () {
const canal = MethodChannel('pluriwave/file_actions');
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
});
Future<int> pulsarElegirCarpeta(
WidgetTester tester, {
required String? uriDevuelta,
}) async {
SharedPreferences.setMockInitialValues({});
var invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
expect(call.method, 'pickMusicFolder');
return uriDevuelta;
});
await tester.pumpWidget(buildScreen());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('Choose folder'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
return invalidaciones;
}
testWidgets('elegir una carpeta invalida el árbol cacheado del coche', (
tester,
) async {
final invalidaciones = await pulsarElegirCarpeta(
tester,
uriDevuelta: 'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FMyFolder',
);
expect(
invalidaciones,
1,
reason:
'acaba de aparecer música local donde antes no había, y el head '
'unit no vuelve a preguntar por su cuenta',
);
});
testWidgets('cancelar el selector NO invalida nada (el `if (uri == null) '
'return` es deliberado)', (tester) async {
final invalidaciones = await pulsarElegirCarpeta(
tester,
uriDevuelta: null,
);
expect(
invalidaciones,
0,
reason:
'nada cambió, así que forzar un re-browse del árbol entero sería '
'trabajo gratis para el coche',
);
});
});
}
+142
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Requested: the Android Auto playback screen must offer previous/next for
@@ -172,4 +173,145 @@ void main() {
);
});
});
group('contextoParaSaltoEmisora — la MISMA decisión, nombrada para poder '
'recordarla entre procesos', () {
Emisora favorita(String uuid, String grupo) => Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
final rock1 = favorita('rock1', 'g-rock');
final rock2 = favorita('rock2', 'g-rock');
final jazz1 = favorita('jazz1', 'g-jazz');
test('nombra el grupo cuando el salto se queda dentro del grupo', () {
expect(
contextoParaSaltoEmisora(
actual: rock1,
favoritos: [rock1, jazz1, rock2],
misEmisoras: const [],
todas: const [],
),
const ContextoSalto.grupo('g-rock'),
);
});
test('nombra la lista de cada uno de los otros tres casos', () {
expect(
contextoParaSaltoEmisora(
actual: jazz1,
favoritos: [rock1, jazz1, rock2],
misEmisoras: const [],
todas: const [],
),
const ContextoSalto.favoritos(),
reason: 'un grupo de un solo miembro cae a todos los favoritos',
);
expect(
contextoParaSaltoEmisora(
actual: c,
favoritos: [a, b],
misEmisoras: [c, a],
todas: [a, b, c],
),
const ContextoSalto.misEmisoras(),
);
expect(
contextoParaSaltoEmisora(
actual: c,
favoritos: [a],
misEmisoras: [b],
todas: [a, b, c],
),
const ContextoSalto.todas(),
);
});
test('null cuando la emisora no está en ninguna lista', () {
expect(
contextoParaSaltoEmisora(
actual: emisora('huerfana'),
favoritos: [a],
misEmisoras: [b],
todas: [a, b],
),
isNull,
);
});
test('CONCUERDA con listaParaSaltoEmisora en todos los casos: son la '
'misma decisión y no pueden divergir', () {
final escenarios = <List<List<Emisora>>>[
[
[rock1],
[rock1, jazz1, rock2],
const [],
const [],
],
[
[jazz1],
[rock1, jazz1, rock2],
const [],
const [],
],
[
[c],
[a, b],
[c, a],
[a, b, c],
],
[
[c],
[a],
[b],
[a, b, c],
],
[
[emisora('huerfana')],
[a],
[b],
[a, b],
],
];
for (final escenario in escenarios) {
final actual = escenario[0].single;
final favoritos = escenario[1];
final misEmisoras = escenario[2];
final todas = escenario[3];
final contexto = contextoParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
);
final porContexto =
contexto == null
? const <Emisora>[]
: resolverListaContexto(
contexto: contexto,
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
destacadas: const [],
grupos: const [
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
);
expect(
porContexto,
listaParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
),
);
}
});
});
}
@@ -0,0 +1,308 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Contexto de reproducción — el «en qué lista estoy» que sobrevive a que el
/// proceso muera.
///
/// Mismo molde headless-safe que `emisoras_destacadas.dart`: solo
/// `shared_preferences` y modelos, jamás `EstadoRadio` ni un `ChangeNotifier`,
/// porque este módulo tiene que leerse desde el motor sin árbol de widgets que
/// levanta Android Auto.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Emisora emisora(String uuid, {String grupo = GrupoFavoritos.sinAsignarId}) =>
Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
group('ContextoSalto — serialización', () {
test('la clave de persistencia queda fijada literalmente', () {
// Un rename silencioso aquí no rompe nada en compilación y deja al
// conductor sin contexto tras actualizar: se fija a propósito.
expect(claveContextoSalto, 'contexto_salto_v1');
});
test('round trip de los tres tipos que llevan carga útil', () {
for (final contexto in [
const ContextoSalto.grupo('g-rock'),
const ContextoSalto.favoritos(),
const ContextoSalto.misEmisoras(),
const ContextoSalto.todas(),
const ContextoSalto.destacadas(['a', 'b', 'c']),
]) {
expect(ContextoSalto.desdeMapa(contexto.aMapa()), contexto);
}
});
test('un payload corrupto o ajeno devuelve null en vez de lanzar', () {
expect(ContextoSalto.desdeMapa(const {}), isNull);
expect(ContextoSalto.desdeMapa(const {'tipo': 'inventado'}), isNull);
expect(
ContextoSalto.desdeMapa(const {'tipo': 'grupoFavoritos'}),
isNull,
reason: 'un contexto de grupo sin id de grupo no resuelve a nada',
);
expect(
ContextoSalto.desdeMapa(const {'tipo': 'destacadas', 'uuids': 7}),
isNull,
);
});
});
group('persistencia', () {
setUp(() => SharedPreferences.setMockInitialValues({}));
test('round trip por disco', () async {
final prefs = await SharedPreferences.getInstance();
await guardarContextoSalto(
const ContextoSalto.grupo('g-rock'),
prefs: prefs,
);
expect(
await contextoSaltoPersistido(prefs: prefs),
const ContextoSalto.grupo('g-rock'),
);
});
test('sin nada persistido devuelve null', () async {
final prefs = await SharedPreferences.getInstance();
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
});
test('un JSON ilegible degrada a null, nunca lanza: esto se lee desde un '
'botón del volante', () async {
SharedPreferences.setMockInitialValues({
claveContextoSalto: 'esto no es json',
});
final prefs = await SharedPreferences.getInstance();
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
SharedPreferences.setMockInitialValues({
claveContextoSalto: jsonEncode({'tipo': 'inventado'}),
});
expect(
await contextoSaltoPersistido(
prefs: await SharedPreferences.getInstance(),
),
isNull,
);
});
});
group('resolverListaContexto — degradación del contexto recordado', () {
final rock1 = emisora('rock1', grupo: 'g-rock');
final rock2 = emisora('rock2', grupo: 'g-rock');
final jazz1 = emisora('jazz1', grupo: 'g-jazz');
final suelta = emisora('suelta');
final favoritos = [rock1, jazz1, rock2, suelta];
const grupos = [
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
];
List<Emisora> resolver(
ContextoSalto contexto,
Emisora actual, {
List<Emisora>? favs,
List<GrupoFavoritos>? gruposVivos,
List<Emisora> misEmisoras = const [],
List<Emisora> todas = const [],
List<Emisora> destacadas = const [],
}) => resolverListaContexto(
contexto: contexto,
actual: actual,
favoritos: favs ?? favoritos,
misEmisoras: misEmisoras,
todas: todas,
destacadas: destacadas,
grupos: gruposVivos ?? grupos,
);
test('el grupo recordado se recorre con sus miembros VIVOS, no con el '
'snapshot', () {
final nuevo = emisora('rock3', grupo: 'g-rock');
expect(
resolver(
const ContextoSalto.grupo('g-rock'),
rock1,
favs: [rock1, jazz1, rock2, nuevo],
),
[rock1, rock2, nuevo],
);
});
test('el grupo recordado ya NO existe -> cae a todos los favoritos', () {
expect(
resolver(
const ContextoSalto.grupo('g-borrado'),
rock1,
gruposVivos: const [
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
),
favoritos,
);
});
test('el grupo SIGUE VIVO con un solo miembro -> se honra igual: un grupo '
'de una emisora sigue siendo el grupo que eligió el conductor', () {
expect(
resolver(
const ContextoSalto.grupo('g-jazz'),
jazz1,
),
[jazz1],
);
});
test('el grupo sigue vivo pero la emisora que suena ya NO pertenece a él '
'-> se PERMANECE en el grupo (el llamador coge su primera emisora)',
() {
expect(
resolver(const ContextoSalto.grupo('g-rock'), jazz1),
[rock1, rock2],
);
});
test('el grupo sigue vivo pero se quedó VACÍO -> no hay primera emisora '
'que coger, así que se ensancha a todos los favoritos', () {
expect(
resolver(
const ContextoSalto.grupo('g-rock'),
jazz1,
favs: [jazz1, suelta],
),
[jazz1, suelta],
);
});
test('el grupo fue borrado y la emisora ya NO es favorita -> aun así se '
'cae a los favoritos: el llamador elegirá una de ellas', () {
expect(
resolver(
const ContextoSalto.grupo('g-borrado'),
emisora('fuera'),
gruposVivos: const [],
),
favoritos,
);
});
test('no quedan favoritos -> lista vacía: el comportamiento de siempre '
'cuando no hay emisoras agregadas', () {
expect(
resolver(
const ContextoSalto.grupo('g-rock'),
emisora('fuera'),
favs: const [],
gruposVivos: const [],
),
isEmpty,
);
});
test('la emisora salió de favoritos por completo -> el contexto de '
'FAVORITOS se descarta', () {
expect(
resolver(const ContextoSalto.favoritos(), emisora('fuera')),
isEmpty,
);
});
test('favoritos / misEmisoras / todas se resuelven contra su lista viva', () {
expect(resolver(const ContextoSalto.favoritos(), rock1), favoritos);
final propia = emisora('propia');
expect(
resolver(
const ContextoSalto.misEmisoras(),
propia,
misEmisoras: [propia, suelta],
),
[propia, suelta],
);
final catalogo = emisora('catalogo');
expect(
resolver(
const ContextoSalto.todas(),
catalogo,
todas: [catalogo, rock1],
),
[catalogo, rock1],
);
});
test('destacadas respeta el ORDEN CONGELADO, que es la razón de existir '
'del snapshot: la lista viva se reordena sola en cada lectura', () {
final fip = emisora('fip');
final soma = emisora('soma');
final ajena = emisora('ajena');
expect(
resolver(
const ContextoSalto.destacadas(['ajena', 'fip', 'soma']),
ajena,
destacadas: [fip, soma],
),
[ajena, fip, soma],
reason:
'la emisora que suena entra en la lista aunque no esté en el set '
'curado; si no, ambos botones morirían',
);
});
test('destacadas: un uuid del snapshot que ya no resuelve se descarta', () {
final fip = emisora('fip');
final soma = emisora('soma');
expect(
resolver(
const ContextoSalto.destacadas(['fip', 'retirada', 'soma']),
fip,
destacadas: [fip, soma],
),
[fip, soma],
);
});
test('destacadas: si la emisora que suena no está en el snapshot el '
'contexto se descarta', () {
final fip = emisora('fip');
expect(
resolver(
const ContextoSalto.destacadas(['fip']),
emisora('otra'),
destacadas: [fip],
),
isEmpty,
);
});
});
group('uuidsCongeladosDestacadas', () {
test('respeta el orden curado y antepone la emisora que suena cuando no '
'pertenece al set', () {
final fip = emisora('fip');
final soma = emisora('soma');
expect(
uuidsCongeladosDestacadas(actual: fip, destacadas: [fip, soma]),
['fip', 'soma'],
);
expect(
uuidsCongeladosDestacadas(
actual: emisora('ajena'),
destacadas: [fip, soma],
),
['ajena', 'fip', 'soma'],
);
});
});
}
@@ -0,0 +1,125 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Free-tier featured set (fix/auto-quality-guidelines, item 6).
///
/// The whole compliance story rests on this: a Play reviewer on a fresh
/// install is ALWAYS free tier, has no network catalogue snapshot, no
/// favourites, no custom stations and no `ultima_emisora_v1` — so the free
/// root's single folder MUST still resolve to real, playable stations from
/// nothing but the binary itself.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const ultima = Emisora(
uuid: 'uuid-ultima',
nombre: 'Ultima escuchada',
url: 'https://ultima.example/stream',
);
group('resolverEmisorasDestacadas', () {
test('cold bind: sin red, sin EstadoRadio y con prefs vacías devuelve '
'>= 3 emisoras reales', () async {
SharedPreferences.setMockInitialValues({});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.length, greaterThanOrEqualTo(3));
expect(
destacadas.every((e) => e.uuid.isNotEmpty),
isTrue,
reason: 'un uuid vacío no se puede resolver desde emisora:<uuid>',
);
expect(
destacadas.every(
(e) => e.url.startsWith('http://') || e.url.startsWith('https://'),
),
isTrue,
);
expect(
destacadas.map((e) => e.uuid).toSet().length,
destacadas.length,
reason: 'uuids duplicados romperían porUuid',
);
});
test('con ultima_emisora_v1 presente: va PRIMERA y no se duplica',
() async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(ultima.toMap()),
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.first.uuid, ultima.uuid);
expect(
destacadas.where((e) => e.uuid == ultima.uuid).length,
1,
reason: 'la última escuchada no puede aparecer dos veces',
);
expect(destacadas.length, emisorasDestacadas.length + 1);
});
test('la última escuchada YA curada no añade una segunda fila', () async {
final yaCurada = emisorasDestacadas.first;
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(yaCurada.toMap()),
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.first.uuid, yaCurada.uuid);
expect(destacadas.length, emisorasDestacadas.length);
});
test('ultima_emisora_v1 corrupta degrada al set curado, nunca lanza',
() async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: 'no-es-json{{',
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.length, emisorasDestacadas.length);
});
});
group('esEmisoraGratuitaPorUuid', () {
test('un uuid curado es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(
await esEmisoraGratuitaPorUuid(emisorasDestacadas.first.uuid),
isTrue,
);
});
test('la última escuchada es gratuita aunque no esté curada', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(ultima.toMap()),
});
expect(await esEmisoraGratuitaPorUuid(ultima.uuid), isTrue);
});
test('un uuid del catálogo Radio Browser NO es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(await esEmisoraGratuitaPorUuid('uuid-del-catalogo'), isFalse);
});
test('uuid vacío nunca es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(await esEmisoraGratuitaPorUuid(''), isFalse);
});
});
test('claveUltimaEmisora coincide con la que persiste EstadoRadio', () {
expect(claveUltimaEmisora, 'ultima_emisora_v1');
});
}
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
/// `FuenteMusicaLocalAuto.estadoCarpeta` promises, in its own interface doc,
/// «Never throws: cualquier fallo degrada a un valor de
/// [EstadoCarpetaLocal], nunca a una excepción». The refactor to the
/// three-valued enum moved `await _uriPersistida()` OUTSIDE the try/catch,
/// so a SharedPreferences failure escaped again — and the only caller,
/// `PluriWaveAudioHandler.getChildren`'s root branch, awaits it inline, so
/// the throw takes the whole browse root down and empties the car.
///
/// DELIBERATE FILE SEPARATION: nothing here may call
/// `SharedPreferences.setMockInitialValues`. That call swaps
/// `SharedPreferencesStorePlatform.instance` for an in-memory store for the
/// rest of the isolate, and an in-memory store cannot fail. Left alone, the
/// default store answers a real `getAll` platform call that nothing handles
/// under `flutter test`, which is exactly the failure being exercised —
/// hence its own file, not a group inside `musica_local_auto_test.dart`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FuenteMusicaLocalAutoImpl.estadoCarpeta — fallo de prefs', () {
test('un fallo leyendo SharedPreferences degrada a noConfigurada en vez '
'de propagar y vaciar la raíz del coche', () async {
// Sin prefs inyectadas: `_resolverPrefs` cae en
// `SharedPreferences.getInstance()`, que aquí lanza.
final fuente = FuenteMusicaLocalAutoImpl();
await expectLater(
fuente.estadoCarpeta(),
completion(EstadoCarpetaLocal.noConfigurada),
);
});
test('ese fallo NO se reporta como canalNoDisponible, que es la única '
'respuesta que significa «hay carpeta pero no puedo comprobar el '
'permiso»', () async {
final fuente = FuenteMusicaLocalAutoImpl();
final estado = await fuente.estadoCarpeta();
expect(
estado,
isNot(EstadoCarpetaLocal.canalNoDisponible),
reason:
'el fallo de prefs es una MissingPluginException igual que la '
'del canal `pluriwave/file_actions`, así que un único try que '
'las capturase juntas borraría la distinción de 3 valores: el '
'árbol mostraría «Música Local» con un subárbol que explica un '
'problema de canal inexistente',
);
});
});
}
+126
View File
@@ -163,6 +163,132 @@ void main() {
},
);
});
/// fix/android-auto-musica-local — «Muchísimas veces (la mayoría) no
/// aparece la opción de reproducir música local, no aparece ni el menú».
///
/// El usuario TIENE la compra PRO, así que no es un problema de
/// entitlement. La causa real: `hasPersistedPermission` viaja por
/// `MethodChannel('pluriwave/file_actions')`, cuyo ÚNICO registro de
/// handler vive en `MainActivity.configureFlutterEngine`. Cuando Android
/// Auto levanta el MediaBrowserService sin que la app se haya abierto,
/// `audio_service` construye un FlutterEngine SIN Activity, ese método
/// nunca corre, el canal se queda sin handler y `invokeMethod` lanza
/// `MissingPluginException` — indistinguible hasta ahora de «permiso
/// revocado».
///
/// Estos tests fijan la distinción: «el canal no está disponible» NO es
/// «no hay carpeta».
group('FuenteMusicaLocalAutoImpl.estadoCarpeta', () {
const canal = MethodChannel('pluriwave/file_actions');
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
});
test(
'con URI persistida y SIN handler nativo (motor headless de Android '
'Auto) reporta canalNoDisponible, no noConfigurada',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
// Sin handler: `invokeMethod` lanza MissingPluginException, que es
// exactamente lo que pasa en el motor sin Activity.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(
await fuente.estadoCarpeta(),
EstadoCarpetaLocal.canalNoDisponible,
);
},
);
test(
'con URI persistida y handler nativo que responde false (permiso '
'revocado de verdad) reporta noConfigurada',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
expect(call.method, 'hasPersistedPermission');
return false;
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
},
);
test(
'con URI persistida y handler nativo que responde true reporta '
'configurada',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async => true);
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.configurada);
},
);
test(
'sin URI persistida reporta noConfigurada sin invocar el canal',
() async {
SharedPreferences.setMockInitialValues({});
var llamadas = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
llamadas++;
return true;
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
expect(llamadas, 0);
},
);
test(
'un PlatformException del canal (el handler SÍ existe, la llamada '
'falla) reporta noConfigurada, no canalNoDisponible',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
throw PlatformException(code: 'ERROR');
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
},
);
});
group('esArchivoAudio', () {
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
@@ -0,0 +1,267 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Free-tier Android Auto surface (fix/auto-quality-guidelines, items 7, 8
/// and 10).
///
/// Google Play returned "Approved with Issues" against the Android for Cars
/// App Quality Guidelines on version code 157. The free root advertised four
/// folders that each dead-ended on a single non-playable "Función Premium"
/// row, and on a cold headless bind every one of the underlying lists is
/// empty anyway. This suite pins the replacement: ONE browsable folder that
/// resolves to real, playable stations.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FuenteEmisorasAutoLocal.porUuid — item 7', () {
// Cold bind shape: `todas()` is `_snapshotTodas ?? const []`, no
// favourites (sqflite is not initialised under `flutter test`, so the
// read throws and degrades to `[]`), and a custom-stations path that
// does not exist.
FuenteEmisorasAutoLocal fuenteFria() => FuenteEmisorasAutoLocal(
resolverRutaCustom: () async => 'no/existe/emisoras_custom.json',
);
setUp(() => SharedPreferences.setMockInitialValues({}));
test('en frío resuelve un uuid destacado (antes devolvía null y la fila '
'no hacía nada al tocarla)', () async {
final fuente = fuenteFria();
final resuelta = await fuente.porUuid(emisorasDestacadas.first.uuid);
expect(resuelta, isNotNull);
expect(resuelta!.url, emisorasDestacadas.first.url);
});
test('en frío resuelve la última escuchada persistida', () async {
const ultima = Emisora(
uuid: 'uuid-ultima',
nombre: 'Ultima',
url: 'https://ultima.example/stream',
);
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"uuid-ultima","nombre":"Ultima",'
'"url":"https://ultima.example/stream"}',
});
final resuelta = await fuenteFria().porUuid(ultima.uuid);
expect(resuelta?.url, ultima.url);
});
test('un uuid desconocido sigue devolviendo null', () async {
expect(await fuenteFria().porUuid('uuid-inexistente'), isNull);
});
test('el snapshot vivo gana al set destacado para el MISMO uuid',
() async {
final fuente = fuenteFria();
final delCatalogo = Emisora(
uuid: emisorasDestacadas.first.uuid,
nombre: 'Version viva',
url: 'https://viva.example/stream',
);
fuente.actualizarSnapshot(todas: [delCatalogo]);
final resuelta = await fuente.porUuid(delCatalogo.uuid);
expect(resuelta?.url, 'https://viva.example/stream');
});
});
group('raiz(premium:) — item 8', () {
test('free: exactamente UNA carpeta navegable, y ninguna de las cuatro '
'que morían en la fila premium', () {
final constructor = ConstructorArbolAuto();
// `incluirMusicaLocal: true` a propósito: ni siquiera con carpeta
// local configurada puede el tier gratuito ver ese nodo.
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
expect(libre, hasLength(1));
expect(libre.single.id, ConstructorArbolAuto.idDestacadas);
expect(libre.single.playable, isFalse);
expect(libre.single.title, isNotEmpty);
expect(
libre.map((m) => m.id),
isNot(
anyOf(
contains(ConstructorArbolAuto.idFavoritos),
contains(ConstructorArbolAuto.idTodas),
contains(ConstructorArbolAuto.idMisEmisoras),
contains(ConstructorArbolAuto.idMusicaLocal),
),
),
);
});
test('premium: el árbol de hoy, sin cambios (guardia de regresión)', () {
final constructor = ConstructorArbolAuto();
expect(
constructor
.raiz(incluirMusicaLocal: true, premium: true)
.map((m) => m.id),
[
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
],
);
expect(
constructor
.raiz(incluirMusicaLocal: false, premium: true)
.map((m) => m.id),
[
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
],
);
expect(
constructor
.raiz(incluirMusicaLocal: true, premium: true)
.every((m) => m.playable == false),
isTrue,
);
});
test('la raíz del tier gratuito SIEMPRE lleva una carpeta navegable: '
'audio_service 0.18.18 descarta los rootHints, así que un root de '
'un solo item PLAYABLE se renderiza vacío en una unidad que solo '
'acepta FLAG_BROWSABLE', () {
final libre = ConstructorArbolAuto().raiz(
incluirMusicaLocal: false,
premium: false,
);
expect(libre.any((m) => m.playable == false), isTrue);
});
test('el titulo de la unica carpeta gratuita lo decide el LLAMANTE, no '
'una constante castellana de este archivo (hallazgo 4)', () {
final libre = ConstructorArbolAuto().raiz(
incluirMusicaLocal: false,
premium: false,
tituloDestacadas: 'Listen',
);
expect(
libre.single.title,
'Listen',
reason:
'this one label is 100% of the browse tree a free-tier (i.e. '
'every Play reviewer) driver ever sees; the pure builder stays '
'AppLocalizations-free, so the handler has to hand it the string',
);
});
});
group('hijosDestacadas — item 8/9', () {
test('mapea a items PLAYABLE con id emisora:<uuid>', () {
final items = ConstructorArbolAuto().hijosDestacadas(emisorasDestacadas);
expect(items, hasLength(emisorasDestacadas.length));
expect(items.every((m) => m.playable == true), isTrue);
expect(items.first.id, 'emisora:${emisorasDestacadas.first.uuid}');
expect(items.every((m) => m.artUri != null), isTrue);
});
test('lista vacía devuelve lista vacía, nunca lanza', () {
expect(ConstructorArbolAuto().hijosDestacadas(const []), isEmpty);
});
});
group('respuestaBloqueadaPorEntitlement — item 10', () {
List<MediaItem>? gate(String id, {required bool premium}) =>
respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: premium,
destacadas: emisorasDestacadas,
);
test('premium: nada se bloquea', () {
for (final id in [
AudioService.browsableRootId,
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idDestacadas,
'emisora:uuid-del-catalogo',
]) {
expect(gate(id, premium: true), isNull, reason: id);
}
});
test('free: la raíz y el contenido gratuito PASAN', () {
expect(gate(AudioService.browsableRootId, premium: false), isNull);
expect(gate(ConstructorArbolAuto.idDestacadas, premium: false), isNull);
for (final e in emisorasDestacadas) {
expect(gate('emisora:${e.uuid}', premium: false), isNull);
}
});
test('free: el catálogo premium se bloquea', () {
for (final id in [
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idEcualizador,
'grupo:algo',
'emisora:uuid-del-catalogo',
'pista:doc-id',
]) {
expect(gate(id, premium: false), isNotNull, reason: id);
}
});
test('la rama bloqueada devuelve el contenido gratuito, NUNCA una fila '
'no reproducible: eso es exactamente lo que Play citó', () {
final bloqueada = gate(ConstructorArbolAuto.idTodas, premium: false);
expect(bloqueada, isNotNull);
expect(bloqueada, isNotEmpty);
expect(
bloqueada!.every((m) => m.playable == true),
isTrue,
reason: 'una fila no reproducible en el árbol es la cita de Play',
);
expect(bloqueada.map((m) => m.id), [
for (final e in emisorasDestacadas) 'emisora:${e.uuid}',
]);
});
test('sin destacadas resolubles la rama bloqueada sigue sin inventar una '
'fila muerta', () {
final bloqueada = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idTodas,
premium: false,
destacadas: const [],
);
expect(bloqueada, isEmpty);
});
});
test('idPremiumInfo / itemPremiumBloqueado ya no existen — item 10', () {
// Guardia estructural: si alguien los reintroduce, este archivo deja de
// compilar por el `expect` de abajo, no por un comentario. La única
// prueba real es que `ConstructorArbolAuto` no expone ningún item no
// reproducible fuera de las carpetas.
final libre = ConstructorArbolAuto().raiz(
incluirMusicaLocal: true,
premium: false,
);
expect(libre.every((m) => m.id != 'premium:info'), isTrue);
});
}
+115 -79
View File
@@ -1,106 +1,142 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content",
/// design.md ADR-4). All pure — no handler instantiation needed
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test).
/// Android Auto entitlement gating — the id-shape matrix.
///
/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
/// assert the opposite design: that every non-root id, for a free-tier user,
/// collapsed to a single non-playable `premium:info` row. Google Play cited
/// that browse tree against the Android for Cars App Quality Guidelines, so
/// the contract is now content-scoping — the free tier sees LESS, never a
/// row that does nothing.
///
/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in
/// `navegacion_auto_destacadas_test.dart`; this file pins the decision
/// surface ([idPermitidoEnFree]) across every id shape the tree can produce,
/// including the stale/deep-linked ones a head unit's cached tree replays.
void main() {
group('raiz(premium:) — root keeps its labels for every tier', () {
test('premium: identical to today\'s tree (regression guard)', () {
final constructor = ConstructorArbolAuto();
const gratuitas = [
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
];
final premiumConLocal = constructor.raiz(
incluirMusicaLocal: true,
premium: true,
group('idPermitidoEnFree', () {
test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
expect(
idPermitidoEnFree(
AudioService.browsableRootId,
destacadas: gratuitas,
),
isTrue,
);
final premiumSinLocal = constructor.raiz(
incluirMusicaLocal: false,
premium: true,
);
expect(premiumConLocal.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
]);
expect(premiumConLocal.every((m) => m.playable == false), isTrue);
expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue);
expect(premiumSinLocal.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
]);
});
test('free: same folder ids/titles, non-blank, never playable', () {
final constructor = ConstructorArbolAuto();
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
expect(libre, isNotEmpty);
expect(libre.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
]);
expect(libre.every((m) => m.playable == false), isTrue);
});
});
test(
'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium',
() {
final item = ConstructorArbolAuto().itemPremiumBloqueado();
expect(item.id, 'premium:info');
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
},
);
group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () {
test('root nunca es bloqueada (root siempre resuelve via raiz)', () {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: AudioService.browsableRootId,
premium: false,
test('la carpeta gratuita pasa', () {
expect(
idPermitidoEnFree(
ConstructorArbolAuto.idDestacadas,
destacadas: gratuitas,
),
isTrue,
);
expect(respuesta, isNull);
});
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
test('un emisora:<uuid> del set gratuito pasa', () {
for (final e in gratuitas) {
expect(
idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas),
isTrue,
reason: e.uuid,
);
}
});
test('las carpetas premium NO pasan', () {
for (final id in [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador,
// Stale/deep-linked id from before a downgrade — the backstop must
// not special-case known ids (Spec "Stale folder id bypass
// attempt").
'emisora:algun-uuid-viejo',
'grupo:algo',
]) {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: false,
);
expect(respuesta, hasLength(1));
expect(respuesta!.single.id, 'premium:info');
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
reason: id);
}
});
test('cualquier id no-root, en premium, no es bloqueada', () {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idFavoritos,
premium: true,
test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
'ésa es la propiedad de seguridad que el rediseño tenía que '
'conservar', () {
for (final id in [
'emisora:uuid-del-catalogo',
'grupo:algun-grupo',
'pista:doc-id',
'carpeta_local:doc-id',
'carpeta_local_reproducir:doc-id',
'carpeta_local_aleatorio:doc-id',
'eq_preset:Rock',
'premium:info', // la fila muerta que ya no existe
'',
]) {
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
reason: id);
}
});
test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () {
expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse);
});
test('con el set gratuito vacío solo pasan la raíz y su carpeta', () {
expect(
idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []),
isTrue,
);
expect(
idPermitidoEnFree(
ConstructorArbolAuto.idDestacadas,
destacadas: const [],
),
isTrue,
);
expect(
idPermitidoEnFree('emisora:libre-1', destacadas: const []),
isFalse,
);
});
});
group('respuestaBloqueadaPorEntitlement', () {
test('premium: ningún id se bloquea, ni siquiera uno inventado', () {
for (final id in [
AudioService.browsableRootId,
ConstructorArbolAuto.idTodas,
'emisora:cualquier-cosa',
'basura',
]) {
expect(
respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: true,
destacadas: gratuitas,
),
isNull,
reason: id,
);
}
});
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
final bloqueada = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
premium: false,
destacadas: gratuitas,
);
expect(respuesta, isNull);
expect(bloqueada, isNotNull);
expect(bloqueada!.every((m) => m.playable == true), isTrue);
});
});
}
@@ -0,0 +1,221 @@
import 'dart:ui' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Every user-readable label of the Android Auto browse tree is translated.
///
/// The owner's rule, after Play saw a Spanish-only car tree on a head unit
/// in any of the 13 shipped locales: anything a user can read gets
/// translated. The two ARB guards (`arb_parity_test`/`arb_anti_copy_test`)
/// only ever see strings that already entered the ARB system, so neither
/// could catch a label hardcoded in `navegacion_auto.dart` that never
/// became a key. This file closes that hole from the CONSUMPTION side
/// (the tree really renders the injected locale);
/// `test/l10n/etiquetas_arbol_auto_test.dart` closes it from the SOURCE
/// side (no new hardcoded label can be added at all).
Future<Map<String, MetadatosPista>> _sinMetadatos(List<String> ids) async =>
const {};
List<NodoLocal> _pistas(int cuantas, {String prefijo = 'cancion'}) =>
List.generate(
cuantas,
(i) => NodoLocal(
documentId: 'doc-$prefijo-$i',
nombre: '${prefijo}_${i.toString().padLeft(3, '0')}.mp3',
esDirectorio: false,
),
);
void main() {
final ingles = lookupAppLocalizations(const Locale('en'));
group('EtiquetasArbolAuto desde AppLocalizations', () {
test('mapea cada etiqueta del árbol a su clave ARB del locale', () {
final etiquetas = etiquetasArbolAutoDesde(ingles);
expect(etiquetas.escuchar, ingles.autoCarpetaEscuchar);
expect(etiquetas.favoritos, ingles.autoCarpetaFavoritos);
expect(etiquetas.todasLasEmisoras, ingles.autoCarpetaTodas);
expect(etiquetas.misEmisoras, ingles.autoCarpetaMisEmisoras);
expect(etiquetas.musicaLocal, ingles.autoCarpetaMusicaLocal);
expect(
etiquetas.musicaLocalNoDisponible,
ingles.autoMusicaLocalNoDisponible,
);
expect(etiquetas.cargarMas, ingles.autoCargarMas);
expect(etiquetas.ordenarPorCalidad, ingles.autoOrdenarPorCalidad);
expect(etiquetas.reproducirCarpeta, ingles.autoReproducirCarpeta);
expect(etiquetas.reproducirAleatorio, ingles.autoReproducirAleatorio);
expect(etiquetas.pistaSinNombre, ingles.autoPistaSinNombre);
});
test('ninguna etiqueta inglesa cae en el castellano de respaldo', () {
final etiquetas = etiquetasArbolAutoDesde(ingles);
const respaldo = EtiquetasArbolAuto.respaldo;
expect(etiquetas.favoritos, isNot(respaldo.favoritos));
expect(etiquetas.todasLasEmisoras, isNot(respaldo.todasLasEmisoras));
expect(etiquetas.misEmisoras, isNot(respaldo.misEmisoras));
expect(etiquetas.musicaLocal, isNot(respaldo.musicaLocal));
expect(
etiquetas.musicaLocalNoDisponible,
isNot(respaldo.musicaLocalNoDisponible),
);
expect(etiquetas.cargarMas, isNot(respaldo.cargarMas));
expect(etiquetas.ordenarPorCalidad, isNot(respaldo.ordenarPorCalidad));
expect(etiquetas.reproducirCarpeta, isNot(respaldo.reproducirCarpeta));
expect(etiquetas.reproducirAleatorio, isNot(respaldo.reproducirAleatorio));
expect(etiquetas.pistaSinNombre, isNot(respaldo.pistaSinNombre));
});
});
group('ConstructorArbolAuto rotula con las etiquetas inyectadas', () {
final constructor = ConstructorArbolAuto(
etiquetas: etiquetasArbolAutoDesde(ingles),
);
test('la raíz premium rotula sus cuatro carpetas en el locale', () {
final raiz = constructor.raiz(incluirMusicaLocal: true, premium: true);
expect(raiz.map((i) => i.title).toList(), [
ingles.autoCarpetaFavoritos,
ingles.autoCarpetaTodas,
ingles.autoCarpetaMisEmisoras,
ingles.autoCarpetaMusicaLocal,
]);
});
test('la raíz gratuita sigue rotulando Escuchar en el locale', () {
final raiz = constructor.raiz(incluirMusicaLocal: false, premium: false);
expect(raiz.single.title, ingles.autoCarpetaEscuchar);
});
test('el item de música local no disponible va en el locale', () {
expect(
constructor.itemLocalNoDisponible().title,
ingles.autoMusicaLocalNoDisponible,
);
});
test('las acciones de carpeta y la entrada de orden van en el '
'locale', () async {
final items = await constructor.itemsLocales(
_pistas(3),
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final titulos = items.map((i) => i.title).toList();
expect(titulos, contains(ingles.autoReproducirCarpeta));
expect(titulos, contains(ingles.autoReproducirAleatorio));
expect(titulos, contains(ingles.autoOrdenarPorCalidad));
});
test('el item "cargar más" de las tres vistas paginadas va en el '
'locale', () async {
final nodos = _pistas(60);
final porNombre = await constructor.itemsLocales(
nodos,
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final porCalidad = await constructor.itemsLocalesOrdenCalidad(
nodos,
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final porBucket = await constructor.itemsLocalesBucket(
_pistas(60, prefijo: 'apple'),
documentIdPadre: 'padre',
idxBucket: 0,
metadatosDe: _sinMetadatos,
);
expect(porNombre.last.title, ingles.autoCargarMas);
expect(porCalidad.last.title, ingles.autoCargarMas);
expect(porBucket.last.title, ingles.autoCargarMas);
});
test('un nombre de fichero en blanco cae en la pista sin nombre del '
'locale', () async {
final items = await constructor.itemsLocales(
const [
NodoLocal(
documentId: 'doc-vacio',
nombre: ' ',
esDirectorio: false,
),
],
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final pista = items.singleWhere((i) => i.id.startsWith('pista:'));
expect(pista.title, ingles.autoPistaSinNombre);
});
test('los rangos alfabéticos NO se traducen: son rangos de letras '
'latinas, no prosa', () async {
final items = await constructor.itemsLocales(
_pistas(60),
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
expect(items.map((i) => i.title), containsAll(['A-F', 'G-M']));
});
});
group('hijosMusicaLocal propaga las etiquetas', () {
test('la carpeta raíz local rotula sus acciones en el locale', () async {
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: _FuenteLocalFalsa(),
etiquetas: etiquetasArbolAutoDesde(ingles),
);
expect(
items!.map((i) => i.title),
containsAll([
ingles.autoReproducirCarpeta,
ingles.autoReproducirAleatorio,
]),
);
});
});
}
/// Minimal in-memory [FuenteMusicaLocalAuto]: one playable track at the
/// tree root, no metadata, native channel available.
class _FuenteLocalFalsa implements FuenteMusicaLocalAuto {
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async =>
EstadoCarpetaLocal.configurada;
@override
Future<List<NodoLocal>> hijos(String documentId) async =>
documentId.isEmpty
? const [
NodoLocal(
documentId: 'doc-0',
nombre: 'cancion.mp3',
esDirectorio: false,
),
]
: const [];
@override
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds) =>
_sinMetadatos(documentIds);
@override
Future<String?> uriContenidoDePista(String documentId) async =>
'content://fake/$documentId';
}
+72 -4
View File
@@ -2130,6 +2130,74 @@ void main() {
expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull);
});
/// fix/android-auto-musica-local: la raíz ya no oculta el nodo cuando
/// el canal nativo no está disponible, así que el subárbol tiene que
/// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se
/// lee como «no tengo música», que es justo la conclusión equivocada).
///
/// La etiqueta sale de `EtiquetasArbolAuto.musicaLocalNoDisponible`,
/// como TODAS las etiquetas legibles del árbol del coche: todo lo que
/// un usuario lee se traduce. Este test solo comprueba que hay UNA
/// etiqueta no vacía; el idioma concreto lo cubre
/// `navegacion_auto_localizacion_test.dart`.
test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item '
'explicativo NO reproducible, no una carpeta vacía', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
estado: EstadoCarpetaLocal.canalNoDisponible,
);
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(items!.map((i) => i.id), [ConstructorArbolAuto.idLocalNoLista]);
expect(items.single.playable, isFalse);
expect(items.single.title, isNotEmpty);
});
test('canalNoDisponible pero CON pistas resueltas: no se entromete, se '
'listan las pistas normalmente', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
estado: EstadoCarpetaLocal.canalNoDisponible,
hijosPorDocId: {
'': const [
NodoLocal(
documentId: 'd1',
nombre: 'Cancion.mp3',
esDirectorio: false,
),
],
},
);
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(
items!.map((i) => i.id),
isNot(contains(ConstructorArbolAuto.idLocalNoLista)),
);
expect(items.any((i) => i.id == 'pista:d1'), isTrue);
});
test('carpeta genuinamente vacía con el canal SÍ disponible: sigue '
'devolviendo lista vacía, sin item explicativo', () async {
final fuente = _FakeFuenteMusicaLocalAuto();
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(items, isEmpty);
});
test('fuente null (cold-start, nunca registrada) devuelve lista vacía '
'para un id de música local válido, no null y sin lanzar', () async {
final resultado = await hijosMusicaLocal(
@@ -3630,7 +3698,7 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
_FakeFuenteMusicaLocalAuto({
bool configurada = true,
EstadoCarpetaLocal estado = EstadoCarpetaLocal.configurada,
Map<String, List<NodoLocal>>? hijosPorDocId,
Map<String, String?>? uriPorDocId,
Map<String, MetadatosPista>? metadatosPorDocId,
@@ -3638,7 +3706,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
Object? errorEnUriContenido,
Object? errorEnMetadatosDe,
Set<String>? idsConErrorEnHijos,
}) : _configurada = configurada,
}) : _estado = estado,
_hijosPorDocId = hijosPorDocId ?? const {},
_uriPorDocId = uriPorDocId ?? const {},
_metadatosPorDocId = metadatosPorDocId ?? const {},
@@ -3647,7 +3715,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
_errorEnMetadatosDe = errorEnMetadatosDe,
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
final bool _configurada;
final EstadoCarpetaLocal _estado;
final Map<String, List<NodoLocal>> _hijosPorDocId;
final Map<String, String?> _uriPorDocId;
final Map<String, MetadatosPista> _metadatosPorDocId;
@@ -3666,7 +3734,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
final List<List<String>> llamadasMetadatosDe = [];
@override
Future<bool> hayCarpetaConfigurada() async => _configurada;
Future<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
@override
Future<List<NodoLocal>> hijos(String documentId) async {
@@ -0,0 +1,679 @@
import 'dart:async';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Free-tier Android Auto surface, handler side (fix/auto-quality-guidelines,
/// items 9, 11, 12, 13, 14).
///
/// Every test here runs with `_fuenteNavegacionGlobal` NEVER registered —
/// this file never calls `registrarFuenteNavegacion`. That is the exact bind
/// a Play reviewer performs: Android Auto starts the headless engine, and
/// until (and even after) `main.dart` wires its sources, the car's browse and
/// play paths must produce real, playable content out of the binary alone.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier: `esPremiumPersistido` is
// `getBool('compra_premium_v1') ?? false`, and there is no trial key.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.lectorLocalePlataforma =
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
});
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
group('debeBloquearCambioDeEmisora — item 11', () {
test('free + emisora gratuita: NO bloquea', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
isFalse,
);
});
test('free + emisora premium: bloquea (propiedad de seguridad original — '
'un emisora:<uuid> rancio de antes de una bajada de tier no puede '
'sonar)', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
isTrue,
);
});
test('premium: nunca bloquea', () {
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
isFalse,
);
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: true),
isFalse,
);
});
});
group('getChildren — item 9', () {
test('sin fuente de navegación registrada, la carpeta gratuita devuelve '
'>= 3 items REPRODUCIBLES', () async {
final handler = crearHandler();
final items = await handler.getChildren(
ConstructorArbolAuto.idDestacadas,
);
expect(items.length, greaterThanOrEqualTo(3));
expect(items.every((m) => m.playable == true), isTrue);
expect(items.every((m) => m.id.startsWith('emisora:')), isTrue);
});
test('la raíz gratuita es UNA carpeta y no paga el round trip nativo de '
'estadoCarpeta()', () async {
final fuenteLocal = _FuenteMusicaLocalEspia();
registrarFuenteMusicaLocal(fuenteLocal);
final handler = crearHandler();
final raiz = await handler.getChildren(AudioService.browsableRootId);
expect(raiz.map((m) => m.id), [ConstructorArbolAuto.idDestacadas]);
expect(
fuenteLocal.llamadasEstadoCarpeta,
0,
reason:
'estadoCarpeta() viaja por un MethodChannel que NO existe en el '
'motor headless; el tier gratuito no puede ver Música Local, así '
'que preguntarlo solo añade una vía de fallo en la raíz',
);
});
test('una carpeta premium NO devuelve una fila no reproducible: devuelve '
'el contenido gratuito', () async {
final handler = crearHandler();
final items = await handler.getChildren(ConstructorArbolAuto.idTodas);
expect(items, isNotEmpty);
expect(items.every((m) => m.playable == true), isTrue);
expect(items.every((m) => m.id != 'premium:info'), isTrue);
});
});
group('playFromMediaId — item 12', () {
test('free + uuid gratuito: SUENA', () async {
final handler = crearHandler();
final destacada = emisorasDestacadas.first;
await handler.playFromMediaId('emisora:${destacada.uuid}');
await pumpEventQueue();
expect(guion.urlsSolicitadas, contains(destacada.url));
});
test('free + uuid premium: publica error CON errorCode y errorMessage '
'localizado, nunca vuelve en silencio', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
await handler.playFromMediaId('emisora:uuid-del-catalogo');
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.processingState, AudioProcessingState.error);
expect(estado.errorCode, isNotNull);
expect(
estado.errorMessage,
lookupAppLocalizations(const Locale('en')).autoErrorEmisoraPremium,
);
expect(guion.urlsSolicitadas, isEmpty);
});
test('free + pista local: bloqueada, y también publica el error', () async {
final handler = crearHandler();
await handler.playFromMediaId('pista:doc-id');
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
});
test('free + uuid inexistente en NINGÚN sitio: error, no silencio',
() async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:');
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
});
});
group('playFromSearch — items 12 y 13', () {
test('free: una consulta que casa con el set gratuito SUENA', () async {
final handler = crearHandler();
final destacada = emisorasDestacadas.first;
await handler.playFromSearch(destacada.nombre);
await pumpEventQueue();
expect(guion.urlsSolicitadas, contains(destacada.url));
});
test('free: una consulta sin resultados publica error localizado, nunca '
'vuelve en silencio', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
final handler = crearHandler();
await handler.playFromSearch('emisora que no existe en ningun sitio');
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.processingState, AudioProcessingState.error);
expect(estado.errorCode, isNotNull);
expect(estado.errorMessage, textos().autoErrorBusquedaSinResultados);
});
test('consulta VACÍA ("Reproduce PluriWave") arranca algo — free', () async {
final handler = crearHandler();
await handler.playFromSearch('');
await pumpEventQueue();
expect(guion.urlsSolicitadas, isNotEmpty);
});
test('consulta VACÍA arranca algo — PREMIUM (antes fallaba incluso para '
'un cliente que había pagado)', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final handler = crearHandler();
await handler.playFromSearch(' ');
await pumpEventQueue();
expect(guion.urlsSolicitadas, isNotEmpty);
});
test('consulta vacía prefiere la ÚLTIMA escuchada', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"uuid-ultima","nombre":"Ultima",'
'"url":"https://ultima.example/stream"}',
});
final handler = crearHandler();
await handler.playFromSearch('');
await pumpEventQueue();
expect(guion.urlsSolicitadas, ['https://ultima.example/stream']);
});
});
group('un rechazo NO destruye una sesion que esta sonando — hallazgo 1', () {
/// Puts the handler in the exact state the reviewer reproduces: a free
/// station tapped from the browse tree and audibly playing.
Future<PluriWaveAudioHandler> sonando() async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
guion.ultimoReproductor!.emitir(
PlayerState(true, ProcessingState.ready),
);
await pumpEventQueue();
expect(
handler.playbackState.value.playing,
isTrue,
reason: 'precondicion: la emisora esta sonando',
);
return handler;
}
test(
'una busqueda por voz fallida deja el estado publicado FUERA de error y '
'sigue ofreciendo play/pause/stop',
() async {
final handler = await sonando();
// The free candidate set is only the six compiled-in stations, so
// almost any spoken station name misses. That must not cost the
// driver the whole now-playing screen.
await handler.playFromSearch('BBC');
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason:
'AudioService.java:601-611 maps `error` to STATE_ERROR with the '
'`playing` flag IGNORED, so publishing it over live audio '
'replaces the transport row with an error the session can never '
'clear: playerStateStream is .distinct() (nothing more comes '
'from a steadily playing ExoPlayer) and _bufferedSub re-asserts '
'it ~2x/second through copyWith',
);
expect(
estado.playing,
isTrue,
reason: 'el audio sigue sonando; el estado tiene que decirlo',
);
expect(
estado.systemActions,
containsAll(<MediaAction>[
MediaAction.play,
MediaAction.pause,
MediaAction.stop,
]),
);
expect(
estado.controls.map((c) => c.action),
containsAll(<MediaAction>[MediaAction.pause, MediaAction.stop]),
);
},
);
test('y el motivo del fallo SIGUE llegando al head unit', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
// AudioService.java:541-544 calls `setErrorMessage` from `setState`
// regardless of processingState, so the text still reaches
// PlaybackStateCompat without STATE_ERROR.
expect(
handler.playbackState.value.errorMessage,
textos().autoErrorBusquedaSinResultados,
);
expect(handler.playbackState.value.errorCode, isNotNull);
});
test(
'sin sesion viva el rechazo SI es terminal: error explicado (el caso en '
'que el coche no tiene nada que perder)',
() async {
final handler = crearHandler();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
expect(handler.playbackState.value.errorMessage, isNotNull);
},
);
/// CONTRACT for an action refusal published over a LIVE session
/// (`_publicarErrorAuto`'s non-terminal branch):
///
/// 1. the code and the message are published immediately and stand for
/// [PluriWaveAudioHandler.ventanaErrorAccionAuto] (the two tests
/// above pin step 1);
/// 2. when that window elapses they are BOTH cleared, and nothing else
/// about the state moves;
/// 3. any real player transition arriving first clears them early — a
/// genuine state change supersedes a stale refusal;
/// 4. only the fields this refusal published are ever cleared, so a
/// reconnect status message that replaced them survives.
///
/// It has to be bounded: `_bufferedSub` republishes
/// `playbackState.value.copyWith(...)` ~2x/second and `copyWith` carries
/// every omitted field forward (audio_service.dart:400-427), so
/// `AudioService.java:541-544` re-calls `setErrorMessage(code, msg)` on
/// every one of those pushes. Without a clear, one voice miss makes the
/// session advertise an error for the rest of the station's playback,
/// over audible healthy audio.
group('y el rechazo es TRANSITORIO: nada lo arrastra para siempre', () {
setUp(() {
PluriWaveAudioHandler.ventanaErrorAccionAuto = const Duration(
milliseconds: 80,
);
});
tearDown(() {
PluriWaveAudioHandler.ventanaErrorAccionAuto =
PluriWaveAudioHandler.ventanaErrorAccionAutoPorDefecto;
});
test('al pasar la ventana, codigo y mensaje desaparecen', () async {
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(
handler.playbackState.value.errorCode,
isNotNull,
reason: 'precondicion: el rechazo se publico',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.errorCode,
isNull,
reason:
'every later push carries the code forward through copyWith, so '
'the session would keep telling the head unit it is in error '
'while the station plays perfectly',
);
expect(estado.errorMessage, isNull);
expect(
estado.playing,
isTrue,
reason: 'clearing the refusal must not touch the session itself',
);
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason: 'nor its processing state',
);
});
test(
'y un cambio de estado real del reproductor los limpia antes',
() async {
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(handler.playbackState.value.errorMessage, isNotNull);
// The driver pauses: a genuine transition. `manejarEstadoPlayer`
// omitted both fields, so `copyWith` carried the refusal into the
// paused state and every state after it.
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.ready),
);
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.errorCode, isNull);
expect(estado.errorMessage, isNull);
},
);
test('y stop() limpia el codigo, no solo el mensaje', () async {
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(handler.playbackState.value.errorCode, isNotNull);
await handler.stop();
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(
estado.errorCode,
isNull,
reason:
'stop() cleared errorMessage but omitted errorCode, so the idle '
'it publishes shipped a stale ERROR_CODE_PREMIUM_ACCOUNT_'
'REQUIRED (4) with no message to explain it',
);
expect(estado.errorMessage, isNull);
});
});
});
group('errorCode se limpia en un cambio de fuente — item 6', () {
test('un rechazo premium seguido de una emisora gratuita NO arrastra el '
'codigo de error', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:uuid-del-catalogo');
await pumpEventQueue();
expect(
handler.playbackState.value.errorCode,
isNotNull,
reason: 'precondicion: el rechazo publico un codigo',
);
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
expect(
handler.playbackState.value.errorCode,
isNull,
reason:
'`_cambiarFuente` cleared errorMessage but omitted errorCode, and '
'copyWith carries an omitted field forward, so a stale code rode '
'along indefinitely',
);
expect(handler.playbackState.value.errorMessage, isNull);
});
});
group('getChildren(recentRootId) — reanudacion del head unit', () {
test('con una ultima emisora persistida devuelve EXACTAMENTE un item '
'reproducible', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"uuid-ultima","nombre":"Ultima",'
'"url":"https://ultima.example/stream"}',
});
final handler = crearHandler();
final items = await handler.getChildren(AudioService.recentRootId);
expect(
items,
hasLength(1),
reason:
'onGetRoot (AudioService.java:817-821) answers "recent" whenever '
'the head unit sends EXTRA_RECENT, which Android Auto does on '
'reconnect, and the platform expects exactly one resume item — '
'free tier used to fall through and return all six stations',
);
expect(items.single.playable, isTrue);
expect(items.single.id, 'emisora:uuid-ultima');
});
test('sin ultima emisora devuelve lista vacia y no lanza', () async {
final handler = crearHandler();
expect(await handler.getChildren(AudioService.recentRootId), isEmpty);
});
test('premium tambien obtiene su tile de reanudacion, no una lista '
'vacia', () async {
SharedPreferences.setMockInitialValues({
'compra_premium_v1': true,
claveUltimaEmisora:
'{"uuid":"uuid-premium","nombre":"Premium",'
'"url":"https://premium.example/stream"}',
});
final handler = crearHandler();
final items = await handler.getChildren(AudioService.recentRootId);
expect(items, hasLength(1));
expect(items.single.id, 'emisora:uuid-premium');
});
});
group('la raiz gratuita esta LOCALIZADA — hallazgo 4', () {
test('en un motor headless en ingles la unica carpeta que un revisor de '
'Play ve NO sale en castellano', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
final raiz = await handler.getChildren(AudioService.browsableRootId);
expect(raiz, hasLength(1));
expect(
raiz.single.title,
lookupAppLocalizations(const Locale('en')).autoCarpetaEscuchar,
reason:
'raiz(premium: false) is 100% of the browse tree a Play reviewer '
'ever sees, on a device in any of the 13 shipped locales — the '
'hardcoded-Spanish car-label convention stops being defensible '
'once one label IS the whole free root',
);
expect(raiz.single.title, isNot('Escuchar'));
});
test('en castellano sigue diciendo Escuchar', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
final handler = crearHandler();
final raiz = await handler.getChildren(AudioService.browsableRootId);
expect(raiz.single.title, 'Escuchar');
});
});
group('botones de salto — item 14', () {
test('free: el salto CICLA dentro del set gratuito en vez de no hacer '
'nada', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
});
test('free: el salto hacia atrás envuelve al final del set', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
await handler.skipToPrevious();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
});
test('free: los botones se SIGUEN anunciando (un boton que funciona es '
'mejor UX que un hueco, y controls/systemActions se construyen en un '
'listener sincrono que no puede await-ear prefs)', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
// `controls`/`systemActions` are only rebuilt from a real player event,
// so the double has to emit one for this assertion to mean anything.
guion.ultimoReproductor!.emitir(
PlayerState(true, ProcessingState.ready),
);
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.systemActions, contains(MediaAction.skipToNext));
expect(estado.systemActions, contains(MediaAction.skipToPrevious));
});
});
}
/// Cuenta los round trips nativos que la raíz del árbol dispara.
class _FuenteMusicaLocalEspia implements FuenteMusicaLocalAuto {
int llamadasEstadoCarpeta = 0;
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async {
llamadasEstadoCarpeta++;
return EstadoCarpetaLocal.configurada;
}
@override
Future<List<NodoLocal>> hijos(String documentId) async => const [];
@override
Future<String?> uriContenidoDePista(String documentId) async => null;
@override
Future<Map<String, MetadatosPista>> metadatosDe(
List<String> documentIds,
) async => const {};
}
/// Misma forma que el doble de `servicio_audio_transporte_test.dart`.
class _GuionReproductor {
int llamadasPlay = 0;
final urlsSolicitadas = <String>[];
/// The handler rebuilds its player on every source change, so a test that
/// needs to drive player events has to reach the LATEST instance.
_ReproductorFalso? ultimoReproductor;
}
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
void emitir(PlayerState estado) => _estados.add(estado);
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.urlsSolicitadas.add(url);
return null;
}
@override
Future<void> play() async {
_guion.llamadasPlay++;
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -0,0 +1,618 @@
import 'dart:async';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Contexto de reproducción en el HANDLER — «al conectar el coche, siguiente/
/// anterior ya no recuerdan en qué lista estaba».
///
/// El handler es el único que existe en los dos motores (el del móvil y el
/// headless que levanta Android Auto sin Activity), así que es él quien tiene
/// que escribir y leer el contexto. Lo hace por un PUERTO inyectado en
/// `registrarHandler`, exactamente igual que el flag on/off del ecualizador:
/// `servicio_audio.dart` no importa `shared_preferences` ni conoce
/// `EstadoRadio`, que en el coche no llega a construirse nunca.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
late _PuertoContexto puerto;
setUp(() {
guion = _GuionReproductor();
puerto = _PuertoContexto();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
// `_fuenteNavegacionGlobal` es global de módulo: se deja siempre en una
// fuente vacía para que un test no herede las listas del anterior.
registrarFuenteNavegacion(_FuenteFalsa());
});
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
Emisora favorita(String uuid, String grupo) => Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
final rock1 = favorita('rock1', 'g-rock');
final rock2 = favorita('rock2', 'g-rock');
final rock3 = favorita('rock3', 'g-rock');
final jazz1 = favorita('jazz1', 'g-jazz');
final jazz2 = favorita('jazz2', 'g-jazz');
const gruposVivos = [
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
];
PluriWaveAudioHandler handlerConPuerto() {
final handler = crearHandler();
registrarHandler(
handler,
leerContextoSalto: puerto.leer,
guardarContextoSalto: puerto.guardar,
);
return handler;
}
Future<void> premium() async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
}
group('escritura — el contexto se fija en TODO camino que arranca una '
'emisora, no solo en el del árbol del coche', () {
setUp(premium);
test('playFromMediaId (toque en el árbol del coche)', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:rock1');
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
});
test('playFromSearch (voz)', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromSearch('jazz1');
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.favoritos());
});
test('playMediaItem (el camino del móvil: ServicioAudio.reproducir)',
() async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock2, l10n: textos()));
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
});
test('una PISTA LOCAL no escribe contexto de emisora: su cola es otra '
'cosa y pisarlo dejaría la radio recorriendo una lista ajena',
() async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(
const MediaItem(
id: 'content://media/documents/pista-1',
title: 'Pista local',
),
);
await pumpEventQueue();
expect(puerto.guardados, isEmpty);
});
});
group('lectura — el contexto recordado sobrevive al reinicio del proceso',
() {
setUp(premium);
test('la fuente aún no está registrada cuando arranca la emisora (carrera '
'real del bind en frío): el salto usa el contexto de la sesión '
'anterior en vez de morir', () async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
// Bind en frío: `main.dart` todavía no ha llamado a
// registrarFuenteNavegacion cuando el coche pide reproducir.
registrarFuenteNavegacion(_FuenteFalsa());
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
// La fuente llega después, ya con las listas cargadas.
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
expect(puerto.lecturas, greaterThan(0));
});
test('el contexto recordado MANDA sobre la rederivación: el registro de '
'favoritos perdió el grupo, y aun así se recorre el grupo', () async {
// Escenario real: el snapshot que empuja el móvil llega antes de que
// los grupos estén cargados, así que `favoritos()` reporta
// «sin asignar» un rato. Sin memoria, el salto se ensancha a todos los
// favoritos justo en mitad del trayecto.
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
});
});
group('degradación del contexto recordado', () {
setUp(premium);
test('el grupo recordado fue BORRADO -> se recorren todos los favoritos',
() async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [
favorita('rock1', GrupoFavoritos.sinAsignarId),
jazz1,
jazz2,
],
grupos: const [
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, jazz1.url);
});
test('el grupo SIGUE VIVO pero la emisora se salió de él -> se PERMANECE '
'en el grupo y suena su PRIMERA emisora', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2, jazz1], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
guion.urlsSolicitadas.clear();
// El móvil saca rock1 del grupo a media marcha: g-rock sigue existiendo
// y sigue teniendo emisoras, solo que ya no la que suena.
registrarFuenteNavegacion(
_FuenteFalsa(
// jazz1 va DELANTE de rock2 a propósito: si el contexto se
// ensanchara a todos los favoritos, «siguiente» sonaría jazz1.
favoritos: [
favorita('rock1', GrupoFavoritos.sinAsignarId),
jazz1,
rock2,
rock3,
],
grupos: gruposVivos,
),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
expect(
puerto.persistido,
const ContextoSalto.grupo('g-rock'),
reason: 'el grupo sobrevive, así que el contexto no se rederiva',
);
});
test('el grupo sigue vivo con UNA sola emisora -> el salto NO se ensancha '
'a todos los favoritos: se queda donde está', () async {
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [rock1, rock2, jazz1, jazz2],
grupos: gruposVivos,
),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
// rock2 sale del grupo: g-rock se queda solo con la emisora que suena.
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [
rock1,
favorita('rock2', GrupoFavoritos.sinAsignarId),
jazz1,
jazz2,
],
grupos: gruposVivos,
),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas, isEmpty);
expect(puerto.persistido, const ContextoSalto.grupo('g-rock'));
});
test('el grupo fue BORRADO y la emisora ya NO es favorita -> suena la '
'PRIMERA de los favoritos que queden', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [jazz1, jazz2],
grupos: const [
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, jazz1.url);
});
test('no queda ningún favorito y la emisora no está en ninguna lista -> '
'no-op silencioso, nunca un salto arbitrario', () async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(_FuenteFalsa(grupos: gruposVivos));
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas, isEmpty);
});
});
group('tier gratuito', () {
test('la lista de salto queda CONGELADA en el orden curado: que cambie '
'`ultima_emisora_v1` a media marcha no reordena el recorrido, así '
'que «anterior» vuelve a deshacer «siguiente»', () async {
// `resolverEmisorasDestacadas` se recompone como
// `[última reproducida, ...curadas]`, así que la lista SE REORDENA SOLA
// según suena cada emisora. Recorrerla en vivo hacía que «anterior»
// dejara de ser el inverso de «siguiente» a mitad de trayecto.
final kexp = emisorasDestacadas[3];
final siguienteCurada = emisorasDestacadas[4];
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"${kexp.uuid}","nombre":"${kexp.nombre}",'
'"url":"${kexp.url}"}',
});
final handler = handlerConPuerto();
// Fija la divergencia: la lista VIVA con `ultima` = KEXP empieza por
// KEXP, así que recorrerla daría la primera curada. Sin esta línea el
// test de abajo pasaría también con el comportamiento antiguo.
final enVivo = await resolverEmisorasDestacadas();
expect(
emisoraVecina(kexp, enVivo, haciaAtras: false)?.url,
emisorasDestacadas.first.url,
);
await handler.playFromMediaId('emisora:${kexp.uuid}');
await pumpEventQueue();
await handler.skipToNext();
await pumpEventQueue();
expect(
guion.urlsSolicitadas.last,
siguienteCurada.url,
reason:
'en vivo la lista sería [KEXP, ...resto], y «siguiente» se iría a '
'la primera curada en vez de a la que va detrás de KEXP',
);
// El móvil (vivo) persiste la emisora que acaba de sonar.
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
claveUltimaEmisora,
'{"uuid":"${siguienteCurada.uuid}",'
'"nombre":"${siguienteCurada.nombre}",'
'"url":"${siguienteCurada.url}"}',
);
await handler.skipToPrevious();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, kexp.url);
});
test('una emisora ajena al set curado se ANTEPONE al recorrido congelado, '
'para que ningún botón quede muerto', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"ajena","nombre":"Ajena",'
'"url":"https://example.com/ajena"}',
});
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:ajena');
await pumpEventQueue();
expect(
puerto.guardados.last,
ContextoSalto.destacadas([
'ajena',
...emisorasDestacadas.map((e) => e.uuid),
]),
);
await handler.skipToPrevious();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
});
test('un contexto PREMIUM recordado NO se camina en tier gratuito: una '
'cuenta degradada no sigue recorriendo el catálogo', () async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
expect(
puerto.guardados.last.tipo,
TipoContextoSalto.destacadas,
reason: 'el contexto de un usuario free es SIEMPRE el set gratuito',
);
});
test('free: aunque el grupo de favoritos siga VIVO, el recorrido no entra '
'en él tampoco en el segundo salto', () async {
// La nueva caída «grupo vivo -> su primera emisora» no puede convertirse
// en una puerta trasera al contenido de pago.
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2, rock3], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas, [
emisorasDestacadas[1].url,
emisorasDestacadas[2].url,
]);
});
});
group('robustez del puerto', () {
setUp(premium);
test('sin puerto registrado el salto sigue funcionando por rederivación '
'(un test de widgets, o el arranque antes de main.dart)', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = crearHandler();
registrarHandler(handler);
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
});
test('un puerto que revienta degrada a rederivación en vez de dejar al '
'conductor sin botones', () async {
puerto.explota = true;
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
});
});
}
/// Doble del puerto de persistencia que `main.dart` ata a
/// `contexto_reproduccion.dart`.
class _PuertoContexto {
ContextoSalto? persistido;
final guardados = <ContextoSalto>[];
int lecturas = 0;
bool explota = false;
Future<ContextoSalto?> leer() async {
lecturas++;
if (explota) throw StateError('disco ilegible');
return persistido;
}
Future<void> guardar(ContextoSalto contexto) async {
if (explota) throw StateError('disco de solo lectura');
guardados.add(contexto);
persistido = contexto;
}
}
class _FuenteFalsa implements FuenteEmisorasAuto {
_FuenteFalsa({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) : _favoritos = favoritos ?? const [],
_misEmisoras = misEmisoras ?? const [],
_todas = todas ?? const [],
_grupos = grupos ?? const [];
final List<Emisora> _favoritos;
final List<Emisora> _misEmisoras;
final List<Emisora> _todas;
final List<GrupoFavoritos> _grupos;
@override
Future<List<Emisora>> favoritos() async => _favoritos;
@override
Future<List<Emisora>> misEmisoras() async => _misEmisoras;
@override
Future<List<Emisora>> todas() async => _todas;
@override
Future<List<GrupoFavoritos>> grupos() async => _grupos;
@override
Future<Emisora?> porUuid(String uuid) async {
for (final lista in [_favoritos, _misEmisoras, _todas]) {
for (final e in lista) {
if (e.uuid == uuid) return e;
}
}
for (final e in await resolverEmisorasDestacadas()) {
if (e.uuid == uuid) return e;
}
return null;
}
@override
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {}
}
/// Misma forma que el doble de `servicio_audio_auto_free_test.dart`.
class _GuionReproductor {
final urlsSolicitadas = <String>[];
_ReproductorFalso? ultimoReproductor;
}
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.urlsSolicitadas.add(url);
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -0,0 +1,374 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
///
/// Reported by the owner: the toggle behaves correctly from the phone screen
/// but from the car it «sometimes sounds like a doubled equalization and
/// sometimes does nothing».
///
/// Three independent causes, one per group below:
///
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
/// `eq-estado-unico` item A); the preset never did. On a headless Android
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
/// to push the real preset — enabling the equalizer from the car applied
/// FLAT.
///
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
/// the preset's gains, so the native effect was re-activated carrying
/// whatever band levels the previous preset had left in it and only
/// afterwards were the intended ones written, band by band. That audible
/// gap is the «doubled equalization».
///
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
/// change and never restored it until the fresh player attached. Every
/// native EQ path is gated on that flag, so a car toggle landing inside
/// the window flipped the icon and the flag but never touched the audio —
/// the «does nothing» — and the EQ button itself vanished from the car's
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
/// `const []` when unavailable) and came back seconds later.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductorEq guion;
setUp(() {
guion = _GuionReproductorEq();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
});
group('A — the preset is seeded from disk on a headless engine', () {
test('registrarHandler consults the injected preset port exactly once '
'and seeds the handler with it, with no widget tree', () async {
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
handler,
leerPresetPersistido: () async {
lecturas++;
return PresetEcualizador.jazz;
},
);
await pumpEventQueue();
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.presetActual,
PresetEcualizador.jazz,
reason:
'from the car the handler is the ONLY owner of the preset — '
'nothing else ever pushes one on a headless engine',
);
});
test('a read failure leaves the historical default instead of '
'propagating', () async {
final handler = crearHandler();
registrarHandler(
handler,
leerPresetPersistido: () async => throw StateError('sin disco'),
);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.flat);
});
test('without a preset port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
await handler.aplicarPreset(PresetEcualizador.rock);
registrarHandler(handler);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.rock);
});
test('a preset already chosen while the disk read was in flight WINS — '
'seeding never clobbers a live choice', () async {
final handler = crearHandler();
final lectura = Completer<PresetEcualizador?>();
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
// pushes it while the seed's disk read is still pending.
await handler.aplicarPreset(PresetEcualizador.pop);
lectura.complete(PresetEcualizador.jazz);
await pumpEventQueue();
expect(
handler.presetActual,
PresetEcualizador.pop,
reason:
'the seed exists to fill a VOID, not to overrule the richer '
'per-station/per-device preset the phone UI resolves',
);
});
});
group('B — the preset is pushed BEFORE the effect is enabled', () {
test('enabling applies the gains first and only then flips the native '
'effect on', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: true),
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'enabling first would re-activate the native Equalizer carrying '
'the PREVIOUS preset gains, which is the doubled equalization '
'the owner hears',
);
});
test('disabling only flips the effect off — the band gains are NOT '
'reset', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: false),
[PasoEcualizador.habilitacion],
reason:
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
'the effect and RETAINS its band levels, and the enable path '
'rewrites them before re-enabling anyway — zeroing them would be '
'one native round trip per band for no audible difference',
);
});
test('the real toggle path executes those steps IN THAT ORDER', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.pasosEcualizadorEjecutados,
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'the ORDER is the fix; asserting only that both happened would '
'stay green against the exact bug being fixed',
);
});
test('the real disable path executes only the habilitacion step', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(false);
expect(handler.pasosEcualizadorEjecutados, [
PasoEcualizador.habilitacion,
]);
});
});
group('C — a station change no longer drops the equalizer', () {
test('once the EQ was available, no state published across a station '
'change and a car toggle has zero custom actions', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
// The EQ action is on the car's now-playing screen before the station
// changes — that is the state the driver is looking at.
await handler.setEcualizadorActivo(true);
final acciones = <int>[];
final sub = handler.playbackState.listen(
(estado) => acciones.add(
estado.controls.where((c) => c.customAction != null).length,
),
);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
// The car tap that used to land inside the window `_recrearPlayer`
// opened. It republishes the controls from `_eqDisponible`, so a flag
// reset to `false` shows up here as an EQ button that disappeared.
await handler.customAction(accionEqToggle);
await sub.cancel();
expect(
acciones,
isNotEmpty,
reason: 'the station change must publish at least one state',
);
expect(
acciones.every((n) => n > 0),
isTrue,
reason:
'the EQ button vanished and reappeared on every station change '
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
'a DEVICE property and does not change with the station. Got '
'$acciones',
);
});
test('the availability flag survives the player rebuild, so a car toggle '
'inside the window still reaches the native effect', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
expect(
handler.ecualizadorDisponible,
isTrue,
reason:
'this is the flag every native EQ path is gated on; false here '
'is exactly the reported «does nothing»',
);
});
});
group('D — a failed native call is traced and never lies', () {
test('a throwing setEnabled is traced instead of swallowed', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.fallosNativosEcualizador,
greaterThan(0),
reason:
'the silent `catch (_) {}` made a dead native equalizer '
'indistinguishable from a working one in a car logcat',
);
});
test('a failed on/off call leaves the published state honest instead of '
'claiming a state the audio does not have', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
handler.ecualizadorActivo,
isFalse,
reason:
'the native effect refused, so the car icon must not read "on" '
'over audio that is not equalized',
);
});
test('a failed on/off call is not persisted', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
isEmpty,
reason:
'persisting a state the device rejected would resurrect it on '
'the next engine start',
);
});
});
}
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
/// device whose native `Equalizer` effect refuses the call. Nothing else is
/// overridden, so the rest of the handler runs unchanged.
class _EcualizadorQueFalla extends AndroidEqualizer {
@override
Future<void> setEnabled(bool enabled) async {
throw StateError('el efecto nativo rechazo la llamada');
}
}
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
/// the handler builds (it rebuilds its player on every source change).
class _GuionReproductorEq {
int llamadasSetUrl = 0;
_ReproductorFalsoEq? ultimoReproductor;
}
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
/// station change can be driven under `flutter test`. Mirrors the double in
/// `servicio_audio_transporte_test.dart`.
class _ReproductorFalsoEq extends AudioPlayer {
_ReproductorFalsoEq(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductorEq _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -0,0 +1,478 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState;
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-estado-unico — the equalizer's on/off flag gets a SINGLE owner.
///
/// Reported bug: «alguna emisora parece que esta con la ecualizacion activada
/// (suena muy alto) pero con el boton desactivado», and «pulsando sobre el
/// boton de ecualizar en Android Auto tampoco activaba ni desactivaba».
///
/// The flag used to live in three independent copies — the handler's
/// hardcoded `_ecualizadorActivo = true`, `EstadoEcualizador._activo`, and
/// SharedPreferences — and the persisted value only ever reached the handler
/// through `EstadoEcualizador.cargarPersistido()`, which a headless Android
/// Auto engine (no Activity, no Provider tree, no `EstadoRadio._init`) never
/// runs. So in the car the handler played with the equalizer forced ON while
/// disk and the phone UI both said OFF.
///
/// NOTE on testability: the long-standing comment in `servicio_audio.dart`
/// claiming `PluriWaveAudioHandler` "cannot be instantiated in a unit test
/// (a real just_audio.AudioPlayer needs platform MethodChannels)" is WRONG
/// as of just_audio 0.9.46 — `AudioPlayer`'s constructor resolves its
/// platform lazily and never becomes active without a `setUrl`, so the
/// handler constructs fine here and every EQ path that does not touch the
/// native effect is directly exercisable. That is what the dispatch tests
/// below rely on; only the native `setEnabled`/`setGain` calls stay out of
/// reach (they sit behind `_eqDisponible`, which is `false` off-device).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
group('estadoEqInicial (A — seed the handler from disk on every engine)', () {
test('adopts the persisted value when there is one', () {
expect(estadoEqInicial(persistido: false), isFalse);
expect(estadoEqInicial(persistido: true), isTrue);
});
test('defaults to ON only when nothing was ever persisted', () {
expect(
estadoEqInicial(persistido: null),
isTrue,
reason: 'a first install keeps the historical default (EQ on)',
);
});
});
group('registrarHandler (A — seeding)', () {
test('consults the injected read port exactly once and seeds the handler '
'with the persisted value', () async {
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
handler,
leerEqActivoPersistido: () async {
lecturas++;
return false;
},
);
await Future<void>.delayed(Duration.zero);
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.ecualizadorActivo,
isFalse,
reason: 'the handler must adopt what the phone UI persisted',
);
});
test('a read failure leaves the handler on the safe default instead of '
'propagating', () async {
final handler = crearHandler();
registrarHandler(
handler,
leerEqActivoPersistido: () async => throw StateError('sin disco'),
);
await Future<void>.delayed(Duration.zero);
expect(handler.ecualizadorActivo, isTrue);
});
test('without a read port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
await handler.setEcualizadorActivo(false);
registrarHandler(handler);
await Future<void>.delayed(Duration.zero);
expect(handler.ecualizadorActivo, isFalse);
});
});
/// A, CONSTRUCTION-WINDOW half. `_eqActivoPersistido` (the module-level
/// cache behind `_ecualizadorActivo = estadoEqInicial(persistido: ...)`)
/// had zero coverage on BOTH sides: replacing that initialiser with the old
/// hardcoded `= true` left the suite green, and so did deleting the
/// `_eqActivoPersistido = activo` write in `_aplicarEcualizadorActivo`.
///
/// The window it closes is real: `AudioService.init` builds the handler
/// through its `builder` callback and only AFTER that future resolves does
/// `main.dart` reach `registrarHandler`. A car tap landing inside that
/// window would otherwise hit a handler whose flag had never seen disk.
group('A (construction window) — a handler built after a disk read', () {
test('a handler constructed AFTER a read port has already answered '
'starts from the persisted value, not from a hardcoded default',
() async {
// One engine does the read `registrarHandler` performs in main.dart.
final primero = crearHandler();
// Pin the module cache to the OPPOSITE value first. Without this the
// test passes for the wrong reason: whatever ran before may already
// have left the cache on `false`, so deleting the disk→cache write in
// `_sembrarEcualizadorDesdeDisco` would still leave this green. Seeding
// does NOT write the cache (`_aplicarEcualizadorActivo` returns before
// it when `persistir: false`), so after this pin that write is the only
// path that can bring the cache back down to `false`.
await primero.setEcualizadorActivo(true);
registrarHandler(primero, leerEqActivoPersistido: () async => false);
await Future<void>.delayed(Duration.zero);
expect(primero.ecualizadorActivo, isFalse);
// Now the construction window: a handler built by `AudioService.init`'s
// builder, with no port of its own yet.
final segundo = crearHandler();
expect(
segundo.ecualizadorActivo,
isFalse,
reason:
'a car tap landing before registrarHandler must not find the '
'equalizer forced on while disk says off',
);
});
test('the cache follows what the handler itself writes, in both '
'directions', () async {
final handler = crearHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(false);
expect(
crearHandler().ecualizadorActivo,
isFalse,
reason:
'the write side of the cache: a toggle must be visible to the '
'next handler built on this engine',
);
await handler.setEcualizadorActivo(true);
expect(crearHandler().ecualizadorActivo, isTrue);
});
});
group('B — the handler persists its OWN toggle', () {
test('an eq toggle writes through the injected port even with no '
'EstadoEcualizador in play', () async {
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
await handler.setEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
[false, true],
reason:
'a car/notification toggle must survive a process restart '
'without any UI object existing',
);
});
test('seeding from disk does NOT write back to disk', () async {
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
leerEqActivoPersistido: () async => false,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
await Future<void>.delayed(Duration.zero);
expect(handler.ecualizadorActivo, isFalse);
expect(escrituras, isEmpty);
});
test('a failing write port never breaks the toggle', () async {
final handler = crearHandler();
registrarHandler(
handler,
guardarEqActivoPersistido: (_) async => throw StateError('disco lleno'),
);
await handler.setEcualizadorActivo(false);
expect(handler.ecualizadorActivo, isFalse);
});
});
group('decidirToggleEq (C — the customAction decision)', () {
test('flips the current value', () {
expect(
decidirToggleEq(activoActual: true, eqDisponible: true).nuevoActivo,
isFalse,
);
expect(
decidirToggleEq(activoActual: false, eqDisponible: true).nuevoActivo,
isTrue,
);
});
test('a native call is required only when the effect is attached', () {
expect(
decidirToggleEq(
activoActual: true,
eqDisponible: true,
).requiereLlamadaNativa,
isTrue,
);
expect(
decidirToggleEq(
activoActual: true,
eqDisponible: false,
).requiereLlamadaNativa,
isFalse,
reason:
'with no native Equalizer effect the flag still flips, but '
'nothing is pushed to the platform',
);
});
test('the flag still flips with no native effect — the car button must '
'never look inert', () {
expect(
decidirToggleEq(activoActual: false, eqDisponible: false).nuevoActivo,
isTrue,
);
});
});
group('customAction dispatch (C — zero coverage before this)', () {
test('the accionEqToggle literal routes through decidirToggleEq', () async {
final handler = crearHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(true);
await handler.customAction(accionEqToggle);
expect(handler.ecualizadorActivo, isFalse);
await handler.customAction(accionEqToggle);
expect(handler.ecualizadorActivo, isTrue);
});
test('a car toggle persists through the same write port as a phone '
'toggle', () async {
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
// Explicit starting state. A fresh handler seeds `_ecualizadorActivo`
// from the module-level `_eqActivoPersistido` cache, which any earlier
// test in this file leaves at whatever it last wrote. This assertion
// is about what the TOGGLE does, not about what the previous test
// happened to leave behind — without these two lines, simply
// reordering the tests silently flips the expectation to `[true]`.
await handler.setEcualizadorActivo(true);
escrituras.clear();
await handler.customAction(accionEqToggle);
expect(escrituras, [false]);
});
test('an unknown custom action is a silent no-op', () async {
final handler = crearHandler();
registrarHandler(handler);
final antes = handler.ecualizadorActivo;
await handler.customAction('accion.inexistente');
expect(handler.ecualizadorActivo, antes);
});
});
group('debeReasertarEcualizadorNativo (D — re-assert on activation)', () {
test('an idle -> active transition with the effect attached re-asserts', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.ready,
reproductorActivoAntes: false,
eqDisponible: true,
),
isTrue,
reason:
"just_audio's AudioEffect.setEnabled only reaches the platform "
'while the player is active, so a toggle made while stopped '
'never landed natively',
);
});
test('staying active does not re-assert on every event', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.ready,
reproductorActivoAntes: true,
eqDisponible: true,
),
isFalse,
);
});
test('going idle does not re-assert', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.idle,
reproductorActivoAntes: true,
eqDisponible: true,
),
isFalse,
);
});
test('no attached effect never re-asserts', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.ready,
reproductorActivoAntes: false,
eqDisponible: false,
),
isFalse,
);
});
test('buffering/loading/completed already count as active', () {
for (final estado in [
ProcessingState.loading,
ProcessingState.buffering,
ProcessingState.completed,
]) {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: estado,
reproductorActivoAntes: false,
eqDisponible: true,
),
isTrue,
reason:
'$estado is a non-idle state, i.e. the platform player is '
'attached and accepts effect calls',
);
}
});
});
/// D, WIRING half. Everything above this group tests the pure
/// [PluriWaveAudioHandler.debeReasertarEcualizadorNativo] predicate and
/// nothing else: deleting the `playerStateStream` listener's whole re-assert
/// block — the `if (debeReasertar...) unawaited(_reasertarEcualizadorNativo())`
/// call, the `_reproductorActivo = proc != ProcessingState.idle` edge
/// tracking — left the suite green. That is the SAME producer-only hole that
/// let a dead Android Auto EQ button ship, so it gets closed here rather than
/// re-tested at the predicate.
///
/// [PluriWaveAudioHandler.manejarEstadoPlayer] IS the listener body — the
/// same method `playerStateStream.listen` is subscribed to — so these
/// drive the real handler through real player-state transitions.
group('D (wiring) — the playerState idle -> active edge', () {
PlayerState estado(ProcessingState proc, {bool playing = false}) =>
PlayerState(playing, proc);
test('the first non-idle event re-asserts the native effect exactly '
'once, and staying active never re-asserts again', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
expect(handler.reasercionesEcualizador, 0);
handler.manejarEstadoPlayer(estado(ProcessingState.loading));
expect(
handler.reasercionesEcualizador,
1,
reason:
"just_audio's AudioEffect.setEnabled is a no-op while the "
'platform player is detached, so a toggle made while stopped '
'only lands on this edge',
);
handler.manejarEstadoPlayer(estado(ProcessingState.buffering));
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
handler.manejarEstadoPlayer(estado(ProcessingState.completed));
expect(
handler.reasercionesEcualizador,
1,
reason:
'the player emits many events while active; re-asserting on '
'each one would be a native call storm',
);
});
test('going idle re-arms the edge, so stop + play re-asserts again — '
'this is the `_reproductorActivo = proc != idle` line', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
expect(handler.reasercionesEcualizador, 1);
handler.manejarEstadoPlayer(estado(ProcessingState.idle));
expect(
handler.reasercionesEcualizador,
1,
reason: 'going idle itself never re-asserts',
);
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
expect(
handler.reasercionesEcualizador,
2,
reason:
'without the edge-tracking assignment the flag would stay true '
'and the toggle made while stopped would never land natively',
);
});
test('with no native effect attached nothing is ever re-asserted', () async {
final handler = crearHandler();
registrarHandler(handler);
// `_eqDisponible` is false off-device, which is also the real
// "device has no Equalizer effect" case.
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
handler.manejarEstadoPlayer(estado(ProcessingState.idle));
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
expect(handler.reasercionesEcualizador, 0);
});
});
group('F — the EQ re-push must not rewind the car progress bar', () {
test('the EQ controls re-push refreshes updatePosition from the '
'player', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.playbackState.add(
handler.playbackState.value.copyWith(
updatePosition: const Duration(minutes: 3),
),
);
await handler.setEcualizadorActivo(false);
expect(
handler.playbackState.value.updatePosition,
handler.posicionActual,
reason:
'copyWith stamps a fresh updateTime but keeps the OLD '
'updatePosition, so an EQ tap told the car "you are at 3:00, as '
'of right now" and the bar snapped backwards',
);
});
});
}
@@ -0,0 +1,201 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// `mapearGananciaNativa` — the hand-off from the app's ±12 dB slider to the
/// device's native `Equalizer`, whose capability is reported as
/// `AndroidEqualizerParameters.min/maxDecibels`
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
///
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
/// slider against its own end of the native range, so `+6` on a device
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
/// are already the SAME unit, so that multiplication was a unit error:
///
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
/// decibels", and its Android bridge does `setBandLevel(band,
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
/// bound on the control, not a scale to normalise into.
/// * The app makes the user a decibel promise in three places at once: the
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
/// a lie on every device whose range is not exactly ±12.
/// * Presets are persisted and EXPORTED as those same raw slider dB
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
/// restored on a wider-range phone showed identical numbers and played
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
/// rather than merely its depth.
///
/// So: the number the user reads is the number the device is asked for. The
/// native range only CLAMPS it.
///
/// What this deliberately KEEPS from the previous contract — every invariant
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
/// positive reported range, turning a FLAT preset into a boost again), the
/// sign of the user's intent is never inverted, the result never escapes the
/// native range, a device with no headroom above unity can never boost, and a
/// zero-width range collapses to 0.
void main() {
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
test('symmetric range (the common case) is unchanged', () {
expect(
mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15),
0,
);
});
test('asymmetric range no longer boosts a FLAT preset', () {
// The reported symptom: on a device reporting [-12, +19] the old
// midpoint mapping turned every 0 dB band into +3.5 dB of real boost.
expect(
mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19),
0,
reason: 'a FLAT preset must be inaudible, on every device',
);
});
test('a wholly positive range still cannot boost a FLAT preset', () {
// This is precisely why the mapping cannot be a plain
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
// bring the «suena muy alto» bug straight back. The clamp window has
// to be widened so that it always contains 0.
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
});
test('a wholly negative range still cannot cut a FLAT preset', () {
expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0);
});
});
group('mapearGananciaNativa — the slider dB reach the device literally', () {
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the
// device's headroom. The slider says "12.0dB" and the accessibility
// label says "12.0 decibels", so 12 dB is what the device must be
// asked for. The 7 dB of extra hardware headroom is unreachable by
// design until the slider itself is widened and says so.
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
});
test('-12 dB is delivered as -12 dB', () {
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
});
test('values beyond the slider scale clamp to the slider limit', () {
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
// The slider scale is the first bound; the device range is the second.
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
});
});
group('mapearGananciaNativa — the label is the value the device gets', () {
test('+6 dB on a wide-range device is +6 dB, never 10', () {
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
// half of the positive headroom". A slider reading "6.0dB" that
// produced +10 dB of real boost is exactly what made a restored backup
// sound different on a different phone.
expect(
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
closeTo(6, 1e-9),
);
});
test('-6 dB on that same device is -6 dB', () {
expect(
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
closeTo(-6, 1e-9),
);
});
test('the six factory presets keep their shape on an asymmetric device', () {
// Jazz, authored in true dB before any scaling existed. Under the old
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
// different tonal curve, not merely a louder one.
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
final entregado = jazz
.map(
(db) =>
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
)
.toList();
expect(entregado, jazz);
});
test('the sign of the user intent is never inverted', () {
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -12,
maxDecibels: 19,
);
expect(
nativo.sign,
db.sign,
reason: 'a cut must never become a boost ($db dB -> $nativo)',
);
}
});
});
group('mapearGananciaNativa — a device narrower than the slider', () {
test('a request that fits is still delivered literally', () {
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
// so a modest device silently under-delivered every request too.
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
});
test('a request beyond the device range clamps to the device limit', () {
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
});
test('a very narrow device still gets a sane, in-range value', () {
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -1.5,
maxDecibels: 1.5,
);
expect(nativo, greaterThanOrEqualTo(-1.5));
expect(nativo, lessThanOrEqualTo(1.5));
expect(nativo.sign, db.sign);
}
});
});
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
test('a device with no headroom above unity can never boost', () {
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0);
});
test('a cut the device could honour exactly is not over-delivered', () {
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
// range on a request for -12 dB. The user asked for -12; -12 is
// representable here, so -12 is what is sent.
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
});
test('a zero-width range collapses everything to 0', () {
expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0);
expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0);
});
test('the result never escapes the native range', () {
for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -3,
maxDecibels: 19,
);
expect(nativo, greaterThanOrEqualTo(-3));
expect(nativo, lessThanOrEqualTo(19));
}
});
});
}
+192 -7
View File
@@ -1,5 +1,12 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Android Auto play-path backstop (design.md ADR-4, android-auto-media
/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station
@@ -13,26 +20,204 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
/// handler's dispatch methods delegate to (mirrors `mapearEstadoProceso`
/// and every other pure helper in this file).
void main() {
test('free tier: bloquea cualquier cambio de emisora/salto', () {
expect(debeBloquearCambioDeEmisora(premium: false), isTrue);
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
// fix/auto-quality-guidelines item 11: the gate is CONTENT-scoped now.
// Blocking every switch for the free tier is what made the car surface
// useless for the only tier a Play reviewer can be in.
test('free tier: bloquea una emisora del catálogo premium', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
isTrue,
);
});
test('free tier: NO bloquea una emisora del set gratuito', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
isFalse,
);
});
test('premium: nunca bloquea', () {
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
isFalse,
);
});
group('notificarDesbloqueoAuto / registrarNotificacionDesbloqueoAuto', () {
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
/// transición free -> premium». Android Auto cachea la raíz, así que
/// CUALQUIER momento en el que el árbol pasa a poder mostrar algo que
/// antes no podía tiene que invalidarla — muy en particular, que aparezca
/// por fin una Activity (y con ella el handler nativo del canal
/// `pluriwave/file_actions`) o que el usuario acabe de elegir carpeta.
/// De ahí el nombre neutro.
group('invalidarArbolAuto / registrarInvalidacionArbolAuto', () {
test('sin hook registrado, es un no-op seguro', () {
expect(() => notificarDesbloqueoAuto(), returnsNormally);
expect(() => invalidarArbolAuto(), returnsNormally);
});
test('invoca el hook registrado exactamente una vez por llamada', () {
var llamadas = 0;
registrarNotificacionDesbloqueoAuto(() => llamadas++);
registrarInvalidacionArbolAuto(() => llamadas++);
notificarDesbloqueoAuto();
invalidarArbolAuto();
expect(llamadas, 1);
});
test('registrarHandler conecta la invalidación al handler: una llamada '
'notifica la raíz Y Música Local', () async {
final handler = crearHandler();
registrarHandler(handler);
final raiz = <Map<String, dynamic>>[];
final local = <Map<String, dynamic>>[];
final subRaiz = handler
.subscribeToChildren(AudioService.browsableRootId)
.listen(raiz.add);
final subLocal = handler
.subscribeToChildren(ConstructorArbolAuto.idMusicaLocal)
.listen(local.add);
invalidarArbolAuto();
await Future<void>.delayed(Duration.zero);
await subRaiz.cancel();
await subLocal.cancel();
expect(raiz, hasLength(1));
expect(local, hasLength(1));
});
});
/// fix/android-auto-musica-local, item 5.
///
/// `subscribeToChildren` sembraba el `BehaviorSubject` con un mapa vacío.
/// El listener interno de `audio_service` se suscribe en cuanto el head
/// unit navega un id, recibe ESE valor semilla de inmediato y lo reenvía
/// como `notifyChildrenChanged` — o sea, el primer browse de cada id
/// provocaba un segundo `getChildren` espurio. En la raíz eso era un
/// SEGUNDO round trip de permisos justo en la ruta que ya estaba
/// fallando. Sin semilla no hay valor que reenviar, y la invalidación
/// explícita (`notificarHijosCambiaron`) sigue funcionando igual.
group('subscribeToChildren', () {
test('el sujeto arranca SIN valor: nada que reenviar en la primera '
'suscripción, así que no hay notifyChildrenChanged espurio', () {
final handler = crearHandler();
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
});
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
final handler = crearHandler();
expect(
identical(
handler.subscribeToChildren('musica_local'),
handler.subscribeToChildren('musica_local'),
),
isTrue,
);
expect(
identical(
handler.subscribeToChildren('musica_local'),
handler.subscribeToChildren('favoritos'),
),
isFalse,
);
});
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
() async {
final handler = crearHandler();
final stream = handler.subscribeToChildren('musica_local');
final recibidos = <Map<String, dynamic>>[];
final sub = stream.listen(recibidos.add);
handler.notificarHijosCambiaron('musica_local');
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(recibidos, hasLength(1));
});
});
/// fix/android-auto-musica-local — «no aparece la opción de reproducir
/// música local, no aparece ni el menú», con la compra PRO hecha.
///
/// La raíz decidía la existencia del nodo con un round trip de permisos
/// por `MethodChannel`. En el motor headless que Android Auto levanta sin
/// Activity ese canal no tiene handler, la llamada lanzaba
/// `MissingPluginException` y el nodo se omitía — y Android Auto CACHEA
/// la raíz, así que se quedaba fuera toda la sesión.
///
/// La pertenencia a la raíz ya no depende de poder contestar esa
/// pregunta: basta con que el estado NO sea [EstadoCarpetaLocal.noConfigurada].
group('getChildren(root): pertenencia de Música Local', () {
setUp(() {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
});
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
final handler = crearHandler();
final items = await handler.getChildren(AudioService.browsableRootId);
return items.map((i) => i.id).toList();
}
test(
'canalNoDisponible (motor sin Activity, pero el usuario SÍ eligió '
'carpeta): la raíz sigue ofreciendo Música Local',
() async {
expect(
await idsRaizCon(EstadoCarpetaLocal.canalNoDisponible),
contains(ConstructorArbolAuto.idMusicaLocal),
);
},
);
test('configurada: la raíz ofrece Música Local', () async {
expect(
await idsRaizCon(EstadoCarpetaLocal.configurada),
contains(ConstructorArbolAuto.idMusicaLocal),
);
});
test(
'noConfigurada (nunca se eligió carpeta, o el permiso está revocado '
'de verdad): la raíz sigue omitiendo Música Local',
() async {
expect(
await idsRaizCon(EstadoCarpetaLocal.noConfigurada),
isNot(contains(ConstructorArbolAuto.idMusicaLocal)),
);
},
);
});
}
/// Misma forma que `_FakeFuenteMusicaLocalAuto` en
/// `navegacion_auto_test.dart`, reducida a lo que esta suite necesita: solo
/// el estado de la carpeta decide la raíz.
class _FakeFuenteMusicaLocalGating implements FuenteMusicaLocalAuto {
_FakeFuenteMusicaLocalGating(this._estado);
final EstadoCarpetaLocal _estado;
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
@override
Future<List<NodoLocal>> hijos(String documentId) async => const [];
@override
Future<String?> uriContenidoDePista(String documentId) async => null;
@override
Future<Map<String, MetadatosPista>> metadatosDe(
List<String> documentIds,
) async => const {};
}
@@ -0,0 +1,736 @@
import 'dart:async';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// Android for Cars App Quality Guidelines — transport state machine.
///
/// Google Play returned "Approved with Issues" against version code 157:
/// «clicking on stop button makes the entire app useless». These tests drive
/// the REAL [PluriWaveAudioHandler] against a scripted [AudioPlayer] double
/// (installed through [PluriWaveAudioHandler.fabricaReproductorPrueba]) so
/// the published `playbackState` sequence — the only thing Android Auto ever
/// sees — can be asserted end to end.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('stop() durante cambios de fuente en vuelo (P0 — botón Stop)', () {
test(
'dos cambios encolados y un stop: el ultimo estado publicado es idle, '
'nunca vuelve a loading',
() async {
final handler = crearHandler();
final publicados = <AudioProcessingState>[];
final sub = handler.playbackState.listen(
(estado) => publicados.add(estado.processingState),
);
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://b', title: 'B'))
.catchError((_) {}),
);
await handler.stop();
// Drain both queued source changes: they must discover the stale
// revision WITHOUT ever publishing again.
await pumpEventQueue();
await sub.cancel();
expect(
publicados.last,
AudioProcessingState.idle,
reason:
'a stale queued source change must never rewrite `loading` over '
'the `idle` that stop() published — that is what leaves Android '
'Auto spinning forever on a dead session. Secuencia: $publicados',
);
},
);
});
group('pause() durante un cambio de fuente en vuelo (P0 — botón Pausa)', () {
test('la emisora NO arranca: _player.play() nunca se invoca', () async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
expect(
guion.llamadasSetUrl,
1,
reason: 'precondicion: el cambio de fuente esta en vuelo',
);
await handler.pause();
guion.completerSetUrl!.complete(null);
await pumpEventQueue();
expect(
guion.llamadasPlay,
0,
reason:
'the user pressed Pause while the station was loading — the load '
'finishing afterwards must never start playback behind their back',
);
});
test(
'y el coche no se queda en el spinner: el estado publicado sale de '
'loading',
() async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
await handler.pause();
guion.completerSetUrl!.complete(null);
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
isNot(AudioProcessingState.loading),
reason:
'withholding the play() must not leave the car showing the '
'spinner the load started with — nothing else will publish, '
'because the player never transitions',
);
expect(handler.playbackState.value.playing, isFalse);
},
);
});
group('Suelo de estado terminal (P0 — nunca un spinner eterno)', () {
setUp(() {
PluriWaveAudioHandler.vigilanciaTransitoria = const Duration(
milliseconds: 60,
);
});
tearDown(() {
PluriWaveAudioHandler.vigilanciaTransitoria =
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto;
});
test(
'un buffering publicado SIN carga viva cae a un estado terminal dentro '
'de la ventana',
() async {
final handler = crearHandler();
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.buffering),
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'precondicion: el coche esta viendo el spinner',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
handler.playbackState.value.processingState,
isIn(const [
AudioProcessingState.ready,
AudioProcessingState.idle,
AudioProcessingState.error,
]),
reason:
'the only exits from loading/buffering are player events that '
'.distinct() can swallow — without a floor the car spins '
'forever over a session nobody is driving',
);
},
);
test('una carga LEGITIMA en vuelo no se interrumpe', () async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
handler.playbackState.value.processingState,
AudioProcessingState.loading,
reason:
'the watchdog is a floor for a STALLED state machine, not a cap '
'on how long a slow station may take to open',
);
});
test(
'un mount estancado NO cae en un idle mudo: publica un motivo legible '
'(hallazgo 3)',
() async {
final handler = crearHandler();
// Exact shape of a stalled icecast mount: the socket opens, `setUrl`
// returns inside the timeout (so no TimeoutException and no
// PlayerException — `_esErrorDeRed` never fires and the reconnect
// machine is never entered), and then no data ever arrives. State
// sits at buffering with `_cambiosEnVuelo` already back to 0.
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.buffering),
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'a bare `idle` routes straight into `AudioService._stop()` '
'(audio_service.dart:1131-1135), so the driver got silence, a '
'dead session and no explanation. `error` keeps the session '
'alive and carries a message',
);
expect(
estado.errorMessage,
isNotNull,
reason:
'the floor must say something the driver can read and act on',
);
},
);
test(
'un rebuffer normal a mitad de emision NO se convierte en error '
'(regresion: el suelo miraba solo processingState)',
() async {
final handler = crearHandler();
// Established playback: the stream delivered audio and ExoPlayer
// reached `ready` while playing. This is what separates a re-buffer
// from a mount that never produced a byte.
handler.manejarEstadoPlayer(PlayerState(true, ProcessingState.ready));
// Ordinary mid-stream re-buffer: `bufferForPlaybackAfterRebuffer` is
// 5 s, so a tunnel or an LTE handover routinely holds this state for
// longer than the floor's window. ExoPlayer raised no error, so
// `_intentarReconexion` never ran and `reintentoPendiente` is false;
// `_cambiosEnVuelo` is already 0 because the non-blocking
// `_iniciarPlaySinBloquear` returned long ago.
handler.manejarEstadoPlayer(
PlayerState(true, ProcessingState.buffering),
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'precondicion: el reproductor esta rellenando el buffer',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason:
'a self-recovering re-buffer over live audio must never be '
'converted into a hard STATE_ERROR: the driver is in a tunnel, '
'not on a dead mount, and `_errorTerminal` latches so nothing '
'the player emits afterwards could undo it',
);
expect(
estado.playing,
isTrue,
reason:
'the player still owns the timeline — publishing `playing: '
'false` over it desynchronises the head unit transport row',
);
expect(
estado.errorMessage,
isNull,
reason: 'nothing failed, so there is nothing to tell the driver',
);
},
);
test(
'un mount que NUNCA entrego audio sigue cayendo al suelo aunque el '
'reproductor diga playing: true',
() async {
final handler = crearHandler();
// `just_audio`'s `playing` is the play-when-ready intent flag: it
// flips to true the moment `play()` is called, whether or not a
// single byte ever arrives. So the stalled icecast mount the floor
// exists for reports `playing: true` too — `playing` alone can never
// be the discriminator.
handler.manejarEstadoPlayer(
PlayerState(true, ProcessingState.buffering),
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'no `ready` was ever reached on this run, so nothing is '
're-buffering: the driver is staring at a spinner and the floor '
'is the only exit',
);
expect(estado.errorMessage, isNotNull);
},
);
test(
'la ventana del suelo respeta el presupuesto de diez segundos hasta el '
'primer mensaje',
() {
expect(
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto,
lessThanOrEqualTo(const Duration(seconds: 10)),
reason:
'the floor is the ONLY exit for a stalled mount, so its window '
'IS the time-to-first-message for that failure mode; twenty '
"seconds was double the code's own cited budget",
);
},
);
});
group('Error terminal de reproduccion: la sesion sobrevive (hallazgo 2)', () {
test(
'el ultimo estado publicado es error CON mensaje, y no lo sigue un idle',
() async {
// A non-network failure: `_esErrorDeRed` is false, so this goes
// straight down the terminal path instead of the reconnect machine.
guion.errorSetUrl = Exception('mount muerto');
final handler = crearHandler();
final publicados = <AudioProcessingState>[];
final sub = handler.playbackState.listen(
(estado) => publicados.add(estado.processingState),
);
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
// What `_player.stop()` really does: just_audio.dart:1016-1025
// switches to the idle dummy platform, so `playerStateStream` emits a
// distinct (playing:false, idle). The double cannot do that on its
// own, so the test drives the exact event the real player would.
guion.ultimoReproductor!.emitir(
PlayerState(false, ProcessingState.idle),
);
await pumpEventQueue();
await sub.cancel();
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'forwarding that idle makes audio_service call '
'AudioService._stop() -> deactivateMediaSession() + stopSelf(), '
'so PluriWave dropped off the Android Auto playback surface a '
'single event-loop turn after showing the error',
);
expect(estado.errorMessage, isNotNull);
expect(
publicados.last,
isNot(AudioProcessingState.idle),
reason: 'secuencia publicada: $publicados',
);
expect(
handler.mediaItem.value,
isNotNull,
reason:
'Android Auto drops a session with no metadata to show, so '
'nulling the media item on the error path makes the app vanish '
'from the car pane even when the state itself survives — the '
'station that failed has to keep its name on screen',
);
},
);
test(
'un stop() del usuario DESPUES del error sigue produciendo un idle real '
'(la sesion tiene que poder morir cuando el conductor lo pide)',
() async {
guion.errorSetUrl = Exception('mount muerto');
final handler = crearHandler();
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
reason: 'precondicion',
);
await handler.stop();
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.idle,
reason:
'suppressing the error-driven idle must NEVER make the Stop '
'button unkillable — that is the original citation',
);
},
);
});
group('Presupuesto de tiempo hasta el primer mensaje (<= 10 s)', () {
tearDown(() {
PluriWaveAudioHandler.timeoutCambioFuente =
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
});
test('el timeout por defecto deja el primer mensaje dentro de 10 s', () {
expect(
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto,
lessThanOrEqualTo(const Duration(seconds: 10)),
reason:
'Android for Cars App Quality Guidelines allow ten seconds before '
'the driver must be told something; the first attempt alone used '
'to burn twelve',
);
});
test(
'una fuente que nunca responde publica un mensaje visible al agotar el '
'primer intento',
() async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 100,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(
handler.playbackState.value.errorMessage,
isNotNull,
reason:
'the backoff used to publish `buffering` with errorMessage: '
'null, so the car showed a silent spinner for the whole ~100 s '
'reconnect window',
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'still retrying — the message rides ON TOP of the retry',
);
},
);
test('los reintentos siguen DETRAS del mensaje', () async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 100,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
// First backoff delay is 1 s (ControladorReconexion default).
await Future<void>.delayed(const Duration(milliseconds: 1300));
expect(
guion.llamadasSetUrl,
greaterThanOrEqualTo(2),
reason: 'the reconnect machine keeps working after the first message',
);
expect(
handler.playbackState.value.errorMessage,
isNotNull,
reason:
'and the message survives the retry: re-entering `_cambiarFuente` '
'must not blank the car screen back to a silent spinner',
);
});
});
/// A handler nobody released goes on running: its terminal-state floor
/// timer, its `ControladorReconexion` backoff (1/2/4/8/16 s, which easily
/// outlives the test that armed it) and whatever is still queued on
/// `_colaCambioFuente`. When one of those finally performs a source change
/// it calls `_crearPlayer()`, which reads the CURRENT static
/// `fabricaReproductorPrueba` — so it builds a double bound to a LATER
/// test's script and increments that test's counters for work it never
/// asked for. A suite that passes under those conditions passes by luck.
group('Liberacion del handler: nada sobrevive al test que lo creo', () {
tearDown(() {
PluriWaveAudioHandler.timeoutCambioFuente =
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
});
test(
'un handler liberado NO vuelve a construir un reproductor contra la '
'fabrica del test siguiente',
() async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 60,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
// Long enough for the source-change timeout to fire and the reconnect
// machine to arm its first backoff retry (1 s).
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(
guion.llamadasSetUrl,
1,
reason: 'precondicion: hay un reintento armado detras',
);
await handler.liberar();
// Exactly what the framework does between tests: a brand-new script
// and a factory bound to it. Nothing from the previous test may
// reach this.
final guionSiguiente = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) =>
_ReproductorFalso(guionSiguiente, pipeline, carga);
await Future<void>.delayed(const Duration(milliseconds: 1400));
expect(
guionSiguiente.llamadasSetUrl,
0,
reason:
'the leaked backoff retry re-enters `_cambiarFuente`, which '
'calls `_crearPlayer()` and therefore reads whatever factory is '
'installed NOW — attributing a dead handler s work to the test '
'that happens to be running',
);
expect(
guionSiguiente.ultimoReproductor,
isNull,
reason: 'no player at all may be built against the new script',
);
},
);
test('liberar() es idempotente', () async {
final handler = crearHandler();
await handler.liberar();
await handler.liberar();
});
});
group('Idioma de la superficie del coche (motor sin Activity)', () {
tearDown(() {
PluriWaveAudioHandler.lectorLocalePlataforma =
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
});
/// Drives a NON-network failure through the real source-change path so the
/// terminal error message published to the car can be read back.
Future<String?> mensajeDeError(PluriWaveAudioHandler handler) async {
guion.errorSetUrl = Exception('boom');
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
return handler.playbackState.value.errorMessage;
}
test(
'sin configurarLocalizaciones, los mensajes salen en el locale de la '
'plataforma, no en es',
() async {
PluriWaveAudioHandler.lectorLocalePlataforma = () =>
const Locale('en');
final handler = crearHandler();
final mensaje = await mensajeDeError(handler);
expect(
mensaje,
lookupAppLocalizations(const Locale('en')).audioErrorUnexpectedPlayback,
reason:
'`configurarLocalizaciones` only ever runs from '
'`mini_reproductor.dart` didChangeDependencies. The headless '
'Android Auto engine has no Activity and no widget tree, so it '
'never ran there and every car message came out in Spanish',
);
expect(
mensaje,
isNot(
lookupAppLocalizations(
const Locale('es'),
).audioErrorUnexpectedPlayback,
),
);
},
);
test('un locale de plataforma no soportado conserva el respaldo es', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('sw');
final handler = crearHandler();
expect(
await mensajeDeError(handler),
lookupAppLocalizations(const Locale('es')).audioErrorUnexpectedPlayback,
reason: 'the existing fallback must survive an unresolvable locale',
);
});
test('configurarLocalizaciones sigue teniendo prioridad', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
handler.configurarLocalizaciones(
lookupAppLocalizations(const Locale('fr')),
);
expect(
await mensajeDeError(handler),
lookupAppLocalizations(const Locale('fr')).audioErrorUnexpectedPlayback,
reason: 'the phone UI still owns the locale once a widget tree exists',
);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
/// When set, `setUrl` completes with this error instead of succeeding.
Object? errorSetUrl;
/// When true, `setUrl` never completes (simulates a dead stream that only
/// the source-change timeout can end).
bool setUrlCuelga = false;
/// When set, `setUrl` returns this completer's future, so a test can hold a
/// source change mid-flight and release it after acting on the handler.
Completer<Duration?>? completerSetUrl;
/// The handler rebuilds its player on every source change, so a test that
/// needs to drive a player event has to reach the LATEST instance.
_ReproductorFalso? ultimoReproductor;
}
/// A [AudioPlayer] whose platform-touching methods are replaced by the script
/// above. Everything else (the rx subjects the constructor wires up) is the
/// real thing, so the handler's stream plumbing is exercised unchanged.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// Drives the exact `playerStateStream` event the real player would emit.
void emitir(PlayerState estado) {
if (!_estados.isClosed) _estados.add(estado);
}
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
if (_guion.setUrlCuelga) return Completer<Duration?>().future;
final pendiente = _guion.completerSetUrl;
if (pendiente != null) return pendiente.future;
final error = _guion.errorSetUrl;
if (error != null) return Future<Duration?>.error(error);
return Future<Duration?>.value(null);
}
@override
Future<void> play() async {
_guion.llamadasPlay++;
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -0,0 +1,507 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Resuming the last station in Android Auto — the three defects that made a
/// car-only session unable to remember, restart or even NAME what it was
/// playing.
///
/// Every test here runs with NO widget tree and NO browse source registered:
/// that is the engine Android Auto actually starts
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
/// no Activity), so `EstadoRadio` — the only thing that used to write
/// `ultima_emisora_v1` — is never constructed there.
///
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
/// session that happened only in the car never updated the key and the
/// head unit was offered the station from the last time the PHONE was
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
/// tier's featured folder was stale too.
///
/// A2. `play()` with no source called `_player.play()`, and
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
/// BEFORE the `_audioSource != null` gate — so the platform was never
/// touched, the returned Future never completed, and `playing: true`
/// was forwarded over `processingState: idle`.
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
/// button, no audio, no title and no artwork (or a
/// `ForegroundServiceStartNotAllowedException` on API 31+).
///
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
/// are the duration update, `_cambiarFuente` and `stop` — so
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
/// native side got no metadata at all.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
/// The free set's first station: resolvable from the binary alone, so it
/// works on a bind where no browse source was ever registered — which is
/// the whole point of these tests.
const emisoraFip = Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
);
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
// false`) and no `ultima_emisora_v1`.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('A1 — el coche escribe la ultima emisora', () {
test(
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
'inyectado, sin arbol de widgets',
() async {
final prefs = await SharedPreferences.getInstance();
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async {
guardadas.add(emisora);
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
},
);
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
await pumpEventQueue();
expect(
guardadas.map((e) => e.uuid),
[emisoraFip.uuid],
reason:
'a car-only session must update `ultima_emisora_v1` itself — '
'`EstadoRadio` is never built on a headless engine',
);
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
expect(persistida?.uuid, emisoraFip.uuid);
expect(
persistida?.url,
emisoraFip.url,
reason:
'the record has to be PLAYABLE: it is what the recent root and '
'`resolverEmisorasDestacadas` hand back to the head unit',
);
},
);
test('playMediaItem directo (voz, telefono) persiste igual', () async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
expect(guardadas.single.url, 'https://ejemplo/stream');
});
test(
'una pista local NO se persiste como ultima emisora',
() async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'content://media/audio/7',
title: 'Pista local',
extras: {'documentId': 'doc-7'},
),
);
await pumpEventQueue();
expect(
guardadas,
isEmpty,
reason:
'`ultima_emisora_v1` feeds the recent root and the featured '
'folder as an `emisora:<uuid>` row — a `content://` track '
'there is a row that does nothing when tapped',
);
},
);
test('un fallo del puerto se traza y NUNCA propaga', () async {
final handler = crearHandler();
registrarHandler(
handler,
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
await pumpEventQueue();
});
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
final handler = crearHandler();
registrarHandler(handler);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
});
});
group('A2 — play() sin fuente no publica una sesion fantasma', () {
test(
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
'reproductor recibe su url',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
contains(emisoraFip.url),
reason:
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
'straight into play(); on a cold engine there is no source, so '
'it has to resolve the persisted station instead',
);
},
);
test(
'y NINGUN estado publicado lleva playing:true sobre processingState '
'idle',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
await sub.cancel();
expect(
fantasmas,
isEmpty,
reason:
'playing:true over idle is what makes `AudioService.java:559` '
'call enterPlayingState() with STATE_NONE — a PluriWave '
'notification with a pause button, no audio and no title',
);
},
);
test(
'sin nada persistido: no se toca el reproductor, no hay estado '
'fantasma y play() no se queda colgado',
() async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
await expectLater(
handler.play().timeout(const Duration(seconds: 2)),
completes,
);
await pumpEventQueue();
await sub.cancel();
expect(
guion.llamadasPlay,
0,
reason:
'with nothing to restore the player must not be touched at '
'all: `just_audio` publishes playing:true before its source '
'gate and never completes the future it returns',
);
expect(guion.llamadasSetUrl, 0);
expect(fantasmas, isEmpty);
},
);
test(
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
'siempre (pausa -> play no reabre nada)',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
await handler.pause();
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
await handler.play();
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
urlsAntes,
reason:
'a resume must NOT re-open the source, and must never replace '
'the live station with the persisted one',
);
expect(handler.intencionReproducir, isTrue);
},
);
});
group('A3 — arranque en frio: el coche recibe metadatos', () {
test(
'con una emisora persistida se publica su mediaItem SIN arrancar '
'reproduccion',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
expect(
handler.mediaItem.value,
isNotNull,
reason:
'`audio_service.dart:1029-1033` returns before setMediaItem '
'when mediaItem is null, so a cold engine sent the head unit '
'no metadata whatsoever',
);
expect(handler.mediaItem.value?.id, emisoraFip.url);
expect(handler.playbackState.value.playing, isFalse);
expect(
guion.llamadasSetUrl,
0,
reason:
'publishing metadata must not open a stream: a cold bind '
'happens on every reconnect and must stay silent',
);
},
);
test('sin nada persistido el mediaItem sigue vacio', () async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
expect(handler.mediaItem.value, isNull);
});
test(
'una emisora que ya empezo a sonar NO es pisada por la siembra',
() async {
final handler = crearHandler();
final lectura = Completer<Emisora?>();
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
await handler.playMediaItem(
const MediaItem(
id: 'https://enVivo/stream',
title: 'En vivo',
extras: {'uuid': 'uuid-en-vivo'},
),
);
lectura.complete(emisoraFip);
await pumpEventQueue();
expect(
handler.mediaItem.value?.id,
'https://enVivo/stream',
reason:
'the seed exists to fill a VOID; clobbering the live station '
'would rename what the driver is listening to',
);
},
);
});
group('getMediaItem resuelve tambien el set destacado', () {
test(
'sin fuente de navegacion registrada, una emisora destacada resuelve',
() async {
final handler = crearHandler();
registrarHandler(handler);
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
expect(
item,
isNotNull,
reason:
'`porUuid` already falls back to the featured set, so the car '
'could BROWSE a featured station and not resolve its media '
'item — the asymmetry is the bug',
);
expect(item?.id, 'emisora:${emisoraFip.uuid}');
expect(item?.title, emisoraFip.nombre);
},
);
test('un id que no es de emisora sigue devolviendo null', () async {
final handler = crearHandler();
registrarHandler(handler);
expect(await handler.getMediaItem('pista:doc-1'), isNull);
expect(await handler.getMediaItem('emisora:'), isNull);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
}
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
/// it never touches the platform and never completes the future it returned.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// A fresh player has no source, exactly like the real one.
bool _fuenteCargada = false;
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
_fuenteCargada = true;
return null;
}
@override
Future<void> play() {
_guion.llamadasPlay++;
if (!_estados.isClosed) {
_estados.add(
PlayerState(
true,
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
),
);
}
// The dangling future: with no source, upstream `play()` awaits a
// `_playingSubject` transition the platform will never produce.
if (!_fuenteCargada) return Completer<void>().future;
return Future<void>.value();
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
+102 -6
View File
@@ -1,14 +1,57 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
/// Pure port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] is the ONLY
/// piece of `ServicioComprasPlayBilling` that is unit-testable without a
/// real `in_app_purchase` platform channel — [ServicioComprasPlayBilling]
/// itself is the sole call site (Design ADR-2), exercised instead through
/// `EstadoEntitlement` + a fake `PuertoCompras`
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] es la parte
/// pura, y [ServicioComprasPlayBilling] se ejercita inyectando
/// [_InAppPurchaseFalso] — sin ningún platform channel real.
/// `EstadoEntitlement` se prueba aparte con un `PuertoCompras` falso
/// (`estado_entitlement_test.dart`).
/// Fake [InAppPurchase]: deja que cada test empuje lotes por
/// [purchaseStream] a mano. `noSuchMethod` cubre el resto de la API del
/// plugin, que estos tests no ejercitan.
class _InAppPurchaseFalso implements InAppPurchase {
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
int restauracionesPedidas = 0;
final completadas = <PurchaseDetails>[];
@override
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
@override
Future<void> restorePurchases({String? applicationUserName}) async {
restauracionesPedidas++;
}
@override
Future<void> completePurchase(PurchaseDetails purchase) async {
completadas.add(purchase);
}
void emitir(List<PurchaseDetails> compras) => _compras.add(compras);
Future<void> dispose() => _compras.close();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
purchaseID: 'compra-1',
productID: ServicioComprasPlayBilling.idProducto,
verificationData: PurchaseVerificationData(
localVerificationData: 'local',
serverVerificationData: 'server',
source: 'google_play',
),
transactionDate: null,
status: status,
);
void main() {
group('eventoDesdeEstadoCompra', () {
test('purchased -> comprada', () {
@@ -50,6 +93,59 @@ void main() {
});
});
group('ServicioComprasPlayBilling.purchaseStream', () {
test('un lote vacio emite noEncontrada (restaurar sin compras)', () async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final tipos = <TipoEventoCompra>[];
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
addTearDown(sub.cancel);
await servicio.restaurar();
iap.emitir(const <PurchaseDetails>[]);
await Future<void>.delayed(Duration.zero);
// Sin este evento `EstadoEntitlement._compraEnCurso` se queda en `true`
// para siempre y `hoja_premium.dart` deshabilita AMBOS botones
// (comprar y restaurar): el usuario no puede pagar.
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.noEncontrada]);
expect(iap.restauracionesPedidas, 1);
});
test('un lote con compras NO emite noEncontrada', () async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final tipos = <TipoEventoCompra>[];
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
addTearDown(sub.cancel);
iap.emitir(<PurchaseDetails>[_compraFalsa(PurchaseStatus.restored)]);
await Future<void>.delayed(Duration.zero);
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.restaurada]);
});
test('completa las compras pendientes de confirmar', () async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final compra =
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
iap.emitir(<PurchaseDetails>[compra]);
await Future<void>.delayed(Duration.zero);
expect(iap.completadas, <PurchaseDetails>[compra]);
});
});
test('idProducto es el identificador unico no-consumible', () {
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
});
@@ -232,4 +232,107 @@ void main() {
},
);
});
group('ServicioExportImport v4 — equalizer on/off toggle', () {
test('v4 export includes ecualizadorActivo=true and schemaVersion 4', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
eqMultiDeviceEnabled: false,
ecualizadorActivo: true,
);
expect(config['version'], 4);
expect(config['ecualizadorActivo'], isTrue);
});
test('v4 export includes ecualizadorActivo=false and schemaVersion 4', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
eqMultiDeviceEnabled: false,
ecualizadorActivo: false,
);
expect(config['version'], 4);
expect(config['ecualizadorActivo'], isFalse);
});
test('v4 round-trip preserves the equalizer toggle exactly', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 1800],
eqMultiDeviceEnabled: true,
ecualizadorActivo: false,
);
final json = servicio.exportar(config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 4);
expect(importado['ecualizadorActivo'], isFalse);
});
test('omitting ecualizadorActivo keeps the export at v3 (backward compat) '
'and no key is written', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
eqMultiDeviceEnabled: true,
// ecualizadorActivo omitted — emulates a v3 export.
);
expect(config['version'], 3);
expect(config.containsKey('ecualizadorActivo'), isFalse);
});
test('omitting every v3/v4 extension keeps the export at v2 (backward '
'compat)', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
// No v3/v4 extensions at all.
);
expect(config['version'], 2);
expect(config.containsKey('ecualizadorActivo'), isFalse);
expect(config.containsKey('eqMultiDeviceEnabled'), isFalse);
});
});
}
@@ -105,6 +105,80 @@ void main() {
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos.any((g) => g.id == grupo.id), isFalse);
});
// ── Primitivas de restauración (importarConfig) ────────────────────────────
//
// `agregar` es la primitiva de "marcar como favorita": fuerza `sin_asignar`
// y un `orden` nuevo a propósito, porque una emisora recién marcada no
// pertenece a ningún grupo. Reusarla para RESTAURAR una copia de seguridad
// destruía justo los dos campos que la copia traía. `restaurarFavorito` es
// la primitiva que faltaba.
test('restaurarFavorito preserva el grupo y el orden de la copia', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.restaurarGrupo(
const GrupoFavoritos(id: 'grupo-rock', nombre: 'Rock', orden: 7),
);
await servicio.restaurarFavorito(
_emisora(
'radio-1',
'Radio Uno',
).copyWith(orden: 42, grupoFavoritosId: 'grupo-rock'),
);
final favoritos = await servicio.obtenerTodos();
expect(favoritos.single.grupoFavoritosId, 'grupo-rock');
expect(favoritos.single.orden, 42);
});
test('restaurarFavorito cae a Sin asignar cuando el grupo de la copia no '
'existe (copia editada a mano o restauración parcial)', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.restaurarFavorito(
_emisora(
'radio-1',
'Radio Uno',
).copyWith(orden: 3, grupoFavoritosId: 'grupo-fantasma'),
);
final favoritos = await servicio.obtenerTodos();
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(favoritos.single.orden, 3);
});
test('restaurarGrupo hace upsert preservando id, nombre y orden, y NUNCA '
'duplica el grupo protegido', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.restaurarGrupo(
const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz', orden: 5),
);
// Segunda pasada (reimportar la misma copia): upsert, no duplicado.
await servicio.restaurarGrupo(
const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz renombrado', orden: 5),
);
await servicio.restaurarGrupo(
const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Unassigned',
orden: 0,
protegido: true,
),
);
final grupos = await servicio.obtenerGrupos();
final jazz = grupos.singleWhere((g) => g.id == 'grupo-jazz');
expect(jazz.nombre, 'Jazz renombrado');
expect(jazz.orden, 5);
// El protegido conserva su nombre local: no se importa desde la copia.
expect(grupos.singleWhere((g) => g.esSinAsignar).nombre, 'Sin asignar');
});
}
Emisora _emisora(String uuid, String nombre) {
+223
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
@@ -9,6 +10,12 @@ import 'package:pluriwave/widgets/hoja_premium.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// fix/import-alarmas-y-paywall: a purchase sheet the user cannot escape is
/// a dark pattern and a Play policy risk. These cover the dismiss
/// affordance, the honest/concrete feature list, and the equalizer guard
/// (the phone equalizer is free for everyone and must never be presented as
/// a premium feature — this has regressed conceptually before).
/// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test
/// drive [EstadoEntitlement]'s purchase-stream events without touching
/// `in_app_purchase`.
@@ -29,6 +36,28 @@ class _PuertoComprasFalso implements PuertoCompras {
Future<void> dispose() => _eventos.close();
}
/// Fake [InAppPurchase]: permite montar el paywall sobre el
/// `ServicioComprasPlayBilling` REAL (no un `PuertoCompras` falso) para cubrir
/// el bloqueo del paywall de extremo a extremo. `noSuchMethod` cubre el resto
/// de la API del plugin, que este test no ejercita.
class _InAppPurchaseFalso implements InAppPurchase {
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
@override
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
@override
Future<void> restorePurchases({String? applicationUserName}) async {
// Play Billing publica un lote VACÍO cuando no hay nada que restaurar.
_compras.add(const <PurchaseDetails>[]);
}
Future<void> dispose() => _compras.close();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// FIX 3 / FIX 9 (code review): the paywall must show localized feedback for
/// a failed purchase/restore, a distinct non-error confirmation when a
/// restore finds nothing, and its own dedicated "premium active" string
@@ -68,6 +97,150 @@ void main() {
return estado;
}
/// Presents `HojaPremium` through the REAL `mostrarHojaPremium` modal
/// route (unlike [bombear], which embeds it directly with no route to
/// pop) so the dismiss controls can be exercised end-to-end exactly as a
/// user would encounter them.
Future<void> bombearComoHoja(
WidgetTester tester, {
required _PuertoComprasFalso compras,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null, compras: compras),
),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder:
(context) => Scaffold(
body: TextButton(
onPressed: () => mostrarHojaPremium(context),
child: const Text('abrir'),
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
}
group('fix/import-alarmas-y-paywall — dismissibility', () {
testWidgets('the close (X) control closes the sheet without purchasing or '
'restoring', (tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('hoja-premium-cerrar')));
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
});
testWidgets(
'the "not now" secondary action closes the sheet without purchasing '
'or restoring',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('hoja-premium-ahora-no')));
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
},
);
testWidgets('the system back gesture also closes the sheet (default '
'isDismissible/enableDrag, no PopScope blocking it)', (tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
});
testWidgets(
'an already-premium user still gets the close control (no decline '
'needed) and no "not now" button',
(tester) async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(
find.byKey(const ValueKey('hoja-premium-cerrar')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('hoja-premium-ahora-no')),
findsNothing,
);
},
);
});
group('fix/import-alarmas-y-paywall — honest, concrete copy', () {
testWidgets('lists the 5 features premium actually unlocks', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.premiumBeneficioSinAnuncios), findsOneWidget);
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
expect(find.text(l10n.premiumBeneficioGrabacion), findsOneWidget);
expect(find.text(l10n.premiumBeneficioVacaciones), findsOneWidget);
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
});
testWidgets('states this is a one-time purchase, not a subscription', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.premiumPagoUnico), findsOneWidget);
});
testWidgets(
'GUARD: the phone equalizer is never presented as a premium feature',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.equalizerTitle), findsNothing);
expect(find.text(l10n.equalizerActive), findsNothing);
expect(find.textContaining('cualizador'), findsNothing);
expect(find.textContaining('qualizer'), findsNothing);
},
);
});
group(
'FIX 9 — la etiqueta de premium activo es propia, no la del ecualizador',
() {
@@ -148,4 +321,54 @@ void main() {
},
);
});
group('bloqueo del paywall — "Restaurar compras" sin compras previas', () {
testWidgets('extremo a extremo (ServicioComprasPlayBilling real): un lote '
'vacío reactiva AMBOS botones, comprar y restaurar', (tester) async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create:
(_) => EstadoEntitlement(prefs: null, compras: servicio),
),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: HojaPremium()),
),
),
);
await tester.pump();
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
final restaurar = find.byKey(const ValueKey('hoja-premium-restaurar'));
final comprar = find.byKey(const ValueKey('hoja-premium-comprar'));
await tester.tap(restaurar);
await tester.pump();
await tester.pump();
// El botón sigue vivo: si `noEncontrada` nunca llega, `compraEnCurso`
// se queda en `true` y el usuario no puede pagar nunca más.
expect(
tester.widget<OutlinedButton>(restaurar).onPressed,
isNotNull,
reason: 'restaurar debe volver a estar habilitado',
);
expect(
tester.widget<FilledButton>(comprar).onPressed,
isNotNull,
reason: 'comprar debe volver a estar habilitado',
);
expect(find.text(l10n.restauracionSinCompras), findsOneWidget);
});
});
}