Author SHA1 Message Date
ShanaiaBot 2891a5703e chore: bump version to 1.3.3+164 [ci skip] 2026-09-18 17:08:56 +02:00
FreeTLab acf2ebb55f fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m13s
Revision previa al envio a produccion. Cada punto se verifico en el codigo
antes de tocarlo; lo que ya estaba bien se dejo como estaba.

Ubicacion: se declaraba precision fina sin usarla

El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo
con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y
contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que
dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo
que se penaliza en revision.

Verificado que los manifiestos de geolocator_android y geocoding_android no
declaran permisos propios, asi que el merge no lo reinyecta y no hace falta
`tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a
partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio
funcional: la deteccion de pais sigue igual.

El paywall vendia Android Auto como exclusivo, y ya no lo es

La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito
recibio una carpeta navegable con emisoras reproducibles cuando hubo que
cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice
que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y
aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier
gratuito ya tiene expone a reclamacion y a que se cite en revision.

Microfono: se pide al activar el visualizador, no antes

Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto.

Grabacion: uso privado de verdad, no solo en el aviso

La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con
`Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio
telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora
usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa
cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en
silencio. Se añade ademas el aviso de uso privado en esa pantalla.

`recordingActionShare` la usaban DOS botones con significados distintos: el de
grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre
y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para
que al leer el codigo pareciera que solo se compartian enlaces. Separadas en
`stationActionShare` y `recordingActionOpenIn`.

La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga
del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion.

Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-18 17:06:59 +02:00
ShanaiaBot 69eea0f2a1 chore: bump version to 1.3.3+163 [ci skip] 2026-09-18 11:46:41 +02:00
Javier Bautista Fernández 0b7919e72e fix(compras): revocar el PRO tras un reembolso sin penalizar a quien pago
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 4m8s
Verificacion silenciosa en segundo plano con queryPastPurchases, al abrir o
volver a la app y al cargar Android Auto. Solo revoca tras dos respuestas
validas de Play sin la compra separadas 12h; sin red o con error no toca nada.
Reactiva el PRO automaticamente si Play confirma la compra.
2026-09-18 11:45:43 +02:00
ShanaiaBot d7366bbf99 chore: bump version to 1.3.3+162 [ci skip] 2026-09-06 00:11:34 +02:00
FreeTLab b1bf289e0d 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 2m56s
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:08:09 +02:00
FreeTLab 8fc3d99fbd 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:08:09 +02:00
FreeTLab a0fae57219 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:08:09 +02:00
ShanaiaBot 405dc18430 chore: bump version to 1.3.3+161 [ci skip] 2026-09-04 14:31:35 +02:00
FreeTLab bcdf3d55c4 ci: publicar los builds de main donde el portal sabe leerlos
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m20s
Desde el 29-08 ningun build de main volvio a aparecer en builds.freetimelab.es,
y nada avisaba: el job salia verde porque el scp subia correctamente, solo que a
una ruta que el indexador no lee.

El portal indexa DOS niveles, <app>/<version>/<ficheros>. La solucion anterior
metia la rama como TERCER nivel (pluriwave/main/v1.3.3/), asi que sus ficheros
quedaban fuera del indice: 39 versiones listadas, ninguna con "main", ninguna
con los codigos 158, 159 ni 160. Encima el echo del propio paso tenia "pluriwave"
escrito a fuego y anunciaba la carpeta antigua, congelada desde el +157, que es
justo donde se miraba al no encontrar nada.

Ahora la rama va en el NOMBRE DE LA APP, que es lo que el portal si entiende.
PRO conserva la entrada limpia "pluriwave" y main tiene la suya, igual que ya
conviven radar-foral y radar-foral-android. Se cumple el objetivo original -- que
el build de desarrollo no aparezca como ultima version de release -- sin romper
el indexado. La ruta impresa se deriva de la misma variable, para que no puedan
volver a desincronizarse.

Se alinea tambien el arreglo del secreto de Google Play que ya esta en PRO: el
paso se omite con un aviso en vez de hacer `exit 1`, y el mensaje de Telegram
deja de afirmar que se publico algo que no se publico. main nunca ejecuta esos
pasos, pero mantener un unico workflow evita conflictos en cada trasvase entre
ramas.
2026-09-04 14:30:59 +02:00
ShanaiaBot 02609ec82c chore: bump version to 1.3.3+160 [ci skip] 2026-09-02 22:59:19 +02:00
FreeTLab 5f35ab7d6a feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s
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-02 22:56:10 +02:00
FreeTLab 241f81e535 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-02 22:56:10 +02:00
ShanaiaBot a82dcc9c1b chore: bump version to 1.3.3+159 [ci skip] 2026-08-31 14:38:18 +02:00
FreeTLab 3449e2cb79 fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
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-08-31 14:34:49 +02:00
ShanaiaBot 10bb017f4c chore: bump version to 1.3.3+158 [ci skip] 2026-08-29 11:00:00 +02:00
FreeTLab a99df5d055 ci: let PRO own the version name and split artifacts by branch [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m44s
Three related fixes to the release plumbing:

- Only PRO bumps the semver now. main was bumping its patch on every push,
  so it raced permanently ahead of the branch that actually ships (main hit
  1.3.3 while PRO sat at 1.3.0), buried release artifacts under a dev branch
  on the portal, and made every main<->PRO merge conflict on pubspec.yaml.
  The build number still advances on every branch, since Play requires it to
  be monotonic across the whole app.

- The [version set] marker is now searched across every commit the push
  introduced, not just the tip. A plain 'git pull' inserts a merge commit
  with no marker, which silently dropped a pinned name and turned 1.3.0
  into 1.3.1.

- Artifacts land in a per-branch folder so the portal stops interleaving
  development and release builds.
2026-08-29 10:59:20 +02:00
ShanaiaBot ab66f4985c chore: bump version to 1.3.3+157 [ci skip] 2026-08-28 23:53:24 +02:00
FreeTLab d61c62540a ci: name build artifacts by branch and version code [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 23s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m57s
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:41 +02:00
ShanaiaBot 25d5841d57 chore: bump version to 1.3.3+156 [ci skip] 2026-08-28 23:38:07 +02:00
FreeTLab 663fed5f41 merge: alarm-import recovery, dismissible paywall and complete config export
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m41s
Three fixes that all came out of on-device testing:
- e57f7bb restores alarms after a backup import. The import wrote the JSON
  to prefs but EstadoAlarmas never re-read it, so imported alarms were
  invisible, were overwritten by the next edit, and were never scheduled
  natively — they would not have rung.
- a2bed18 makes the premium sheet dismissible (close button, 'not now',
  back gesture) and replaces the bare title with the five concrete things
  the purchase unlocks. A purchase sheet with no way out is a Play policy
  risk, not just bad UX.
- 4ca2813 closes the last export gap: the equalizer on/off toggle now
  travels with the backup (schema v4, additive; an old backup without the
  field leaves the current toggle untouched).
2026-08-28 23:35:37 +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
FreeTLab 1bfd5a2348 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m54s
2026-08-28 20:06:20 +02:00
FreeTLab 4ea5d2056c test(alarmas): anchor the vacation pill test on a relative future range
The test hardcoded 4-18 August 2026, which was in the future when it was
written and is now in the past. The pill only renders for the active or
next range, so the assertion started failing purely because the calendar
moved on — the production code was never wrong.

Anchor the range on next month (days 4-18, so it never straddles a month
boundary) and assert against rangoFechasCorto, the same pure formatter the
widget uses, so the test checks that the pill is rendered rather than
restating the formatter's own output.
2026-08-28 20:06:10 +02:00
ShanaiaBot b5940b2758 chore: bump version to 1.3.1+154 [ci skip] 2026-08-28 20:02:32 +02:00
FreeTLab 9efa6d8937 merge: bring the freemium/IAP release line into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
main had drifted behind PRO by the whole 1.3.0 line: the freemium/IAP
feature, the code-review fixes, the real AdMob ids, the equalizer
cross-surface resync and the closed-testing ad switch all shipped through
PRO only. This reconciles main so day-to-day work no longer branches from
a stale base.

# Conflicts:
#	pubspec.yaml
2026-08-28 19:59:12 +02:00
FreeTLab 55fe50d07d merge: equalizer cross-surface sync + test ads for the closed-testing phase [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 3m9s
Brings in two independent fixes that both need to reach testers:
- e9f47d4 resyncs EstadoEcualizador with car/notification-initiated changes
  and closes the persistence gap that lost them on restart.
- 2e15d05 forces Google test ad units in release builds while
  usarAnunciosDePruebaEnRelease is true, so no tester can generate invalid
  traffic against the AdMob account during closed testing.

[version set] keeps the 1.3.0 name; CI advances the build number.
2026-08-28 19:56:26 +02:00
FreeTLab 2e15d05431 fix(ads): force test ad units in release during closed testing
Closed-testing human testers cannot be registered as AdMob test
devices, so release builds serving real ad units risked invalid
traffic against an AdMob account that currently earns essentially
nothing. Add usarAnunciosDePruebaEnRelease, a single boolean switch
defaulted to true, that keeps bannerAdUnitId/interstitialAdUnitId on
Google's official test ids even in kReleaseMode. Flipping it to false
is the only change needed to go live. AndroidManifest's AdMob
application id is untouched, as it only initializes the SDK.
2026-08-28 19:49:12 +02:00
FreeTLab e9f47d47c2 fix(eq): resync EstadoEcualizador with car/notification-initiated changes
A toggle from the Android Auto notification or a preset picked from the
car's EQ folder mutated PluriWaveAudioHandler state directly, leaving
EstadoEcualizador (and therefore the phone UI) unaware and never
persisting the change, so it was lost on the next app restart.

Forward the handler's ecualizadorActivo flag through ServicioAudio and,
mirroring EstadoRadio's existing playFromMediaId resync, diff it plus
presetActual against the cached values on every estadoStream tick,
adopting and persisting a divergence via ServicioEcualizador.
2026-08-28 17:48:52 +02:00
ShanaiaBot 080d342de0 chore: bump version to 1.3.0+153 [ci skip] 2026-08-28 15:41:41 +02:00
FreeTLab 689f3e8123 chore(release): rebuild to get a fresh version code [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m4s
Play Console already holds version code 152 (name 1.3.0, uploaded 16 Aug),
so the bundle this pipeline produced was rejected as a duplicate code.

This empty commit re-triggers the build. The bump step advances the code to
153 (the first free one) while [version set] keeps the 1.3.0 version name,
which is the release name this freemium/IAP work is shipping under.
2026-08-28 15:23:13 +02:00
ShanaiaBot 70ee13d540 chore: bump version to 1.3.0+152 [ci skip] 2026-08-16 00:13:12 +02:00
ShanaiaBot 186ff45105 chore: bump version to 1.2.29+151 [ci skip] 2026-08-07 17:18:26 +02:00
119 changed files with 15536 additions and 1068 deletions
+94 -17
View File
@@ -68,7 +68,18 @@ jobs:
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "✅ Keystore configurado" echo "✅ Keystore configurado"
- name: Bump versión patch + commit # PRO owns the version NAME; every branch advances the build NUMBER.
#
# Previously main also bumped its patch on every push, so main's semver
# raced permanently ahead of PRO's (main hit 1.3.3 while the branch that
# actually ships sat at 1.3.0). That buried the release artifacts under a
# dev branch on builds.freetimelab.es, which sorts by version, and made
# every main<->PRO merge conflict on pubspec.yaml.
#
# The build number still advances everywhere: Google Play requires it to
# be monotonic across the whole app, so two branches must never mint the
# same code.
- name: Bump versión + commit
run: | run: |
BRANCH="${CURRENT_REF#refs/heads/}" BRANCH="${CURRENT_REF#refs/heads/}"
git config user.name "ShanaiaBot" git config user.name "ShanaiaBot"
@@ -77,12 +88,20 @@ jobs:
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1) SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2) BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
NEW_BUILD=$((BUILD + 1)) NEW_BUILD=$((BUILD + 1))
# If the triggering commit explicitly pins the version name via the
# [version set] marker, ship that semver as-is (a milestone like 1.0.0 # Look for [version set] across EVERY commit this push introduced,
# or a major/minor jump the automatic patch bump cannot reach) and only # not just the tip. `git pull` inserts an auto-generated merge commit
# advance the build number, which Google Play requires to stay # whose message carries no marker, which silently discarded a pinned
# monotonic. Otherwise keep the default automatic patch+build bump. # version name and bumped 1.3.0 to 1.3.1 behind our backs.
if git log -1 --pretty=%B | grep -q '\[version set\]'; then RANGO="${{ gitea.event.before }}..${{ gitea.sha }}"
if git log "$RANGO" --pretty=%B 2>/dev/null | grep -q '\[version set\]'; then
MARCADOR="si"
else
MARCADOR="no"
fi
if [ "$BRANCH" != "PRO" ] || [ "$MARCADOR" = "si" ]; then
# Non-release branches never touch the name; PRO respects a pin.
NEW_VERSION="${SEMVER}+${NEW_BUILD}" NEW_VERSION="${SEMVER}+${NEW_BUILD}"
else else
MAJOR=$(echo "$SEMVER" | cut -d. -f1) MAJOR=$(echo "$SEMVER" | cut -d. -f1)
@@ -91,6 +110,8 @@ jobs:
NEW_PATCH=$((PATCH + 1)) NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}" NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
fi fi
echo "rama=${BRANCH} marcador=${MARCADOR} ${CURRENT} -> ${NEW_VERSION}"
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]" git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
@@ -237,12 +258,47 @@ jobs:
- name: Build AAB release - name: Build AAB release
run: flutter build appbundle --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) - name: Publicar en ftl-builds (Zimaboard)
run: | run: |
VERSION="${{ steps.version.outputs.version }}" VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk" BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
AAB_NOMBRE="pluriwave-v${VERSION}.aab" BRANCH="${CURRENT_REF#refs/heads/}"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}" ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
# La rama va en el NOMBRE DE LA APP, no en una subcarpeta.
#
# El objetivo sigue siendo el de siempre: que main y PRO no se mezclen
# en el portal, que ordena por número de versión y mostraba el build
# de desarrollo como "última versión" por delante del de release.
#
# Pero la primera solución metía la rama como TERCER nivel
# (pluriwave/main/v1.3.3/) y el portal indexa solo DOS —
# <app>/<versión>/<ficheros> —, así que desde el 29-08 ningún build de
# main volvió a aparecer en builds.freetimelab.es aunque el job saliera
# verde: el scp subía bien, a una ruta que el indexador no lee. Nada
# avisaba, y el echo de abajo se comía la rama y mandaba a la carpeta
# antigua, que llevaba congelada desde el +157.
#
# Con la rama en el nombre, PRO conserva la entrada limpia "pluriwave"
# y main tiene la suya, igual que ya conviven radar-foral y
# radar-foral-android.
if [ "$BRANCH" = "PRO" ]; then
APP="pluriwave"
else
APP="pluriwave-$(echo "$BRANCH" | tr '/' '-')"
fi
DESTINO="/opt/ftl-builds/builds/${APP}/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519" SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}" ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
@@ -252,28 +308,46 @@ jobs:
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \ scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \ build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}" "ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}" # La ruta se imprime desde ${APP}, no a mano: la version anterior tenia
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}" # "pluriwave" escrito a fuego y mandaba a la carpeta equivocada cada
# vez que se compilaba algo que no fuera PRO.
echo "✅ APK: builds.freetimelab.es → ${APP} → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → ${APP} → 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 - name: Preparar credenciales de Google Play
id: credenciales_play
if: ${{ gitea.ref == 'refs/heads/PRO' }} if: ${{ gitea.ref == 'refs/heads/PRO' }}
env: env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: | run: |
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" echo "disponible=no" >> "$GITHUB_OUTPUT"
exit 1 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 fi
mkdir -p fastlane/credentials mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
echo "disponible=si" >> "$GITHUB_OUTPUT"
- name: Instalar Fastlane - name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' }} if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
run: | run: |
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
- name: Publicar AAB en Google Play Internal Testing - 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: env:
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
@@ -291,8 +365,11 @@ jobs:
if [ -z "$BOT_TOKEN" ]; then exit 0; fi if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados" 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.
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing" 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 else
MSG="${MSG}%0APublicado en builds.freetimelab.es" MSG="${MSG}%0APublicado en builds.freetimelab.es"
fi fi
+8 -1
View File
@@ -12,8 +12,15 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Approximate location ONLY. The single consumer
(EstadoBusqueda.cargarEmisorasCercanas) asks for LocationAccuracy.low
and throws the fix away except for Placemark.isoCountryCode, so a
country-level fix is all this app can use. Declaring
ACCESS_FINE_LOCATION would also contradict the approved Data Safety
declaration. geolocator builds its runtime request from whichever of
the two permissions the merged manifest declares, so with COARSE
alone the system dialog offers approximate precision only. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- <!--
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31 Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
@@ -10,7 +10,6 @@ import android.content.pm.PackageManager
import android.media.AudioDeviceCallback import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import android.app.AlarmManager import android.app.AlarmManager
@@ -26,6 +25,7 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity import com.ryanheise.audioservice.AudioServiceActivity
import es.freetimelab.pluriwave.fileactions.FileActionsHandler
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -269,10 +269,31 @@ class MainActivity : AudioServiceActivity() {
} }
activeInstance = this 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( MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, flutterEngine.dartExecutor.binaryMessenger,
fileActionsChannel fileActionsChannel
).setMethodCallHandler { call, result -> ).setMethodCallHandler { call, result ->
if (fileActionsHandler.manejar(call, result)) return@setMethodCallHandler
when (call.method) { when (call.method) {
"openDirectory" -> { "openDirectory" -> {
val path = call.argument<String>("path") val path = call.argument<String>("path")
@@ -327,53 +348,11 @@ class MainActivity : AudioServiceActivity() {
pendingMusicFolderResult = null pendingMusicFolderResult = null
} }
} }
"listAudioChildren" -> { // listAudioChildren / resolvePlayableUri /
val treeUri = call.argument<String>("treeUri") // hasPersistedPermission / readAudioMetadataBatch los
val parentDocumentId = call.argument<String>("parentDocumentId") ?: "" // atiende FileActionsHandler arriba (item 3): no necesitan
Log.d( // Activity, asi que tienen que poder registrarse tambien en
tag, // un engine que no la tiene.
"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))
}
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -423,241 +402,6 @@ class MainActivity : AudioServiceActivity() {
super.onActivityResult(requestCode, resultCode, data) 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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true 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
+11
View File
@@ -10,6 +10,7 @@ import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart'; import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart'; import 'estado/estado_idioma.dart';
import 'estado/estado_navegacion.dart'; import 'estado/estado_navegacion.dart';
import 'estado/estado_visualizador.dart';
import 'servicios/servicio_anuncios.dart'; import 'servicios/servicio_anuncios.dart';
import 'servicios/servicio_compras.dart'; import 'servicios/servicio_compras.dart';
import 'widgets/banner_anuncio_superior.dart'; import 'widgets/banner_anuncio_superior.dart';
@@ -122,6 +123,12 @@ class PluriWaveApp extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs), create: (_) => EstadoIdioma(sharedPreferences: prefs),
), ),
// Sensitive-permission opt-in for the waveform visualizer's real
// audio capture. Lives at the root because BOTH visualizer call
// sites (the Escuchar hero and the full player) have to read it —
// whichever of them mounts first is the one that would otherwise
// trigger the RECORD_AUDIO request.
ChangeNotifierProvider(create: (_) => EstadoVisualizador(prefs: prefs)),
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal` // Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
// watches this instead of owning `_indice` locally. // watches this instead of owning `_indice` locally.
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()), ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
@@ -205,6 +212,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
// without a live native sink. Re-subscribe and re-seed the active device // without a live native sink. Re-subscribe and re-seed the active device
// (no-op when multi-device EQ is off). // (no-op when multi-device EQ is off).
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual()); unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
// Silent, throttled license re-verification (refund revocation) and a
// re-sync with any change the Android Auto path persisted meanwhile.
// Fire-and-forget: never delays the resume, never shows anything.
unawaited(context.read<EstadoEntitlement>().refrescarLicencia());
} }
@override @override
+113 -1
View File
@@ -37,7 +37,9 @@ class EstadoEcualizador extends ChangeNotifier {
_presetsPersonalizadosService = _presetsPersonalizadosService =
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(), presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
_dispositivoAudio = dispositivoAudio, _dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null); _emisoraActualUuid = emisoraActualUuid ?? (() => null) {
_escucharCambiosEqDesdeHandler();
}
final ServicioAudio audio; final ServicioAudio audio;
final ServicioEcualizador servicio; final ServicioEcualizador servicio;
@@ -84,6 +86,27 @@ class EstadoEcualizador extends ChangeNotifier {
StreamSubscription<DispositivoAudio>? _deviceSub; StreamSubscription<DispositivoAudio>? _deviceSub;
Future<void>? _refrescoEnCurso; Future<void>? _refrescoEnCurso;
/// Catches a car/notification-initiated EQ change that bypasses this
/// class entirely (eq-sync-superficies): `accionEqToggle` calls
/// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and
/// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both
/// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual`
/// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact
/// shape `EstadoRadio._escucharErroresReproduccion` already uses for the
/// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream]
/// tick (which the handler already re-emits on any EQ change via
/// `_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; PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal; PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo; bool get activo => _activo;
@@ -337,6 +360,63 @@ class EstadoEcualizador extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Subscribes to [ServicioAudio.estadoStream] to catch a
/// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq]
/// doc for the full rationale).
void _escucharCambiosEqDesdeHandler() {
_suscripcionEstadoAudioEq = audio.estadoStream.listen((_) {
unawaited(_resincronizarConHandler());
});
}
/// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo],
/// [ServicioAudio.presetActual]) against our cached [_activo]/
/// [_presetActual] and adopts the handler's value on divergence.
///
/// Deliberately never calls back into [audio] here (no
/// `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 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
/// unexpected platform state that makes [audio]'s EQ getters unavailable
/// must never crash the stream subscription — it just skips this tick.
Future<void> _resincronizarConHandler() async {
try {
final activoHandler = audio.ecualizadorActivo;
final presetHandler = audio.presetActual;
final activoDiverge = activoHandler != _activo;
final presetDiverge = presetHandler != _presetActual;
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;
}
if (presetDiverge) {
_presetActual = presetHandler;
}
notifyListeners();
} catch (_) {
// See doc above — never let a resync failure crash the app.
}
}
/// Applies [preset] to the audio engine and tracks it as current /// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations). /// WITHOUT persisting it (used when switching stations).
Future<void> aplicarPresetActivo(PresetEcualizador preset) async { Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
@@ -583,12 +663,29 @@ class EstadoEcualizador extends ChangeNotifier {
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight /// 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 /// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about. /// 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 { Future<void> cambiarActivo(bool activo) async {
_activo = activo; _activo = activo;
notifyListeners(); notifyListeners();
await audio.setEcualizadorActivo(activo); await audio.setEcualizadorActivo(activo);
if (_activo != activo) return; if (_activo != activo) return;
final aceptado = audio.ecualizadorActivo;
if (aceptado != activo) {
_activo = aceptado;
notifyListeners();
return;
}
if (activo) { if (activo) {
await audio.aplicarPreset(_presetActual); await audio.aplicarPreset(_presetActual);
if (_activo != activo) return; if (_activo != activo) return;
@@ -627,12 +724,21 @@ class EstadoEcualizador extends ChangeNotifier {
/// Replaces the whole EQ configuration (backup import path): persists it, /// Replaces the whole EQ configuration (backup import path): persists it,
/// re-applies the preset effective for the current station and notifies. /// 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({ Future<void> importarConfiguracion({
required PresetEcualizador principal, required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora, required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo, Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz, Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled, bool? eqMultiDeviceEnabled,
bool? activo,
}) async { }) async {
_presetPrincipal = principal; _presetPrincipal = principal;
_presetsEmisoraMap _presetsEmisoraMap
@@ -667,12 +773,18 @@ class EstadoEcualizador extends ChangeNotifier {
final presetEfectivoActual = final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo(); uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual); await aplicarPresetActivo(presetEfectivoActual);
if (activo != null) {
await cambiarActivo(activo);
}
notifyListeners(); notifyListeners();
} }
@override @override
void dispose() { void dispose() {
_deviceSub?.cancel(); _deviceSub?.cancel();
_suscripcionEstadoAudioEq?.cancel();
super.dispose(); super.dispose();
} }
} }
+79 -10
View File
@@ -3,14 +3,16 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.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'; import '../servicios/servicio_compras.dart';
import '../servicios/verificacion_licencia.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable /// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it — /// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older /// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds"). /// builds"). Shared with the silent license re-verification
const _keyPremium = 'compra_premium_v1'; /// (`verificacion_licencia.dart`), which may revoke it after a refund.
const _keyPremium = claveCompraPremium;
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe /// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from /// Entitlement Read"): resolves the persisted premium flag directly from
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead, /// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path. /// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier { class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras}) EstadoEntitlement({
: _prefs = prefs, SharedPreferences? prefs,
_compras = compras { PuertoCompras? compras,
DateTime Function()? reloj,
}) : _prefs = prefs,
_compras = compras,
_reloj = reloj {
final flujo = _compras; final flujo = _compras;
if (flujo != null) { if (flujo != null) {
_comprasSub = flujo.eventos.listen(_alRecibirEvento); _comprasSub = flujo.eventos.listen(_alRecibirEvento);
} }
_cargar(); // The silent license check is chained AFTER the load and never awaited
// by anyone: the persisted flag is served immediately, exactly as
// before, and the check can only adjust it later, in the background.
unawaited(_cargar().then((_) => _verificarLicencia()));
} }
/// The single non-consumable product id (Design "Interfaces / Contracts"), /// The single non-consumable product id (Design "Interfaces / Contracts"),
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
final SharedPreferences? _prefs; final SharedPreferences? _prefs;
final PuertoCompras? _compras; final PuertoCompras? _compras;
/// Injectable clock for the license check's throttle/spacing rules.
final DateTime Function()? _reloj;
StreamSubscription<EventoCompra>? _comprasSub; StreamSubscription<EventoCompra>? _comprasSub;
bool _desechado = false;
bool _esPremium = false; bool _esPremium = false;
bool _compraEnCurso = false; bool _compraEnCurso = false;
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
Future<SharedPreferences> _resolverPrefs() async => Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance(); _prefs ?? SharedPreferences.getInstance();
/// Fire-and-forget hook for app resume: re-syncs with the persisted flag
/// (the Android Auto path may have changed it) and runs the throttled
/// silent license check. Never throws, never touches [compraEnCurso] or
/// [resultadoUsuario].
Future<void> refrescarLicencia() async {
try {
_sincronizarConPrefs(await _resolverPrefs());
} catch (e) {
debugPrint('[PluriWave][licencia] refresco fallido $e');
}
await _verificarLicencia();
}
/// Runs [verificarLicencia] against the purchase port and mirrors any
/// change of the persisted flag. Silent by construction: it only ever
/// updates [esPremium] and notifies — no purchase-stream event, no
/// [resultadoUsuario], no [compraEnCurso].
Future<void> _verificarLicencia() async {
final compras = _compras;
if (compras == null || _desechado) return;
try {
final prefs = await _resolverPrefs();
await verificarLicencia(
consultar: compras.consultarPropiedad,
prefs: prefs,
reloj: _reloj,
);
_sincronizarConPrefs(prefs);
} catch (e) {
debugPrint('[PluriWave][licencia] verificacion fallida $e');
}
}
/// Aligns [esPremium] with the persisted flag. Safe against a racing
/// [_desbloquear]: that one writes the prefs cache in the same synchronous
/// block where it flips [_esPremium], so both always agree here.
void _sincronizarConPrefs(SharedPreferences prefs) {
if (_desechado) return;
final premium = prefs.getBool(_keyPremium) ?? false;
if (premium == _esPremium) return;
_esPremium = premium;
notifyListeners();
// Either direction changes what the car may show (local music is
// premium-gated), so the cached Android Auto tree is stale both ways.
invalidarArbolAuto();
}
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when /// Starts the purchase flow (Spec "Successful purchase"). A no-op when
/// already premium (Spec "Already-purchased attempt is idempotent") — no /// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted. /// duplicate charge is even attempted.
@@ -171,22 +231,31 @@ class EstadoEntitlement extends ChangeNotifier {
} }
Future<void> _desbloquear() async { Future<void> _desbloquear() async {
// Prefs resolved FIRST so the in-memory flip and the prefs-cache write
// below happen in one synchronous block (`setBool` updates the cache
// before awaiting the platform) — [_sincronizarConPrefs] can never
// observe one without the other.
final prefs = await _resolverPrefs();
final yaEraPremium = _esPremium; final yaEraPremium = _esPremium;
_esPremium = true; _esPremium = true;
_compraEnCurso = false; _compraEnCurso = false;
final prefs = await _resolverPrefs(); final escritura = prefs.setBool(_keyPremium, true);
await prefs.setBool(_keyPremium, true); // A real purchase/restore is fresh proof of ownership: drop any stale
// absence streak of the silent license check.
await reiniciarAusenciasLicencia(prefs);
await escritura;
notifyListeners(); notifyListeners();
if (!yaEraPremium) { if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively // Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium // invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind. // transition, rather than waiting for the head unit's own re-bind.
notificarDesbloqueoAuto(); invalidarArbolAuto();
} }
} }
@override @override
void dispose() { void dispose() {
_desechado = true;
_comprasSub?.cancel(); _comprasSub?.cancel();
super.dispose(); super.dispose();
} }
+27 -10
View File
@@ -182,24 +182,41 @@ class EstadoGrabacion extends ChangeNotifier {
return launchUrl(uri, mode: LaunchMode.externalApplication); return launchUrl(uri, mode: LaunchMode.externalApplication);
} }
Future<bool> abrirUltimaGrabacion() async { /// Hands the recording at [ruta] to whatever player the user already has
final archivo = ultimoArchivo; /// on THIS device (`ACTION_VIEW` through the native `openFile` method,
if (archivo == null || !await archivo.exists()) { /// over the app's own `FileProvider`). Nothing leaves the device: this is
debugPrint('[PluriWave][recordings] last recording missing'); /// the "play my own copy elsewhere" action, not a share sheet.
///
/// Returns `false` when the file is gone or no installed app accepted the
/// intent, so the caller can say so instead of failing in silence.
///
/// Static-review-only, like [abrirDirectorio] and every other method here
/// that crosses `pluriwave/file_actions`: the channel has no handler under
/// `flutter test`. The screens that call it inject a seam instead.
Future<bool> abrirGrabacion(String ruta) async {
final archivo = File(ruta);
if (!await archivo.exists()) {
debugPrint('[PluriWave][recordings] file missing: $ruta');
return false; return false;
} }
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}'); debugPrint('[PluriWave][recordings] opening file: $ruta');
if (!kIsWeb && Platform.isAndroid) { if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', { final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path, 'path': ruta,
'mimeType': 'audio/*', 'mimeType': 'audio/*',
}); });
return abierto ?? false; return abierto ?? false;
} }
return launchUrl( return launchUrl(Uri.file(ruta), mode: LaunchMode.externalApplication);
Uri.file(archivo.path), }
mode: LaunchMode.externalApplication,
); Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimoArchivo;
if (archivo == null) {
debugPrint('[PluriWave][recordings] last recording missing');
return false;
}
return abrirGrabacion(archivo.path);
} }
@override @override
+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. /// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() { void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) { _suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
@@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual; final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual; _emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station // Issue 4's write used to live here as well. It is gone: the handler
// change too — remember it the same way `reproducir` does. // persists every station itself from `_cambiarFuente`, which is the
unawaited(_persistirUltimaEmisora(actual)); // 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(); notifyListeners();
}); });
@@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier {
} }
_emisoraSeleccionada = emisora; _emisoraSeleccionada = emisora;
notifyListeners(); notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a // Issue 4's `ultima_emisora_v1` write used to be here. It now happens
// restart — fire-and-forget, same treatment as `radio.registrarClick` // once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
// below (a persistence failure here must never block playback). // below reaches for this very station — see
unawaited(_persistirUltimaEmisora(emisora)); // [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 { try {
await audio.reproducir(emisora); await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return; if (revision != _revisionReproduccion) return;
@@ -807,9 +795,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1'; static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v3 — portabilidad completa /// Genera el JSON de toda la configuración (v4 — portabilidad completa
/// con presets por dispositivo y matriz multi-device). /// con presets por dispositivo, matriz multi-device y el toggle
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4). /// on/off del ecualizador).
/// La forma del sobre vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async { Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos(); final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos(); final grupos = await favoritos.obtenerGrupos();
@@ -837,6 +826,8 @@ class EstadoRadio extends ChangeNotifier {
presetsPorDispositivo: ecualizador.presetsDispositivo, presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz, presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled, 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. /// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa), /// 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 { Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1; 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(); final prefs = await _resolverPrefs();
@@ -873,7 +865,12 @@ class EstadoRadio extends ChangeNotifier {
final favRaw = data['favoritos'] as List? ?? []; final favRaw = data['favoritos'] as List? ?? [];
for (final raw in favRaw) { for (final raw in favRaw) {
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map)); 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 ─────────────────────────────────────────────────── // ── Emisoras custom ───────────────────────────────────────────────────
@@ -932,12 +929,20 @@ class EstadoRadio extends ChangeNotifier {
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?; 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( await ecualizador.importarConfiguracion(
principal: presetPrincipal, principal: presetPrincipal,
porEmisora: presetsPorEmisora, porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo, presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz, presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled, eqMultiDeviceEnabled: eqMultiDeviceEnabled,
activo: ecualizadorActivo,
); );
// ── Alarmas (v2) ────────────────────────────────────────────────────── // ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -945,7 +950,12 @@ class EstadoRadio extends ChangeNotifier {
final alarmasData = data['alarmas']; final alarmasData = data['alarmas'];
if (alarmasData is Map<String, dynamic>) { if (alarmasData is Map<String, dynamic>) {
// Escribimos el bloque JSON tal como estaba en el dispositivo origen. // 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)); await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
} }
} }
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Opt-in for reading the REAL audio level behind the waveform visualizer.
///
/// This is a sensitive-permission gate, not a cosmetic preference.
/// `VisualizadorAudio` subscribes to the native `pluriwave/audio_visualizer`
/// EventChannel only while [ondaRealHabilitada] is true, and that
/// subscription is precisely what makes `MainActivity` request
/// `RECORD_AUDIO`. So the flag must:
///
/// * default to `false`, so a fresh install never asks;
/// * flip to `true` only from a place where the user has just been told what
/// the permission is for (see `PantallaAjustesVisualizador`);
/// * be revocable at any time with no friction at all.
///
/// Without it the visualizer animates its synthetic wave, which is exactly
/// what it already did whenever the permission was denied — nothing about
/// the app breaks.
class EstadoVisualizador extends ChangeNotifier {
EstadoVisualizador({SharedPreferences? prefs}) : _prefs = prefs {
_cargar();
}
/// Persisted key. Public so tests can assert persistence without
/// duplicating the literal.
static const String claveOndaReal = 'visualizador_onda_real_v1';
final SharedPreferences? _prefs;
bool _ondaRealHabilitada = false;
bool get ondaRealHabilitada => _ondaRealHabilitada;
Future<void> cambiarOndaReal(bool habilitada) async {
if (habilitada == _ondaRealHabilitada) return;
_ondaRealHabilitada = habilitada;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.setBool(claveOndaReal, habilitada);
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final guardado = prefs.getBool(claveOndaReal) ?? false;
if (guardado == _ondaRealHabilitada) return;
_ondaRealHabilitada = guardado;
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
}
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "تعذّر تحميل الدول.", "radioCountriesError": "تعذّر تحميل الدول.",
"recordingActionDelete": "حذف", "recordingActionDelete": "حذف",
"recordingActionRename": "إعادة تسمية", "recordingActionRename": "إعادة تسمية",
"recordingActionShare": "مشاركة", "stationActionShare": "مشاركة المحطة",
"recordingActionOpenIn": "فتح في تطبيق آخر",
"recordingOpenNoAppError": "لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.",
"recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.", "recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟", "recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟",
"recordingRenameDialogTitle": "إعادة تسمية التسجيل", "recordingRenameDialogTitle": "إعادة تسمية التسجيل",
@@ -904,5 +906,31 @@
"restaurarCompras": "استعادة المشتريات", "restaurarCompras": "استعادة المشتريات",
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.", "compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.", "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": "مقطع بلا اسم",
"recordingsPrivateUseNotice": "التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.",
"visualizerRealWaveTitle": "الموجة الحقيقية للصوت",
"visualizerRealWaveSubtitle": "تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.",
"visualizerRealWavePermissionExplanation": "لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.",
"visualizerRealWaveEnableAction": "تفعيل"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "দেশগুলো লোড করা যায়নি।", "radioCountriesError": "দেশগুলো লোড করা যায়নি।",
"recordingActionDelete": "মুছে ফেলুন", "recordingActionDelete": "মুছে ফেলুন",
"recordingActionRename": "নাম পরিবর্তন করুন", "recordingActionRename": "নাম পরিবর্তন করুন",
"recordingActionShare": "শেয়ার করুন", "stationActionShare": "স্টেশন শেয়ার করুন",
"recordingActionOpenIn": "অন্য অ্যাপে খুলুন",
"recordingOpenNoAppError": "এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।",
"recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।", "recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?", "recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?",
"recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন", "recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন",
@@ -904,5 +906,31 @@
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন", "restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।", "compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।", "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": "নামহীন ট্র্যাক",
"recordingsPrivateUseNotice": "রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।",
"visualizerRealWaveTitle": "আসল অডিও তরঙ্গ",
"visualizerRealWaveSubtitle": "বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।",
"visualizerRealWavePermissionExplanation": "মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।",
"visualizerRealWaveEnableAction": "চালু করুন"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Die Länder konnten nicht geladen werden.", "radioCountriesError": "Die Länder konnten nicht geladen werden.",
"recordingActionDelete": "Löschen", "recordingActionDelete": "Löschen",
"recordingActionRename": "Umbenennen", "recordingActionRename": "Umbenennen",
"recordingActionShare": "Teilen", "stationActionShare": "Sender teilen",
"recordingActionOpenIn": "In anderer App öffnen",
"recordingOpenNoAppError": "Keine App auf diesem Gerät kann diese Aufnahme abspielen.",
"recordingDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.", "recordingDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
"recordingDeleteConfirmTitle": "Aufnahme löschen?", "recordingDeleteConfirmTitle": "Aufnahme löschen?",
"recordingRenameDialogTitle": "Aufnahme umbenennen", "recordingRenameDialogTitle": "Aufnahme umbenennen",
@@ -904,5 +906,31 @@
"restaurarCompras": "Käufe wiederherstellen", "restaurarCompras": "Käufe wiederherstellen",
"compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.", "compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.", "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": "Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)",
"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",
"recordingsPrivateUseNotice": "Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.",
"visualizerRealWaveTitle": "Echte Audio-Wellenform",
"visualizerRealWaveSubtitle": "Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.",
"visualizerRealWavePermissionExplanation": "Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.",
"visualizerRealWaveEnableAction": "Aktivieren"
} }
+30 -2
View File
@@ -237,7 +237,9 @@
"recordingsLibraryEmptyTitle": "No recordings yet", "recordingsLibraryEmptyTitle": "No recordings yet",
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.", "recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
"recordingActionRename": "Rename", "recordingActionRename": "Rename",
"recordingActionShare": "Share", "stationActionShare": "Share station",
"recordingActionOpenIn": "Open in another app",
"recordingOpenNoAppError": "No app on this device can play this recording.",
"recordingActionDelete": "Delete", "recordingActionDelete": "Delete",
"recordingRenameDialogTitle": "Rename recording", "recordingRenameDialogTitle": "Rename recording",
"recordingRenameLabel": "Name", "recordingRenameLabel": "Name",
@@ -904,5 +906,31 @@
"restaurarCompras": "Restore purchases", "restaurarCompras": "Restore purchases",
"compraError": "We couldn't complete the purchase. Please try again.", "compraError": "We couldn't complete the purchase. Please try again.",
"restauracionSinCompras": "We didn't find any previous purchase on this account.", "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": "Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)",
"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",
"recordingsPrivateUseNotice": "Recordings are for your own personal use. Please respect each station's rights and those of its content.",
"visualizerRealWaveTitle": "Real audio waveform",
"visualizerRealWaveSubtitle": "The bars follow the sound that is playing. When off, they animate on their own.",
"visualizerRealWavePermissionExplanation": "Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.",
"visualizerRealWaveEnableAction": "Turn on"
} }
+30 -2
View File
@@ -237,7 +237,9 @@
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones", "recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.", "recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
"recordingActionRename": "Renombrar", "recordingActionRename": "Renombrar",
"recordingActionShare": "Compartir", "stationActionShare": "Compartir emisora",
"recordingActionOpenIn": "Abrir en otra app",
"recordingOpenNoAppError": "No hay ninguna app en este dispositivo que pueda reproducir la grabación.",
"recordingActionDelete": "Eliminar", "recordingActionDelete": "Eliminar",
"recordingRenameDialogTitle": "Renombrar grabación", "recordingRenameDialogTitle": "Renombrar grabación",
"recordingRenameLabel": "Nombre", "recordingRenameLabel": "Nombre",
@@ -863,5 +865,31 @@
"restaurarCompras": "Restaurar compras", "restaurarCompras": "Restaurar compras",
"compraError": "No se ha podido completar la compra. Inténtalo de nuevo.", "compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
"restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.", "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": "Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)",
"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",
"recordingsPrivateUseNotice": "Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.",
"visualizerRealWaveTitle": "Onda real del audio",
"visualizerRealWaveSubtitle": "Las barras siguen el sonido que está sonando. Desactivado, se animan solas.",
"visualizerRealWavePermissionExplanation": "Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.",
"visualizerRealWaveEnableAction": "Activar"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Impossible de charger les pays.", "radioCountriesError": "Impossible de charger les pays.",
"recordingActionDelete": "Supprimer", "recordingActionDelete": "Supprimer",
"recordingActionRename": "Renommer", "recordingActionRename": "Renommer",
"recordingActionShare": "Partager", "stationActionShare": "Partager la station",
"recordingActionOpenIn": "Ouvrir dans une autre appli",
"recordingOpenNoAppError": "Aucune application de cet appareil ne peut lire cet enregistrement.",
"recordingDeleteConfirmMessage": "Cette action est irréversible.", "recordingDeleteConfirmMessage": "Cette action est irréversible.",
"recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?", "recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?",
"recordingRenameDialogTitle": "Renommer l'enregistrement", "recordingRenameDialogTitle": "Renommer l'enregistrement",
@@ -904,5 +906,31 @@
"restaurarCompras": "Restaurer les achats", "restaurarCompras": "Restaurer les achats",
"compraError": "Impossible de finaliser l'achat. Veuillez réessayer.", "compraError": "Impossible de finaliser l'achat. Veuillez réessayer.",
"restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.", "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": "Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)",
"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",
"recordingsPrivateUseNotice": "Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.",
"visualizerRealWaveTitle": "Onde audio réelle",
"visualizerRealWaveSubtitle": "Les barres suivent le son en cours. Désactivées, elles s'animent d'elles-mêmes.",
"visualizerRealWavePermissionExplanation": "Android n'autorise la lecture du niveau audio qu'avec l'autorisation du microphone. PluriWave n'écoute ni n'enregistre le microphone : il mesure seulement le son qu'il diffuse déjà. Vous pouvez le désactiver à tout moment.",
"visualizerRealWaveEnableAction": "Activer"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "देश लोड नहीं हो सके।", "radioCountriesError": "देश लोड नहीं हो सके।",
"recordingActionDelete": "हटाएं", "recordingActionDelete": "हटाएं",
"recordingActionRename": "नाम बदलें", "recordingActionRename": "नाम बदलें",
"recordingActionShare": "शेयर करें", "stationActionShare": "स्टेशन शेयर करें",
"recordingActionOpenIn": "दूसरे ऐप में खोलें",
"recordingOpenNoAppError": "इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।",
"recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।", "recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?", "recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?",
"recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें", "recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें",
@@ -904,5 +906,31 @@
"restaurarCompras": "खरीदारी पुनर्स्थापित करें", "restaurarCompras": "खरीदारी पुनर्स्थापित करें",
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।", "compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।", "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": "बिना नाम का ट्रैक",
"recordingsPrivateUseNotice": "रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।",
"visualizerRealWaveTitle": "असली ऑडियो तरंग",
"visualizerRealWaveSubtitle": "बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।",
"visualizerRealWavePermissionExplanation": "Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।",
"visualizerRealWaveEnableAction": "चालू करें"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Negara tidak dapat dimuat.", "radioCountriesError": "Negara tidak dapat dimuat.",
"recordingActionDelete": "Hapus", "recordingActionDelete": "Hapus",
"recordingActionRename": "Ganti nama", "recordingActionRename": "Ganti nama",
"recordingActionShare": "Bagikan", "stationActionShare": "Bagikan stasiun",
"recordingActionOpenIn": "Buka di aplikasi lain",
"recordingOpenNoAppError": "Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.",
"recordingDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.", "recordingDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
"recordingDeleteConfirmTitle": "Hapus rekaman?", "recordingDeleteConfirmTitle": "Hapus rekaman?",
"recordingRenameDialogTitle": "Ganti nama rekaman", "recordingRenameDialogTitle": "Ganti nama rekaman",
@@ -904,5 +906,31 @@
"restaurarCompras": "Pulihkan pembelian", "restaurarCompras": "Pulihkan pembelian",
"compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.", "compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.",
"restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.", "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": "Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)",
"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",
"recordingsPrivateUseNotice": "Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.",
"visualizerRealWaveTitle": "Gelombang audio asli",
"visualizerRealWaveSubtitle": "Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.",
"visualizerRealWavePermissionExplanation": "Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.",
"visualizerRealWaveEnableAction": "Aktifkan"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Non è stato possibile caricare i paesi.", "radioCountriesError": "Non è stato possibile caricare i paesi.",
"recordingActionDelete": "Elimina", "recordingActionDelete": "Elimina",
"recordingActionRename": "Rinomina", "recordingActionRename": "Rinomina",
"recordingActionShare": "Condividi", "stationActionShare": "Condividi stazione",
"recordingActionOpenIn": "Apri in un'altra app",
"recordingOpenNoAppError": "Nessuna app su questo dispositivo può riprodurre questa registrazione.",
"recordingDeleteConfirmMessage": "Questa azione non può essere annullata.", "recordingDeleteConfirmMessage": "Questa azione non può essere annullata.",
"recordingDeleteConfirmTitle": "Eliminare la registrazione?", "recordingDeleteConfirmTitle": "Eliminare la registrazione?",
"recordingRenameDialogTitle": "Rinomina registrazione", "recordingRenameDialogTitle": "Rinomina registrazione",
@@ -904,5 +906,31 @@
"restaurarCompras": "Ripristina acquisti", "restaurarCompras": "Ripristina acquisti",
"compraError": "Non è stato possibile completare l'acquisto. Riprova.", "compraError": "Non è stato possibile completare l'acquisto. Riprova.",
"restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.", "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": "Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)",
"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",
"recordingsPrivateUseNotice": "Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.",
"visualizerRealWaveTitle": "Onda audio reale",
"visualizerRealWaveSubtitle": "Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.",
"visualizerRealWavePermissionExplanation": "Android consente di leggere il livello audio solo con l'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.",
"visualizerRealWaveEnableAction": "Attiva"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "国の一覧を読み込めませんでした。", "radioCountriesError": "国の一覧を読み込めませんでした。",
"recordingActionDelete": "削除", "recordingActionDelete": "削除",
"recordingActionRename": "名前を変更", "recordingActionRename": "名前を変更",
"recordingActionShare": "共有", "stationActionShare": "放送局を共有",
"recordingActionOpenIn": "別のアプリで開く",
"recordingOpenNoAppError": "この端末には、この録音を再生できるアプリがありません。",
"recordingDeleteConfirmMessage": "この操作は元に戻せません。", "recordingDeleteConfirmMessage": "この操作は元に戻せません。",
"recordingDeleteConfirmTitle": "録音を削除しますか?", "recordingDeleteConfirmTitle": "録音を削除しますか?",
"recordingRenameDialogTitle": "録音の名前を変更", "recordingRenameDialogTitle": "録音の名前を変更",
@@ -904,5 +906,31 @@
"restaurarCompras": "購入を復元", "restaurarCompras": "購入を復元",
"compraError": "購入を完了できませんでした。もう一度お試しください。", "compraError": "購入を完了できませんでした。もう一度お試しください。",
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。", "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": "名称未設定のトラック",
"recordingsPrivateUseNotice": "録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。",
"visualizerRealWaveTitle": "実際の音声波形",
"visualizerRealWaveSubtitle": "バーが再生中の音に合わせて動きます。オフのときは独自に動きます。",
"visualizerRealWavePermissionExplanation": "Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。",
"visualizerRealWaveEnableAction": "オンにする"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Não foi possível carregar os países.", "radioCountriesError": "Não foi possível carregar os países.",
"recordingActionDelete": "Excluir", "recordingActionDelete": "Excluir",
"recordingActionRename": "Renomear", "recordingActionRename": "Renomear",
"recordingActionShare": "Compartilhar", "stationActionShare": "Compartilhar estação",
"recordingActionOpenIn": "Abrir em outro app",
"recordingOpenNoAppError": "Nenhum app deste dispositivo consegue reproduzir esta gravação.",
"recordingDeleteConfirmMessage": "Esta ação não pode ser desfeita.", "recordingDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
"recordingDeleteConfirmTitle": "Excluir gravação?", "recordingDeleteConfirmTitle": "Excluir gravação?",
"recordingRenameDialogTitle": "Renomear gravação", "recordingRenameDialogTitle": "Renomear gravação",
@@ -904,5 +906,31 @@
"restaurarCompras": "Restaurar compras", "restaurarCompras": "Restaurar compras",
"compraError": "Não foi possível concluir a compra. Tente novamente.", "compraError": "Não foi possível concluir a compra. Tente novamente.",
"restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.", "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": "Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)",
"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",
"recordingsPrivateUseNotice": "As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.",
"visualizerRealWaveTitle": "Onda de áudio real",
"visualizerRealWaveSubtitle": "As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.",
"visualizerRealWavePermissionExplanation": "O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.",
"visualizerRealWaveEnableAction": "Ativar"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Не удалось загрузить страны.", "radioCountriesError": "Не удалось загрузить страны.",
"recordingActionDelete": "Удалить", "recordingActionDelete": "Удалить",
"recordingActionRename": "Переименовать", "recordingActionRename": "Переименовать",
"recordingActionShare": "Поделиться", "stationActionShare": "Поделиться станцией",
"recordingActionOpenIn": "Открыть в другом приложении",
"recordingOpenNoAppError": "На этом устройстве нет приложения, способного воспроизвести эту запись.",
"recordingDeleteConfirmMessage": "Это действие нельзя отменить.", "recordingDeleteConfirmMessage": "Это действие нельзя отменить.",
"recordingDeleteConfirmTitle": "Удалить запись?", "recordingDeleteConfirmTitle": "Удалить запись?",
"recordingRenameDialogTitle": "Переименовать запись", "recordingRenameDialogTitle": "Переименовать запись",
@@ -904,5 +906,31 @@
"restaurarCompras": "Восстановить покупки", "restaurarCompras": "Восстановить покупки",
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.", "compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.", "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": "Трек без названия",
"recordingsPrivateUseNotice": "Записи предназначены только для личного использования. Уважайте права станций и их контента.",
"visualizerRealWaveTitle": "Реальная звуковая волна",
"visualizerRealWaveSubtitle": "Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.",
"visualizerRealWavePermissionExplanation": "Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.",
"visualizerRealWaveEnableAction": "Включить"
} }
+30 -2
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "无法加载国家列表。", "radioCountriesError": "无法加载国家列表。",
"recordingActionDelete": "删除", "recordingActionDelete": "删除",
"recordingActionRename": "重命名", "recordingActionRename": "重命名",
"recordingActionShare": "分享", "stationActionShare": "分享电台",
"recordingActionOpenIn": "用其他应用打开",
"recordingOpenNoAppError": "此设备上没有可播放该录音的应用。",
"recordingDeleteConfirmMessage": "此操作无法撤销。", "recordingDeleteConfirmMessage": "此操作无法撤销。",
"recordingDeleteConfirmTitle": "删除录音?", "recordingDeleteConfirmTitle": "删除录音?",
"recordingRenameDialogTitle": "重命名录音", "recordingRenameDialogTitle": "重命名录音",
@@ -904,5 +906,31 @@
"restaurarCompras": "恢复购买", "restaurarCompras": "恢复购买",
"compraError": "无法完成购买,请重试。", "compraError": "无法完成购买,请重试。",
"restauracionSinCompras": "未在此账户中找到以前的购买记录。", "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": "未命名曲目",
"recordingsPrivateUseNotice": "录音仅供你个人使用。请尊重各电台及其内容的权利。",
"visualizerRealWaveTitle": "真实音频波形",
"visualizerRealWaveSubtitle": "音条会跟随正在播放的声音。关闭时,音条会自行跳动。",
"visualizerRealWavePermissionExplanation": "Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。",
"visualizerRealWaveEnableAction": "开启"
} }
+171 -3
View File
@@ -904,11 +904,23 @@ abstract class AppLocalizations {
/// **'Renombrar'** /// **'Renombrar'**
String get recordingActionRename; String get recordingActionRename;
/// No description provided for @recordingActionShare. /// No description provided for @stationActionShare.
/// ///
/// In es, this message translates to: /// In es, this message translates to:
/// **'Compartir'** /// **'Compartir emisora'**
String get recordingActionShare; String get stationActionShare;
/// No description provided for @recordingActionOpenIn.
///
/// In es, this message translates to:
/// **'Abrir en otra app'**
String get recordingActionOpenIn;
/// No description provided for @recordingOpenNoAppError.
///
/// In es, this message translates to:
/// **'No hay ninguna app en este dispositivo que pueda reproducir la grabación.'**
String get recordingOpenNoAppError;
/// No description provided for @recordingActionDelete. /// No description provided for @recordingActionDelete.
/// ///
@@ -3367,6 +3379,162 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'Premium activo'** /// **'Premium activo'**
String get premiumActivo; 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:
/// **'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)'**
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;
/// No description provided for @recordingsPrivateUseNotice.
///
/// In es, this message translates to:
/// **'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.'**
String get recordingsPrivateUseNotice;
/// No description provided for @visualizerRealWaveTitle.
///
/// In es, this message translates to:
/// **'Onda real del audio'**
String get visualizerRealWaveTitle;
/// No description provided for @visualizerRealWaveSubtitle.
///
/// In es, this message translates to:
/// **'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.'**
String get visualizerRealWaveSubtitle;
/// No description provided for @visualizerRealWavePermissionExplanation.
///
/// In es, this message translates to:
/// **'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.'**
String get visualizerRealWavePermissionExplanation;
/// No description provided for @visualizerRealWaveEnableAction.
///
/// In es, this message translates to:
/// **'Activar'**
String get visualizerRealWaveEnableAction;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+94 -1
View File
@@ -456,7 +456,14 @@ class AppLocalizationsAr extends AppLocalizations {
String get recordingActionRename => 'إعادة تسمية'; String get recordingActionRename => 'إعادة تسمية';
@override @override
String get recordingActionShare => 'مشاركة'; String get stationActionShare => 'مشاركة المحطة';
@override
String get recordingActionOpenIn => 'فتح في تطبيق آخر';
@override
String get recordingOpenNoAppError =>
'لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.';
@override @override
String get recordingActionDelete => 'حذف'; String get recordingActionDelete => 'حذف';
@@ -1863,4 +1870,90 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get premiumActivo => 'النسخة المميزة مفعّلة'; 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 => 'مقطع بلا اسم';
@override
String get recordingsPrivateUseNotice =>
'التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.';
@override
String get visualizerRealWaveTitle => 'الموجة الحقيقية للصوت';
@override
String get visualizerRealWaveSubtitle =>
'تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.';
@override
String get visualizerRealWavePermissionExplanation =>
'لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.';
@override
String get visualizerRealWaveEnableAction => 'تفعيل';
} }
+95 -1
View File
@@ -461,7 +461,14 @@ class AppLocalizationsBn extends AppLocalizations {
String get recordingActionRename => 'নাম পরিবর্তন করুন'; String get recordingActionRename => 'নাম পরিবর্তন করুন';
@override @override
String get recordingActionShare => 'শেয়ার করুন'; String get stationActionShare => 'স্টেশন শেয়ার করুন';
@override
String get recordingActionOpenIn => 'অন্য অ্যাপে খুলুন';
@override
String get recordingOpenNoAppError =>
'এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।';
@override @override
String get recordingActionDelete => 'মুছে ফেলুন'; String get recordingActionDelete => 'মুছে ফেলুন';
@@ -1874,4 +1881,91 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get premiumActivo => 'প্রিমিয়াম সক্রিয়'; 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 => 'নামহীন ট্র্যাক';
@override
String get recordingsPrivateUseNotice =>
'রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।';
@override
String get visualizerRealWaveTitle => 'আসল অডিও তরঙ্গ';
@override
String get visualizerRealWaveSubtitle =>
'বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।';
@override
String get visualizerRealWavePermissionExplanation =>
'মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।';
@override
String get visualizerRealWaveEnableAction => 'চালু করুন';
} }
+94 -1
View File
@@ -464,7 +464,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get recordingActionRename => 'Umbenennen'; String get recordingActionRename => 'Umbenennen';
@override @override
String get recordingActionShare => 'Teilen'; String get stationActionShare => 'Sender teilen';
@override
String get recordingActionOpenIn => 'In anderer App öffnen';
@override
String get recordingOpenNoAppError =>
'Keine App auf diesem Gerät kann diese Aufnahme abspielen.';
@override @override
String get recordingActionDelete => 'Löschen'; String get recordingActionDelete => 'Löschen';
@@ -1888,4 +1895,90 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium aktiv'; 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 =>
'Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)';
@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';
@override
String get recordingsPrivateUseNotice =>
'Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.';
@override
String get visualizerRealWaveTitle => 'Echte Audio-Wellenform';
@override
String get visualizerRealWaveSubtitle =>
'Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.';
@override
String get visualizerRealWaveEnableAction => 'Aktivieren';
} }
+95 -1
View File
@@ -458,7 +458,14 @@ class AppLocalizationsEn extends AppLocalizations {
String get recordingActionRename => 'Rename'; String get recordingActionRename => 'Rename';
@override @override
String get recordingActionShare => 'Share'; String get stationActionShare => 'Share station';
@override
String get recordingActionOpenIn => 'Open in another app';
@override
String get recordingOpenNoAppError =>
'No app on this device can play this recording.';
@override @override
String get recordingActionDelete => 'Delete'; String get recordingActionDelete => 'Delete';
@@ -1867,4 +1874,91 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium active'; 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 =>
'Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)';
@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';
@override
String get recordingsPrivateUseNotice =>
'Recordings are for your own personal use. Please respect each station\'s rights and those of its content.';
@override
String get visualizerRealWaveTitle => 'Real audio waveform';
@override
String get visualizerRealWaveSubtitle =>
'The bars follow the sound that is playing. When off, they animate on their own.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.';
@override
String get visualizerRealWaveEnableAction => 'Turn on';
} }
+96 -1
View File
@@ -462,7 +462,14 @@ class AppLocalizationsEs extends AppLocalizations {
String get recordingActionRename => 'Renombrar'; String get recordingActionRename => 'Renombrar';
@override @override
String get recordingActionShare => 'Compartir'; String get stationActionShare => 'Compartir emisora';
@override
String get recordingActionOpenIn => 'Abrir en otra app';
@override
String get recordingOpenNoAppError =>
'No hay ninguna app en este dispositivo que pueda reproducir la grabación.';
@override @override
String get recordingActionDelete => 'Eliminar'; String get recordingActionDelete => 'Eliminar';
@@ -1881,4 +1888,92 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium activo'; 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 =>
'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)';
@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';
@override
String get recordingsPrivateUseNotice =>
'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.';
@override
String get visualizerRealWaveTitle => 'Onda real del audio';
@override
String get visualizerRealWaveSubtitle =>
'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.';
@override
String get visualizerRealWaveEnableAction => 'Activar';
} }
+97 -1
View File
@@ -467,7 +467,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get recordingActionRename => 'Renommer'; String get recordingActionRename => 'Renommer';
@override @override
String get recordingActionShare => 'Partager'; String get stationActionShare => 'Partager la station';
@override
String get recordingActionOpenIn => 'Ouvrir dans une autre appli';
@override
String get recordingOpenNoAppError =>
'Aucune application de cet appareil ne peut lire cet enregistrement.';
@override @override
String get recordingActionDelete => 'Supprimer'; String get recordingActionDelete => 'Supprimer';
@@ -1894,4 +1901,93 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium actif'; 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 =>
'Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)';
@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';
@override
String get recordingsPrivateUseNotice =>
'Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.';
@override
String get visualizerRealWaveTitle => 'Onde audio réelle';
@override
String get visualizerRealWaveSubtitle =>
'Les barres suivent le son en cours. Désactivées, elles s\'animent d\'elles-mêmes.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android n\'autorise la lecture du niveau audio qu\'avec l\'autorisation du microphone. PluriWave n\'écoute ni n\'enregistre le microphone : il mesure seulement le son qu\'il diffuse déjà. Vous pouvez le désactiver à tout moment.';
@override
String get visualizerRealWaveEnableAction => 'Activer';
} }
+95 -1
View File
@@ -459,7 +459,14 @@ class AppLocalizationsHi extends AppLocalizations {
String get recordingActionRename => 'नाम बदलें'; String get recordingActionRename => 'नाम बदलें';
@override @override
String get recordingActionShare => 'शेयर करें'; String get stationActionShare => 'स्टेशन शेयर करें';
@override
String get recordingActionOpenIn => 'दूसरे ऐप में खोलें';
@override
String get recordingOpenNoAppError =>
'इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।';
@override @override
String get recordingActionDelete => 'हटाएं'; String get recordingActionDelete => 'हटाएं';
@@ -1867,4 +1874,91 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get premiumActivo => 'प्रीमियम सक्रिय'; 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 => 'बिना नाम का ट्रैक';
@override
String get recordingsPrivateUseNotice =>
'रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।';
@override
String get visualizerRealWaveTitle => 'असली ऑडियो तरंग';
@override
String get visualizerRealWaveSubtitle =>
'बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।';
@override
String get visualizerRealWavePermissionExplanation =>
'Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।';
@override
String get visualizerRealWaveEnableAction => 'चालू करें';
} }
+95 -1
View File
@@ -459,7 +459,14 @@ class AppLocalizationsId extends AppLocalizations {
String get recordingActionRename => 'Ganti nama'; String get recordingActionRename => 'Ganti nama';
@override @override
String get recordingActionShare => 'Bagikan'; String get stationActionShare => 'Bagikan stasiun';
@override
String get recordingActionOpenIn => 'Buka di aplikasi lain';
@override
String get recordingOpenNoAppError =>
'Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.';
@override @override
String get recordingActionDelete => 'Hapus'; String get recordingActionDelete => 'Hapus';
@@ -1878,4 +1885,91 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium aktif'; 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 =>
'Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)';
@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';
@override
String get recordingsPrivateUseNotice =>
'Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.';
@override
String get visualizerRealWaveTitle => 'Gelombang audio asli';
@override
String get visualizerRealWaveSubtitle =>
'Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.';
@override
String get visualizerRealWaveEnableAction => 'Aktifkan';
} }
+97 -1
View File
@@ -463,7 +463,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get recordingActionRename => 'Rinomina'; String get recordingActionRename => 'Rinomina';
@override @override
String get recordingActionShare => 'Condividi'; String get stationActionShare => 'Condividi stazione';
@override
String get recordingActionOpenIn => 'Apri in un\'altra app';
@override
String get recordingOpenNoAppError =>
'Nessuna app su questo dispositivo può riprodurre questa registrazione.';
@override @override
String get recordingActionDelete => 'Elimina'; String get recordingActionDelete => 'Elimina';
@@ -1891,4 +1898,93 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium attivo'; 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 =>
'Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)';
@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';
@override
String get recordingsPrivateUseNotice =>
'Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.';
@override
String get visualizerRealWaveTitle => 'Onda audio reale';
@override
String get visualizerRealWaveSubtitle =>
'Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android consente di leggere il livello audio solo con l\'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.';
@override
String get visualizerRealWaveEnableAction => 'Attiva';
} }
+90 -1
View File
@@ -445,7 +445,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get recordingActionRename => '名前を変更'; String get recordingActionRename => '名前を変更';
@override @override
String get recordingActionShare => '共有'; String get stationActionShare => '放送局を共有';
@override
String get recordingActionOpenIn => '別のアプリで開く';
@override
String get recordingOpenNoAppError => 'この端末には、この録音を再生できるアプリがありません。';
@override @override
String get recordingActionDelete => '削除'; String get recordingActionDelete => '削除';
@@ -1812,4 +1818,87 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get premiumActivo => 'プレミアム有効'; 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 => '名称未設定のトラック';
@override
String get recordingsPrivateUseNotice =>
'録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。';
@override
String get visualizerRealWaveTitle => '実際の音声波形';
@override
String get visualizerRealWaveSubtitle => 'バーが再生中の音に合わせて動きます。オフのときは独自に動きます。';
@override
String get visualizerRealWavePermissionExplanation =>
'Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。';
@override
String get visualizerRealWaveEnableAction => 'オンにする';
} }
+95 -1
View File
@@ -461,7 +461,14 @@ class AppLocalizationsPt extends AppLocalizations {
String get recordingActionRename => 'Renomear'; String get recordingActionRename => 'Renomear';
@override @override
String get recordingActionShare => 'Compartilhar'; String get stationActionShare => 'Compartilhar estação';
@override
String get recordingActionOpenIn => 'Abrir em outro app';
@override
String get recordingOpenNoAppError =>
'Nenhum app deste dispositivo consegue reproduzir esta gravação.';
@override @override
String get recordingActionDelete => 'Excluir'; String get recordingActionDelete => 'Excluir';
@@ -1878,4 +1885,91 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get premiumActivo => 'Premium ativo'; 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 =>
'Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)';
@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';
@override
String get recordingsPrivateUseNotice =>
'As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.';
@override
String get visualizerRealWaveTitle => 'Onda de áudio real';
@override
String get visualizerRealWaveSubtitle =>
'As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.';
@override
String get visualizerRealWavePermissionExplanation =>
'O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.';
@override
String get visualizerRealWaveEnableAction => 'Ativar';
} }
+96 -1
View File
@@ -461,7 +461,14 @@ class AppLocalizationsRu extends AppLocalizations {
String get recordingActionRename => 'Переименовать'; String get recordingActionRename => 'Переименовать';
@override @override
String get recordingActionShare => 'Поделиться'; String get stationActionShare => 'Поделиться станцией';
@override
String get recordingActionOpenIn => 'Открыть в другом приложении';
@override
String get recordingOpenNoAppError =>
'На этом устройстве нет приложения, способного воспроизвести эту запись.';
@override @override
String get recordingActionDelete => 'Удалить'; String get recordingActionDelete => 'Удалить';
@@ -1884,4 +1891,92 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get premiumActivo => 'Премиум активен'; 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 => 'Трек без названия';
@override
String get recordingsPrivateUseNotice =>
'Записи предназначены только для личного использования. Уважайте права станций и их контента.';
@override
String get visualizerRealWaveTitle => 'Реальная звуковая волна';
@override
String get visualizerRealWaveSubtitle =>
'Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.';
@override
String get visualizerRealWaveEnableAction => 'Включить';
} }
+88 -1
View File
@@ -443,7 +443,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get recordingActionRename => '重命名'; String get recordingActionRename => '重命名';
@override @override
String get recordingActionShare => '分享'; String get stationActionShare => '分享电台';
@override
String get recordingActionOpenIn => '用其他应用打开';
@override
String get recordingOpenNoAppError => '此设备上没有可播放该录音的应用。';
@override @override
String get recordingActionDelete => '删除'; String get recordingActionDelete => '删除';
@@ -1797,4 +1803,85 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get premiumActivo => '高级版已解锁'; 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 => '未命名曲目';
@override
String get recordingsPrivateUseNotice => '录音仅供你个人使用。请尊重各电台及其内容的权利。';
@override
String get visualizerRealWaveTitle => '真实音频波形';
@override
String get visualizerRealWaveSubtitle => '音条会跟随正在播放的声音。关闭时,音条会自行跳动。';
@override
String get visualizerRealWavePermissionExplanation =>
'Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。';
@override
String get visualizerRealWaveEnableAction => '开启';
} }
+140 -8
View File
@@ -10,13 +10,17 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart'; import 'app.dart';
import 'estado/estado_entitlement.dart'; import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart'; import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart'; import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart'; import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart'; import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart'; import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart'; import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart'; import 'servicios/servicio_presets_personalizados.dart';
import 'servicios/verificacion_licencia.dart';
import 'tema/pluriwave_tokens.dart'; import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0; const _anchoMinimoLandscape = 600.0;
@@ -88,7 +92,7 @@ Future<void> main() async {
// //
// Regression this fixes, self-inflicted by the reordering above: the root // Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with // 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 // 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, // 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 // and be told there is no local music — and Android Auto caches the browse
@@ -103,7 +107,7 @@ Future<void> main() async {
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl()); registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never // 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 // it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway. // actually take effect anyway.
unawaited(aplicarPoliticaOrientacion()); unawaited(aplicarPoliticaOrientacion());
@@ -143,6 +147,16 @@ Future<void> main() async {
// injected into every state/service below. // injected into every state/service below.
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Silent license re-verification (refund revocation) for the Android Auto
// path: this engine may be the headless one, with no widget tree and so no
// `EstadoEntitlement`. The browse root triggers it fire-and-forget; the
// shared in-flight guard in `verificarLicencia` keeps it to one query at a
// time even when the phone UI checks too.
registrarVerificacionLicenciaAuto(
() =>
verificarLicencia(consultar: compras.consultarPropiedad, prefs: prefs),
);
// User-saved EQ presets for the car's Ecualizador folder, same // User-saved EQ presets for the car's Ecualizador folder, same
// injectable-prefs DI convention and same pre-init placement as the two // injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a // registrations above (neither depends on the AudioHandler). Passed as a
@@ -151,6 +165,18 @@ Future<void> main() async {
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs); final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar); 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` // Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners // had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches // drops what it is given — so every exception `audio_service` catches
@@ -179,7 +205,33 @@ Future<void> main() async {
// radio; headphones unplugged pauses it. Shared by both the on-time and // radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below. // degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) { 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 // The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels // (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention // with it and can never leak — same "register from main.dart" convention
@@ -189,7 +241,7 @@ Future<void> main() async {
unawaited(sesionAudio.configurar()); unawaited(sesionAudio.configurar());
} }
Widget construirApp() => _OrientacionResponsiveApp( Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras), child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
); );
@@ -268,20 +320,79 @@ Future<void> aplicarPoliticaOrientacion({
} }
} }
class _OrientacionResponsiveApp extends StatefulWidget { /// Whether the Android Auto browse tree must be invalidated right now
const _OrientacionResponsiveApp({required this.child}); /// (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; final Widget child;
@override @override
State<_OrientacionResponsiveApp> createState() => State<OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState(); _OrientacionResponsiveAppState();
} }
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp> class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
with WidgetsBindingObserver { with WidgetsBindingObserver {
ui.Display? _display; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -295,6 +406,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
unawaited(aplicarPoliticaOrientacion(display: _display)); 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 @override
void didChangeMetrics() { void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display)); unawaited(aplicarPoliticaOrientacion(display: _display));
@@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile; import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../../estado/estado_alarmas.dart';
import '../../estado/estado_radio.dart'; import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart'; import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart'; import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart'; import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.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 /// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` — /// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's /// 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 (confirmar != true) return;
if (context.mounted) { if (context.mounted) {
final estado = context.read<EstadoRadio>(); final estado = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
await estado.importarConfig(json); await aplicarImportacionConfig(estado, alarmas, json);
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)), SnackBar(content: Text(l10n.backupImportSuccess)),
); );
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart'; import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart'; import '../../servicios/musica_local_auto.dart';
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../../widgets/pluri_glass_surface.dart'; import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart'; import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart'; import '../../widgets/pluri_push_scaffold.dart';
@@ -54,6 +55,15 @@ class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final uri = await _fuente.elegirCarpeta(); final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance. // 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; if (!context.mounted) return;
setState(() { setState(() {
_carpetaActual = Future.value(uri); _carpetaActual = Future.value(uri);
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_visualizador.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// AUDIO group · "Onda real del audio" — the point-of-intent opt-in for the
/// waveform visualizer's microphone permission.
///
/// Before this screen existed, `RECORD_AUDIO` was requested the moment
/// `VisualizadorAudio` subscribed to its native EventChannel, which the home
/// screen's "Escuchar" hero does on the user's FIRST play. A radio app that
/// pops "allow PluriWave to record audio?" the first time you press play is
/// asking for a sensitive permission with zero context, and Play expects
/// context.
///
/// Mirrors `PantallaAjustesSalidaAudio`'s point-of-intent shape (its
/// BLUETOOTH_CONNECT request sits behind the multi-device toggle the same
/// way), with one addition: the explanation is shown and accepted BEFORE the
/// flag flips, since flipping it is what triggers the system dialog.
class PantallaAjustesVisualizador extends StatelessWidget {
const PantallaAjustesVisualizador({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.visualizerRealWaveTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoVisualizador()],
),
);
}
}
class _CuerpoVisualizador extends StatelessWidget {
const _CuerpoVisualizador();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoVisualizador>();
final habilitada = estado.ondaRealHabilitada;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// GestureDetector + custom row instead of SwitchListTile, for the
// same reason PantallaAjustesSalidaAudio does it: ListTile ink
// inside PluriGlassSurface's DecoratedBox trips a Material
// assertion.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _alternar(context, estado, !habilitada),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.visualizerRealWaveTitle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.visualizerRealWaveSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: habilitada,
onChanged: (valor) => _alternar(context, estado, valor),
),
],
),
),
const SizedBox(height: 8),
// The same text the confirmation dialog shows, kept permanently on
// screen: a user who already granted the permission should be able
// to re-read what it is for without toggling anything.
Text(
l10n.visualizerRealWavePermissionExplanation,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
/// Turning it OFF is immediate — withdrawing a permission must never be
/// harder than granting it. Turning it ON goes through the explanation
/// first, and only a deliberate confirmation flips the flag.
Future<void> _alternar(
BuildContext context,
EstadoVisualizador estado,
bool habilitada,
) async {
if (!habilitada) {
await estado.cambiarOndaReal(false);
return;
}
final l10n = AppLocalizations.of(context);
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
key: const ValueKey('visualizador-explicacion-permiso'),
title: Text(l10n.visualizerRealWaveTitle),
content: Text(l10n.visualizerRealWavePermissionExplanation),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.visualizerRealWaveEnableAction),
),
],
),
);
if (confirmado != true) return;
await estado.cambiarOndaReal(true);
}
}
+21
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../estado/estado_ecualizador.dart'; import '../estado/estado_ecualizador.dart';
import '../estado/estado_entitlement.dart'; import '../estado/estado_entitlement.dart';
import '../estado/estado_grabacion.dart'; import '../estado/estado_grabacion.dart';
import '../estado/estado_visualizador.dart';
import '../estado/estado_idioma.dart'; import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../l10n/display_names.dart'; import '../l10n/display_names.dart';
@@ -27,6 +28,7 @@ import 'ajustes/pantalla_ajustes_musica_local.dart';
import 'ajustes/pantalla_ajustes_orden_listas.dart'; import 'ajustes/pantalla_ajustes_orden_listas.dart';
import 'ajustes/pantalla_ajustes_salida_audio.dart'; import 'ajustes/pantalla_ajustes_salida_audio.dart';
import 'ajustes/pantalla_ajustes_timer_sueno.dart'; import 'ajustes/pantalla_ajustes_timer_sueno.dart';
import 'ajustes/pantalla_ajustes_visualizador.dart';
import 'ajustes/widgets/fila_ajuste.dart'; import 'ajustes/widgets/fila_ajuste.dart';
import 'pantalla_grabaciones.dart'; import 'pantalla_grabaciones.dart';
@@ -104,6 +106,9 @@ class _AjustesContent extends StatelessWidget {
final esPremium = context.select<EstadoEntitlement, bool>( final esPremium = context.select<EstadoEntitlement, bool>(
(e) => e.esPremium, (e) => e.esPremium,
); );
final ondaRealActiva = context.select<EstadoVisualizador, bool>(
(e) => e.ondaRealHabilitada,
);
return Column( return Column(
children: [ children: [
@@ -137,6 +142,22 @@ class _AjustesContent extends StatelessWidget {
(_) => const PantallaAjustesSalidaAudio(), (_) => const PantallaAjustesSalidaAudio(),
), ),
), ),
// Point-of-intent entry for the waveform visualizer's
// microphone opt-in. It is a nav row, not an inline switch,
// because the Settings root carries zero inline controls by
// design — the switch and its explanation live on the detail
// screen, which is also where the user reads what the
// permission is for before granting it.
FilaAjuste(
icon: Icons.graphic_eq_rounded,
titulo: l10n.visualizerRealWaveTitle,
valor: ondaRealActiva ? l10n.equalizerActive : null,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesVisualizador(),
),
),
FilaAjuste( FilaAjuste(
icon: Icons.bedtime_rounded, icon: Icons.bedtime_rounded,
titulo: l10n.timerSectionTitle, titulo: l10n.timerSectionTitle,
+53 -13
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart'; import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
@@ -90,19 +89,24 @@ class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
/// WU15: the recordings library — storage usage, browsable rows with /// WU15: the recordings library — storage usage, browsable rows with
/// inline playback, and a "⋮" menu constrained to exactly /// inline playback, and a "⋮" menu constrained to exactly
/// Rename/Share/Delete (`recordings-library` spec). Distinct from /// Rename/Open-in-another-app/Delete (`recordings-library` spec). Distinct
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit /// from `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// SETTINGS screen, not this browsable file list. /// SETTINGS screen, not this browsable file list.
class PantallaGrabaciones extends StatefulWidget { class PantallaGrabaciones extends StatefulWidget {
const PantallaGrabaciones({ const PantallaGrabaciones({
super.key, super.key,
ReproductorGrabaciones? reproductor, ReproductorGrabaciones? reproductor,
Future<void> Function(String ruta)? compartir, Future<bool> Function(String ruta)? abrirEnOtraApp,
}) : _reproductorInyectado = reproductor, }) : _reproductorInyectado = reproductor,
_compartirInyectado = compartir; _abrirEnOtraAppInyectada = abrirEnOtraApp;
final ReproductorGrabaciones? _reproductorInyectado; final ReproductorGrabaciones? _reproductorInyectado;
final Future<void> Function(String ruta)? _compartirInyectado;
/// Seam for the local-open action. Was `compartir`, which handed the audio
/// file to the system share sheet — redistribution of someone else's
/// broadcast. It now opens the file in a player already installed on THIS
/// device, and returns whether any app accepted it.
final Future<bool> Function(String ruta)? _abrirEnOtraAppInyectada;
@override @override
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState(); State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
@@ -111,8 +115,9 @@ class PantallaGrabaciones extends StatefulWidget {
class _PantallaGrabacionesState extends State<PantallaGrabaciones> { class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
late final ReproductorGrabaciones _reproductor = late final ReproductorGrabaciones _reproductor =
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio(); widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
late final Future<void> Function(String ruta) _compartir = late final Future<bool> Function(String ruta) _abrirEnOtraApp =
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]); widget._abrirEnOtraAppInyectada ??
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(ruta);
late Future<List<ArchivoGrabacion>> _grabaciones; late Future<List<ArchivoGrabacion>> _grabaciones;
final Map<String, Future<Duration?>> _duracionCache = {}; final Map<String, Future<Duration?>> _duracionCache = {};
@@ -153,8 +158,8 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
await _renombrar(archivo); await _renombrar(archivo);
return; return;
} }
if (accion == 'share') { if (accion == 'open') {
await _compartir(archivo.ruta); await _abrirLocalmente(archivo);
return; return;
} }
if (accion == 'delete') { if (accion == 'delete') {
@@ -162,6 +167,20 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
} }
} }
/// Plays the user's own recording in another app on the same device. A
/// device with no audio viewer installed (and the native side's own
/// fallback to the containing folder failing too) returns `false` — the
/// action then says so instead of looking like a dead menu entry.
Future<void> _abrirLocalmente(ArchivoGrabacion archivo) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final abierto = await _abrirEnOtraApp(archivo.ruta);
if (!mounted || abierto) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingOpenNoAppError)),
);
}
Future<void> _renombrar(ArchivoGrabacion archivo) async { Future<void> _renombrar(ArchivoGrabacion archivo) async {
final nuevoNombre = await showDialog<String>( final nuevoNombre = await showDialog<String>(
context: context, context: context,
@@ -286,6 +305,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
), ),
], ],
), ),
// Production-readiness pass: recording a broadcast holds up as
// a private copy, and stops holding up the moment the product
// reads as a redistribution tool. The library had no such
// statement at all, while the manifest already publishes the
// recordings folder to the system file manager
// (RecordingsDocumentsProvider). Deliberately factual and
// low-key — a footnote, not a warning banner — and always
// visible, empty library included.
const SizedBox(height: 16),
Padding(
key: const ValueKey('grabaciones-aviso-uso-privado'),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
l10n.recordingsPrivateUseNotice,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
),
], ],
); );
}, },
@@ -373,7 +413,7 @@ class _FilaGrabacion extends StatelessWidget {
// 44x44/radius-12 art placeholder (recordings carry no per-station // 44x44/radius-12 art placeholder (recordings carry no per-station
// favicon, so this is a themed fallback square, not invented artwork), // favicon, so this is a themed fallback square, not invented artwork),
// name, meta line, a 24px play/pause affordance, and the SAME "-" // name, meta line, a 24px play/pause affordance, and the SAME "-"
// menu (Rename/Share/Delete) as before, just restyled. // menu (Rename/Open in another app/Delete) as before, just restyled.
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row( child: Row(
@@ -466,8 +506,8 @@ class _FilaGrabacion extends StatelessWidget {
child: Text(l10n.recordingActionRename), child: Text(l10n.recordingActionRename),
), ),
PopupMenuItem( PopupMenuItem(
value: 'share', value: 'open',
child: Text(l10n.recordingActionShare), child: Text(l10n.recordingActionOpenIn),
), ),
PopupMenuItem( PopupMenuItem(
value: 'delete', value: 'delete',
+9
View File
@@ -6,6 +6,7 @@ import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_ecualizador.dart'; import '../estado/estado_ecualizador.dart';
import '../estado/estado_navegacion.dart'; import '../estado/estado_navegacion.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../estado/estado_visualizador.dart';
import '../l10n/display_names.dart'; import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
@@ -471,6 +472,14 @@ class _EscucharHero extends StatelessWidget {
// Audit 1.7 (t4 lines 66-68): 30 discrete // Audit 1.7 (t4 lines 66-68): 30 discrete
// bottom-anchored bars, not a continuous stroke. // bottom-anchored bars, not a continuous stroke.
barrasDiscretas: true, barrasDiscretas: true,
// Sensitive-permission gate: subscribing to the
// native waveform channel is what makes Android ask
// for RECORD_AUDIO, so it happens only after the
// user opts in from Settings.
capturaRealHabilitada:
context
.watch<EstadoVisualizador>()
.ondaRealHabilitada,
), ),
], ],
), ),
+10 -1
View File
@@ -9,6 +9,7 @@ import 'package:shimmer/shimmer.dart';
import '../estado/estado_ecualizador.dart'; import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart'; import '../estado/estado_grabacion.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../estado/estado_visualizador.dart';
import '../l10n/gen/app_localizations.dart'; import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
@@ -185,6 +186,10 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
color: tokens.warmCoral, color: tokens.warmCoral,
altura: 40, altura: 40,
barrasDiscretas: true, barrasDiscretas: true,
// Sensitive-permission gate: see the same note on the
// Escuchar hero's visualizer in `pantalla_inicio.dart`.
capturaRealHabilitada:
context.watch<EstadoVisualizador>().ondaRealHabilitada,
gradienteFinAlpha: 0.45, gradienteFinAlpha: 0.45,
).pluriFadeIn( ).pluriFadeIn(
context, context,
@@ -1133,7 +1138,11 @@ class _BandejaHerramientas extends StatelessWidget {
child: _TileHerramienta( child: _TileHerramienta(
key: const Key('player-tool-share'), key: const Key('player-tool-share'),
icon: Icons.share_rounded, icon: Icons.share_rounded,
label: l10n.recordingActionShare, // Shares the STATION — its name and its stream url — never an
// audio file. This used to borrow `recordingActionShare`, the
// recordings library's own menu label, which made one key stand
// for two unrelated actions.
label: l10n.stationActionShare,
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'), onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
), ),
), ),
+11 -5
View File
@@ -5,11 +5,17 @@ import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart'; import '../tema/pluriwave_tokens.dart';
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design /// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
/// "Timeout without re-init"): the vendored `audio_service` plugin's /// "Timeout without re-init"): the `audio_service` plugin's self-bind has no
/// self-bind has no native timeout and an unhandled `onConnectionSuspended` /// native timeout and an unhandled `onConnectionSuspended` case, so under
/// case, so under bind contention (Android Auto cold start) the handshake /// bind contention (Android Auto cold start) the handshake can hang forever.
/// can hang forever. Top-level const so tests can reference the production /// Top-level const so tests can reference the production value without
/// value without duplicating it. /// 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); const timeoutArranqueAudio = Duration(seconds: 8);
/// Outcome of racing an `AudioService.init` future against /// 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 /// instance rather than mutating in place, mirroring how
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler` /// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without /// (`controlador_reconexion.dart`) so this stays fully unit-testable without
/// the handler (which cannot be instantiated in unit tests — see this /// the handler. (That last clause used to read "which cannot be instantiated
/// module's sibling test file's doc comment). /// 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 { class ColaLocal {
const ColaLocal({required this.pistas, this.indice = 0}); 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 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -71,6 +72,24 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
return recortado.isEmpty ? nombreGenerico : recortado; 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 /// Browse-source abstraction for the local-music branch of the Android Auto
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s /// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as /// (`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. /// browse domain, not a station source.
abstract class FuenteMusicaLocalAuto { abstract class FuenteMusicaLocalAuto {
/// Whether a local-music root folder is picked AND its permission is /// Whether a local-music root folder is picked AND its permission is
/// still valid. Never throws — a revoked/never-granted permission /// still valid — o si esa pregunta no se puede contestar porque el canal
/// degrades to `false` (Spec "Permission revoked or never granted"). /// nativo no existe en este motor. Never throws: cualquier fallo degrada
Future<bool> hayCarpetaConfigurada(); /// 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 /// Immediate children of [documentId] (`''` = the tree root itself), one
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager /// SAF level deep (Design "Lazy per-folder enumeration, never an eager
@@ -197,18 +218,56 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
} }
@override @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 { try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', { final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
'treeUri': uri, 'treeUri': uri,
}); });
return valido ?? false; return valido == true
} catch (_) { ? 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 // Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted"): never throw, degrade to "not configured". // 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/grupo_favoritos.dart';
import '../modelos/pista_local.dart'; import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart'; import '../modelos/preset_ecualizador.dart';
import 'contexto_reproduccion.dart';
import 'emisoras_destacadas.dart';
import 'musica_local_auto.dart'; import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart'; import 'persistencia_tolerante.dart';
import 'servicio_favoritos.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 /// Pure builder for the Android Auto browse tree: folders, leaf items, id
/// resolution. No platform dependency — fully testable without a running /// resolution. No platform dependency — fully testable without a running
/// car or a real `AudioHandler`. /// car or a real `AudioHandler`.
class ConstructorArbolAuto { 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 /// Root folder ids (Design "media-id scheme"). The tree root itself is
/// identified by [AudioService.browsableRootId], not by a constant here — /// identified by [AudioService.browsableRootId], not by a constant here —
/// the handler compares against it directly before calling [raiz]. /// the handler compares against it directly before calling [raiz].
@@ -211,6 +300,17 @@ class ConstructorArbolAuto {
static const idTodas = 'todas'; static const idTodas = 'todas';
static const idMisEmisoras = 'mis_emisoras'; 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 /// Root folder id for the local-music browsable root (Design "media-id
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own /// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list /// dedicated branch (`hijosMusicaLocal`), not the generic station-list
@@ -229,6 +329,17 @@ class ConstructorArbolAuto {
/// hidden. /// hidden.
static const idEcualizador = 'ecualizador'; 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 _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50; static const _maxItemsPorCarpeta = 50;
@@ -311,8 +422,10 @@ class ConstructorArbolAuto {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2, 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
}; };
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras, /// The root folders, all non-playable, and TIER-DEPENDENT: Favoritos,
/// optionally Música Local, Ecualizador), all non-playable. /// 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 /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// There is NO `Ecualizador` folder. The car's only equalizer control is /// 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 /// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
/// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`, /// is configured") — the caller lo deriva de
/// keeping this builder itself synchronous and side-effect free. /// `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 /// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used
/// same visible folder labels for every tier — "keeps the same visible /// to be accepted and ignored, on the theory that "the root keeps the same
/// folder labels for free users" is the explicit design choice, so a free /// visible folder labels for free users" was friendlier than a reduced
/// driver still sees a real, familiar menu rather than a wall of "Función /// menu. It was not: every one of those four folders dead-ended on a single
/// Premium" rows. The lock itself is enforced one level DOWN, at the /// non-playable "Función Premium" row, and Google Play cited exactly that
/// `getChildren` choke point (see [itemPremiumBloqueado] and /// against the Android for Cars App Quality Guidelines.
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these ///
/// folders as a free user reveals the lock there, never here. /// 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({ List<MediaItem> raiz({
required bool incluirMusicaLocal, required bool incluirMusicaLocal,
required bool premium, required bool premium,
}) => [ String? tituloDestacadas,
_carpeta(idFavoritos, 'Favoritos'), }) =>
_carpeta(idTodas, 'Todas las emisoras'), premium
_carpeta(idMisEmisoras, 'Mis emisoras'), ? [
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), _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 free tier's playable station rows (fix/auto-quality-guidelines,
/// the single non-playable item every non-root folder collapses to for a /// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium
/// free-tier user. Hardcoded Spanish label, matching every other car-tree /// folders use, capped like every other folder.
/// label in this file (never routed through `AppLocalizations` — ///
/// established convention, see [_tituloMasLocal]'s doc). /// Separate from [hijos] because that path is gated on [_idsCarpetas] and
static const idPremiumInfo = 'premium:info'; /// 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 /// El item de [idLocalNoLista]. Rotulado con
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier /// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a /// head unit. No reproducible — seleccionarlo es un no-op.
/// crash (Spec "Free-tier user selects a locked item"). MediaItem itemLocalNoDisponible() => MediaItem(
MediaItem itemPremiumBloqueado() => MediaItem( id: idLocalNoLista,
id: idPremiumInfo, title: etiquetas.musicaLocalNoDisponible,
title: 'Función Premium',
playable: false, playable: false,
extras: _contentStyleLista, extras: _contentStyleLista,
); );
@@ -464,20 +614,15 @@ class ConstructorArbolAuto {
return (documentId, pagina); 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 /// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
/// `artUri` (the label alone is the affordance, like [_carpeta]), id /// `artUri` (the label alone is the affordance, like [_carpeta]), id
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips /// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page. /// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
/// Rotulado con [EtiquetasArbolAuto.cargarMas].
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) => MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
MediaItem( MediaItem(
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre', id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
title: _tituloMasLocal, title: etiquetas.cargarMas,
playable: false, playable: false,
extras: _contentStyleLista, extras: _contentStyleLista,
); );
@@ -602,41 +747,41 @@ class ConstructorArbolAuto {
/// for small folders. /// for small folders.
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets; 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>` — /// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde]. /// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
/// Hardcoded Spanish label, matching every other car-tree label in this /// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
/// file — never routed through `AppLocalizations` (established
/// car-tree-label precedent, see [_tituloMasLocal]).
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta( MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre', '${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
'Ordenar por calidad', etiquetas.ordenarPorCalidad,
); );
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id /// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0, /// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded /// round-trips via [bucketLocalDesde].
/// alphabetical-range label (e.g. `'A-F'`), matching every other ///
/// car-tree label in this file — never routed through `AppLocalizations`. /// [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) => MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta); _carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// The "Reproducir carpeta" playable action item (Design ADR-5): id /// The "play folder" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label, /// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
/// matching every other car-tree label in this file — never routed /// [EtiquetasArbolAuto.reproducirCarpeta].
/// through `AppLocalizations`.
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem( MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre', id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
title: 'Reproducir carpeta', title: etiquetas.reproducirCarpeta,
playable: true, playable: true,
extras: _contentStyleGrid, extras: _contentStyleGrid,
); );
/// The "Reproducir aleatorio" playable action item (Design ADR-5), /// The "shuffle play" playable action item (Design ADR-5),
/// mirrors [_itemReproducirCarpeta]. /// mirrors [_itemReproducirCarpeta].
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem( MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre', id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
title: 'Reproducir aleatorio', title: etiquetas.reproducirAleatorio,
playable: true, playable: true,
extras: _contentStyleGrid, extras: _contentStyleGrid,
); );
@@ -718,7 +863,7 @@ class ConstructorArbolAuto {
int siguientePagina, int siguientePagina,
) => MediaItem( ) => MediaItem(
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre', id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal, title: etiquetas.cargarMas,
playable: false, playable: false,
extras: _contentStyleLista, extras: _contentStyleLista,
); );
@@ -732,7 +877,7 @@ class ConstructorArbolAuto {
int siguientePagina, int siguientePagina,
) => MediaItem( ) => MediaItem(
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre', id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal, title: etiquetas.cargarMas,
playable: false, playable: false,
extras: _contentStyleLista, extras: _contentStyleLista,
); );
@@ -812,7 +957,7 @@ class ConstructorArbolAuto {
final titulo = final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty) (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta ? tituloMeta
: _tituloDesdeNombre(nodo.nombre); : _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre);
final artUriMeta = meta?.artUri?.trim(); final artUriMeta = meta?.artUri?.trim();
final artUri = final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty) (artUriMeta != null && artUriMeta.isNotEmpty)
@@ -929,24 +1074,56 @@ class ConstructorArbolAuto {
} }
} }
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design /// Whether [parentMediaId] is content the FREE tier is allowed to browse
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any /// (fix/auto-quality-guidelines, item 10): the browsable root itself, the
/// other resolution. For the root itself this NEVER blocks (the root always /// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:<uuid>`
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays /// whose uuid belongs to [destacadas].
/// 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 /// Everything else — the catalogue folders, favourites, custom stations,
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from /// local music, the equalizer folder, group folders, local tracks, and any
/// before a downgrade is blocked exactly the same way as a legitimate /// station uuid that is not in the free set — is premium content.
/// current folder id (android-auto-media spec "Free-Tier Browse Never ///
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the /// Pure and id-shaped, with the free universe INJECTED, so the whole matrix
/// caller should proceed with its normal resolution (root, or premium). /// 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({ List<MediaItem>? respuestaBloqueadaPorEntitlement({
required String parentMediaId, required String parentMediaId,
required bool premium, required bool premium,
required List<Emisora> destacadas,
}) { }) {
if (parentMediaId == AudioService.browsableRootId) return null;
if (premium) 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 /// 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 /// 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 /// never called and no exception propagates (Spec "Unknown or stale media
/// id"). /// 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, { String id, {
required FuenteEmisorasAuto fuente, required FuenteEmisorasAuto fuente,
required Future<void> Function(MediaItem) reproducir, required Future<void> Function(MediaItem) reproducir,
}) async { }) async {
if (!id.startsWith(_prefijoEmisora)) return; final uuid = uuidDeMediaIdEmisora(id);
final uuid = id.substring(_prefijoEmisora.length); if (uuid == null) return false;
if (uuid.isEmpty) return;
final emisora = await fuente.porUuid(uuid); final emisora = await fuente.porUuid(uuid);
if (emisora == null) return; if (emisora == null) return false;
final item = MediaItem( final item = MediaItem(
id: emisora.url, id: emisora.url,
@@ -984,6 +1165,58 @@ Future<void> reproducirPorMediaId(
extras: {'uuid': emisora.uuid}, extras: {'uuid': emisora.uuid},
); );
await reproducir(item); 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 /// 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> favoritos,
required List<Emisora> misEmisoras, required List<Emisora> misEmisoras,
required List<Emisora> todas, 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) { Emisora? enLista(List<Emisora> lista) {
for (final e in lista) { for (final e in lista) {
@@ -1031,13 +1310,13 @@ List<Emisora> listaParaSaltoEmisora({
if (grupo != GrupoFavoritos.sinAsignarId) { if (grupo != GrupoFavoritos.sinAsignarId) {
final delGrupo = final delGrupo =
favoritos.where((e) => e.grupoFavoritosId == grupo).toList(); 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(misEmisoras) != null) return const ContextoSalto.misEmisoras();
if (enLista(todas) != null) return todas; if (enLista(todas) != null) return const ContextoSalto.todas();
return const []; return null;
} }
/// The station before or after [actual] in [lista], wrapping around at both /// The station before or after [actual] in [lista], wrapping around at both
@@ -1164,25 +1443,22 @@ Future<void> seleccionarPresetEqPorMediaId(
if (!activo) await activarEcualizador(true); 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"): /// Filename → display title (Design "Title = filename minus extension"):
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no /// 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 /// dot, or the dot is the first character — e.g. a hidden file like
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be /// `.mp3`), falling back to [sinNombre] when the result would be blank.
/// blank. ///
String _tituloDesdeNombre(String nombre) { /// [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(); final recortado = nombre.trim();
if (recortado.isEmpty) return _tituloLocalFallback; if (recortado.isEmpty) return sinNombre;
final ultimoPunto = recortado.lastIndexOf('.'); final ultimoPunto = recortado.lastIndexOf('.');
final sinExtension = final sinExtension =
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado; ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
final resultado = sinExtension.trim(); 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 = /// Resolves the on-brand fallback `artUri` for a local track (Design "art =
@@ -1455,12 +1731,13 @@ Future<void> reproducirCarpetaLocal(
Future<MediaItem?> construirMediaItemColaLocal( Future<MediaItem?> construirMediaItemColaLocal(
NodoLocal nodo, { NodoLocal nodo, {
required FuenteMusicaLocalAuto fuente, required FuenteMusicaLocalAuto fuente,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async { }) async {
final contentUri = await fuente.uriContenidoDePista(nodo.documentId); final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
if (contentUri == null || contentUri.isEmpty) return null; if (contentUri == null || contentUri.isEmpty) return null;
return MediaItem( return MediaItem(
id: contentUri, id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId), title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
album: 'PluriWave', album: 'PluriWave',
// Item 3: a queued local track had NO artUri at all before — reuses // Item 3: a queued local track had NO artUri at all before — reuses
// [artUriLocal] (the SAME on-brand rotation the browse tree's // [artUriLocal] (the SAME on-brand rotation the browse tree's
@@ -1524,8 +1801,9 @@ Future<Map<String, MetadatosPista>> _metadatosDeConCache(
Future<List<MediaItem>?> hijosMusicaLocal( Future<List<MediaItem>?> hijosMusicaLocal(
String parentMediaId, { String parentMediaId, {
required FuenteMusicaLocalAuto? fuente, required FuenteMusicaLocalAuto? fuente,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async { }) async {
final constructor = ConstructorArbolAuto(); final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST — // Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
// routing order is irrelevant to correctness (every prefix in this file // routing order is irrelevant to correctness (every prefix in this file
@@ -1584,13 +1862,24 @@ Future<List<MediaItem>?> hijosMusicaLocal(
if (fuente == null) return const []; if (fuente == null) return const [];
try { try {
final nodos = await fuente.hijos(documentId); final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocales( final items = await constructor.itemsLocales(
nodos, nodos,
documentIdPadre: documentId, documentIdPadre: documentId,
pagina: pagina, pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
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 (_) { } catch (_) {
return const []; return const [];
} }
@@ -1605,11 +1894,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the /// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
/// Now Playing title consistent with what the user tapped without requiring /// Now Playing title consistent with what the user tapped without requiring
/// a second native round trip. /// a second native round trip.
String _tituloDesdeDocumentId(String documentId) { String _tituloDesdeDocumentId(String documentId, String sinNombre) {
final ultimaBarra = documentId.lastIndexOf('/'); final ultimaBarra = documentId.lastIndexOf('/');
final segmento = final segmento =
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId; 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 /// Routing seam between a car-tapped `pista:<docId>` media id and the
@@ -1628,6 +1917,7 @@ Future<void> reproducirPistaLocal(
String id, { String id, {
required FuenteMusicaLocalAuto fuente, required FuenteMusicaLocalAuto fuente,
required Future<void> Function(MediaItem) reproducir, required Future<void> Function(MediaItem) reproducir,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async { }) async {
if (!esPistaMediaId(id)) return; if (!esPistaMediaId(id)) return;
final documentId = id.substring(_prefijoPista.length); final documentId = id.substring(_prefijoPista.length);
@@ -1638,7 +1928,7 @@ Future<void> reproducirPistaLocal(
final pista = PistaLocal( final pista = PistaLocal(
documentId: documentId, documentId: documentId,
titulo: _tituloDesdeDocumentId(documentId), titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre),
contentUri: contentUri, contentUri: contentUri,
); );
@@ -1733,6 +2023,16 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
return _snapshotTodas ?? const []; 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 @override
Future<Emisora?> porUuid(String uuid) async { Future<Emisora?> porUuid(String uuid) async {
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]); final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
@@ -1741,6 +2041,9 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
if (emisora.uuid == uuid) return emisora; if (emisora.uuid == uuid) return emisora;
} }
} }
for (final emisora in await resolverEmisorasDestacadas()) {
if (emisora.uuid == uuid) return emisora;
}
return null; return null;
} }
+20 -5
View File
@@ -15,13 +15,28 @@ const _bannerAdUnitIdReal = 'ca-app-pub-6038935671414339/5658618378';
/// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock). /// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock).
const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248'; const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248';
/// Real id in release builds only; test id everywhere else (debug/profile, /// TESTING-PHASE SWITCH. While `true`, release builds serve Google's official
/// including internal-testing-track builds run via `flutter run --release` /// TEST ad units instead of the real ones, so none of the closed-testing
/// on a personal device — see the "never tap your own ads" note above). /// human testers can generate invalid traffic against the AdMob account
/// (they cannot be registered as AdMob test devices). Flip to `false` for
/// the production release — that is the ONLY change needed to start serving
/// real ads. This does NOT affect the AdMob application id in
/// `AndroidManifest.xml`, which stays real in every build (it only
/// initializes the SDK and carries none of the click risk).
const usarAnunciosDePruebaEnRelease = true;
/// Real id in release builds only, and only once [usarAnunciosDePruebaEnRelease]
/// is flipped to `false`; test id everywhere else (debug/profile, including
/// internal-testing-track builds run via `flutter run --release` on a
/// personal device — see the "never tap your own ads" note above).
const bannerAdUnitId = const bannerAdUnitId =
kReleaseMode ? _bannerAdUnitIdReal : bannerAdUnitIdPrueba; kReleaseMode && !usarAnunciosDePruebaEnRelease
? _bannerAdUnitIdReal
: bannerAdUnitIdPrueba;
const interstitialAdUnitId = const interstitialAdUnitId =
kReleaseMode ? _interstitialAdUnitIdReal : interstitialAdUnitIdPrueba; kReleaseMode && !usarAnunciosDePruebaEnRelease
? _interstitialAdUnitIdReal
: interstitialAdUnitIdPrueba;
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns /// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
/// the entitlement gate for both surfaces, the interstitial's session /// the entitlement gate for both surfaces, the interstitial's session
File diff suppressed because it is too large Load Diff
+109 -8
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/foundation.dart' show debugPrint;
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors /// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so /// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
@@ -38,6 +39,25 @@ class EventoCompra {
final String? mensaje; final String? mensaje;
} }
/// Outcome of the SILENT ownership query ([PuertoCompras.consultarPropiedad])
/// used to re-verify the persisted premium flag (refund revocation).
///
/// Only [poseida] and [noPoseida] are definitive answers from Play; anything
/// that is not a clean answer (offline, billing unavailable, query error,
/// exception, timeout, pending purchase) is [desconocido], which the
/// verification policy treats as "change nothing" (fail-open, ADR-2).
enum ResultadoVerificacionLicencia {
/// Play reports the premium product as purchased on this account.
poseida,
/// Play answered successfully and the premium product is NOT among the
/// account's purchases (e.g. refunded or revoked).
noPoseida,
/// No trustworthy answer — never used to revoke.
desconocido,
}
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on /// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
/// this port, never on `in_app_purchase` directly — matches /// this port, never on `in_app_purchase` directly — matches
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and /// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
@@ -55,13 +75,24 @@ abstract class PuertoCompras {
/// Re-queries Play Billing for a prior purchase on this account. /// Re-queries Play Billing for a prior purchase on this account.
Future<void> restaurar(); Future<void> restaurar();
/// Silently asks the store whether this account currently owns the
/// premium product. Unlike [restaurar], it NEVER emits on [eventos] (the
/// premium sheet listens there) and never throws: every failure maps to
/// [ResultadoVerificacionLicencia.desconocido].
Future<ResultadoVerificacionLicencia> consultarPropiedad();
} }
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file /// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
/// depends on [PuertoCompras] instead. /// depends on [PuertoCompras] instead.
class ServicioComprasPlayBilling implements PuertoCompras { class ServicioComprasPlayBilling implements PuertoCompras {
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase}) ServicioComprasPlayBilling({
: _iap = inAppPurchase ?? InAppPurchase.instance { InAppPurchase? inAppPurchase,
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
_consultarComprasPasadasInyectada = consultarComprasPasadas,
_limiteConsultaPropiedad = limiteConsultaPropiedad {
_sub = _iap.purchaseStream.listen( _sub = _iap.purchaseStream.listen(
_alRecibirCompras, _alRecibirCompras,
onError: (Object error) { onError: (Object error) {
@@ -77,6 +108,15 @@ class ServicioComprasPlayBilling implements PuertoCompras {
static const idProducto = 'pluriwave_premium'; static const idProducto = 'pluriwave_premium';
final InAppPurchase _iap; final InAppPurchase _iap;
/// Test seam for [consultarPropiedad]; `null` in production, where the
/// Android platform addition's `queryPastPurchases` is used.
final Future<QueryPurchaseDetailsResponse> Function()?
_consultarComprasPasadasInyectada;
/// Upper bound for [consultarPropiedad]: a hung BillingClient connection
/// resolves to [ResultadoVerificacionLicencia.desconocido].
final Duration _limiteConsultaPropiedad;
final _eventos = StreamController<EventoCompra>.broadcast(); final _eventos = StreamController<EventoCompra>.broadcast();
StreamSubscription<List<PurchaseDetails>>? _sub; StreamSubscription<List<PurchaseDetails>>? _sub;
@@ -125,14 +165,48 @@ class ServicioComprasPlayBilling implements PuertoCompras {
} }
} }
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
try {
final disponible = await _iap.isAvailable().timeout(
_limiteConsultaPropiedad,
);
if (!disponible) return ResultadoVerificacionLicencia.desconocido;
final consultar =
_consultarComprasPasadasInyectada ??
() =>
_iap
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
.queryPastPurchases();
// `queryPastPurchases` reads the account's purchases straight from
// the BillingClient: unlike `restorePurchases` it does NOT push them
// into `purchaseStream`, so the premium sheet never sees this check.
final respuesta = await consultar().timeout(_limiteConsultaPropiedad);
return resultadoDesdeComprasPasadas(
respuesta.pastPurchases,
conError: respuesta.error != null,
);
} catch (e) {
debugPrint('[PluriWave][compras] consultarPropiedad -> desconocido $e');
return ResultadoVerificacionLicencia.desconocido;
}
}
void _alRecibirCompras(List<PurchaseDetails> compras) { void _alRecibirCompras(List<PurchaseDetails> compras) {
if (compras.isEmpty) { if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore completes without ever // `restorePurchases()` with nothing to restore pushes an EMPTY batch
// pushing a PurchaseDetails (Spec "Restore finds nothing") — there is // (`in_app_purchase_android` does `_purchaseUpdatedController.add(
// no per-call correlation in this stream, so this fires on ANY empty // pastPurchases)` unconditionally) — there is no per-call correlation
// batch. In practice `queryPastPurchases`/`restorePurchases` on an // in this stream, so this fires on ANY empty batch. In practice
// account with nothing to restore is the only source of an empty // `restorePurchases` on an account with nothing to restore is the only
// batch this stream would ever emit. // 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; return;
} }
for (final compra in compras) { for (final compra in compras) {
@@ -169,6 +243,33 @@ EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) {
}; };
} }
/// Pure mapping from a `queryPastPurchases` answer to the typed ownership
/// result (same port-boundary rationale as [eventoDesdeEstadoCompra]).
///
/// The premium product present as purchased/restored is proof of ownership
/// even when the answer also carries an error (the query spans in-app AND
/// subscriptions, and a failure of the latter is irrelevant here). A pending
/// entry is not a clean answer. Absence only counts as [noPoseida] when the
/// query succeeded without error.
ResultadoVerificacionLicencia resultadoDesdeComprasPasadas(
List<PurchaseDetails> compras, {
required bool conError,
}) {
final delProducto = compras.where(
(c) => c.productID == ServicioComprasPlayBilling.idProducto,
);
final comprada = delProducto.any(
(c) =>
c.status == PurchaseStatus.purchased ||
c.status == PurchaseStatus.restored,
);
if (comprada) return ResultadoVerificacionLicencia.poseida;
if (conError || delProducto.isNotEmpty) {
return ResultadoVerificacionLicencia.desconocido;
}
return ResultadoVerificacionLicencia.noPoseida;
}
extension<T> on List<T> { extension<T> on List<T> {
T? get firstOrNull => isEmpty ? null : first; T? get firstOrNull => isEmpty ? null : first;
} }
+39
View File
@@ -238,6 +238,45 @@ class ServicioEcualizador {
await prefs.setBool(_keyActivo, activo); 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 { Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs); 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). /// Owns the backup (export/import) JSON serialization (S4-R4).
/// ///
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and /// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the /// `eqMultiDeviceEnabled`. v4 extends v3 with `ecualizadorActivo` (the
/// export stays at v2 for backward compat with the old app. State APPLICATION /// equalizer's global ON/OFF toggle). When the version-N extension
/// (writing favorites, EQ, alarms back into the app) stays in /// 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, /// `EstadoRadio.importarConfig` — this service only owns serialization,
/// parsing and the envelope shape. /// parsing and the envelope shape.
class ServicioExportImport { class ServicioExportImport {
const ServicioExportImport(); const ServicioExportImport();
/// Current backup schema version (v3multi-device EQ). /// Current backup schema version (v4equalizer on/off toggle).
static const int versionActual = 3; 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. /// Legacy v2 version constant kept for clarity.
static const int versionV2 = 2; static const int versionV2 = 2;
/// Builds the export envelope. /// Builds the export envelope.
/// ///
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null), /// When [presetsPorDispositivo] or [presetsMatriz] or
/// [versionActual] (3) is written. When both are omitted the call behaves /// [eqMultiDeviceEnabled] are provided (non-null), at least [versionV3] (3)
/// identically to the original v2 format (version key stays 2) so old /// is written. When [ecualizadorActivo] is ALSO provided (non-null),
/// backups keep round-tripping without version bumps. /// [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 /// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// and passes through untouched (no re-parsing here). /// and passes through untouched (no re-parsing here).
@@ -45,14 +52,27 @@ class ServicioExportImport {
Map<String, PresetEcualizador>? presetsPorDispositivo, Map<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? presetsMatriz, Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled, bool? eqMultiDeviceEnabled,
// v4 extension — the equalizer's global ON/OFF toggle. Omitting it
// produces a v3 (or v2)-compatible export.
bool? ecualizadorActivo,
}) { }) {
final tieneExtensionesV3 = final tieneExtensionesV3 =
presetsPorDispositivo != null || presetsPorDispositivo != null ||
presetsMatriz != null || presetsMatriz != null ||
eqMultiDeviceEnabled != 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>{ final envelope = <String, dynamic>{
'version': tieneExtensionesV3 ? versionActual : versionV2, 'version': version,
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(), 'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
// Favorites + groups (preserves grupo_id assignments per station). // Favorites + groups (preserves grupo_id assignments per station).
// The protected "sin asignar" group is implicit and never exported. // The protected "sin asignar" group is implicit and never exported.
@@ -88,6 +108,11 @@ class ServicioExportImport {
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false; envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
} }
// v4 extension: only written when explicitly provided.
if (tieneExtensionV4) {
envelope['ecualizadorActivo'] = ecualizadorActivo;
}
return envelope; 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 { Future<void> eliminarGrupo(String id) async {
if (id == GrupoFavoritos.sinAsignarId) return; if (id == GrupoFavoritos.sinAsignarId) return;
final db = await _database; final db = await _database;
+218
View File
@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:shared_preferences/shared_preferences.dart';
import 'servicio_compras.dart';
export 'servicio_compras.dart' show ResultadoVerificacionLicencia;
/// Versioned persistence key (Design ADR-1) of the permanent, non-consumable
/// premium unlock. Owned here so the headless verification below and
/// `EstadoEntitlement` read/write exactly ONE key.
const claveCompraPremium = 'compra_premium_v1';
/// Epoch millis of the last verification that got a DEFINITIVE answer
/// ([ResultadoVerificacionLicencia.poseida] / [noPoseida]).
const claveUltimaVerificacionLicencia = 'licencia_ultima_verificacion_ms';
/// Epoch millis of the last ATTEMPT, whatever its outcome.
const claveUltimoIntentoLicencia = 'licencia_ultimo_intento_ms';
/// Consecutive definitive "not owned" answers while the flag was premium.
const claveAusenciasLicencia = 'licencia_ausencias_consecutivas';
/// Epoch millis of the FIRST absence of the current streak.
const clavePrimeraAusenciaLicencia = 'licencia_primera_ausencia_ms';
/// After a definitive answer, Play is not asked again for this long.
const intervaloVerificacionLicencia = Duration(hours: 24);
/// After an attempt without a definitive answer (offline, billing
/// unavailable, error), the retry waits at least this long, so resuming the
/// app or reconnecting the car while offline never hammers Play.
const intervaloReintentoLicencia = Duration(hours: 1);
/// Minimum time between the first absence and the one that confirms it —
/// a transient empty Play Store cache must never revoke a paying user.
const separacionMinimaAusencias = Duration(hours: 12);
/// Definitive absences required (spaced by [separacionMinimaAusencias])
/// before the premium flag is revoked.
const ausenciasParaRevocar = 2;
/// What a [verificarLicencia] run changed in the persisted flag.
enum CambioLicencia {
/// Nothing changed (throttled, unknown answer, or state already right).
sinCambios,
/// The flag went false -> true (e.g. a reinstall of a paying user).
desbloqueada,
/// The flag went true -> false (refund confirmed twice, spaced apart).
revocada,
}
/// The run currently in flight, shared by every caller in this isolate
/// (phone UI and Android Auto), so only ONE ownership query runs at a time.
Future<CambioLicencia>? _verificacionEnCurso;
/// Silent, headless-safe license re-verification (refund revocation).
///
/// No `BuildContext`, no purchase-stream events, never throws, and every
/// non-definitive outcome leaves the persisted state untouched (fail-open,
/// ADR-2) — PRO is never removed for lack of connectivity. Callers fire and
/// forget it; it must never sit on a startup or browse path.
///
/// Rules:
/// * throttled by [intervaloVerificacionLicencia] after a definitive answer
/// and by [intervaloReintentoLicencia] after any attempt; a clock that
/// went backwards never blocks it;
/// * [ResultadoVerificacionLicencia.poseida]: flag forced to `true` (silent
/// unlock if it was `false`), absence streak cleared;
/// * [ResultadoVerificacionLicencia.noPoseida] with the flag `true`: the
/// streak grows, and the flag is revoked only once it reaches
/// [ausenciasParaRevocar] AND at least [separacionMinimaAusencias] passed
/// since its first absence; with the flag `false` there is nothing to do;
/// * [ResultadoVerificacionLicencia.desconocido] (or an exception): nothing
/// changes, not even the streak.
Future<CambioLicencia> verificarLicencia({
required Future<ResultadoVerificacionLicencia> Function() consultar,
SharedPreferences? prefs,
DateTime Function()? reloj,
}) {
final enCurso = _verificacionEnCurso;
if (enCurso != null) return enCurso;
final ejecucion = _verificar(
consultar: consultar,
prefs: prefs,
reloj: reloj ?? DateTime.now,
);
_verificacionEnCurso = ejecucion;
unawaited(
ejecucion.whenComplete(() {
if (identical(_verificacionEnCurso, ejecucion)) {
_verificacionEnCurso = null;
}
}),
);
return ejecucion;
}
/// Clears the absence streak — a real purchase/restore is fresh proof of
/// ownership, so a stale streak must not survive it.
Future<void> reiniciarAusenciasLicencia(SharedPreferences prefs) async {
await prefs.remove(claveAusenciasLicencia);
await prefs.remove(clavePrimeraAusenciaLicencia);
}
Future<CambioLicencia> _verificar({
required Future<ResultadoVerificacionLicencia> Function() consultar,
required SharedPreferences? prefs,
required DateTime Function() reloj,
}) async {
try {
final p = prefs ?? await SharedPreferences.getInstance();
final ahora = reloj();
if (_dentroDeVentana(
p,
claveUltimaVerificacionLicencia,
ahora,
intervaloVerificacionLicencia,
) ||
_dentroDeVentana(
p,
claveUltimoIntentoLicencia,
ahora,
intervaloReintentoLicencia,
)) {
return CambioLicencia.sinCambios;
}
await p.setInt(claveUltimoIntentoLicencia, ahora.millisecondsSinceEpoch);
ResultadoVerificacionLicencia resultado;
try {
resultado = await consultar();
} catch (e) {
debugPrint('[PluriWave][licencia] consulta fallida -> sin cambios $e');
resultado = ResultadoVerificacionLicencia.desconocido;
}
switch (resultado) {
case ResultadoVerificacionLicencia.desconocido:
return CambioLicencia.sinCambios;
case ResultadoVerificacionLicencia.poseida:
await p.setInt(
claveUltimaVerificacionLicencia,
ahora.millisecondsSinceEpoch,
);
await reiniciarAusenciasLicencia(p);
if (p.getBool(claveCompraPremium) ?? false) {
return CambioLicencia.sinCambios;
}
await p.setBool(claveCompraPremium, true);
return CambioLicencia.desbloqueada;
case ResultadoVerificacionLicencia.noPoseida:
await p.setInt(
claveUltimaVerificacionLicencia,
ahora.millisecondsSinceEpoch,
);
if (!(p.getBool(claveCompraPremium) ?? false)) {
await reiniciarAusenciasLicencia(p);
return CambioLicencia.sinCambios;
}
return _registrarAusencia(p, ahora);
}
} catch (e) {
// Fail-open (ADR-2): a prefs failure never touches the entitlement.
debugPrint('[PluriWave][licencia] verificacion fallida -> sin cambios $e');
return CambioLicencia.sinCambios;
}
}
Future<CambioLicencia> _registrarAusencia(
SharedPreferences p,
DateTime ahora,
) async {
final ausencias = (p.getInt(claveAusenciasLicencia) ?? 0) + 1;
final primeraMs = p.getInt(clavePrimeraAusenciaLicencia);
if (primeraMs == null || ausencias == 1) {
await p.setInt(claveAusenciasLicencia, 1);
await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch);
return CambioLicencia.sinCambios;
}
final separacion = ahora.difference(
DateTime.fromMillisecondsSinceEpoch(primeraMs),
);
if (separacion.isNegative) {
// The clock went backwards: restart the spacing from now (delays the
// revocation, never hastens it).
await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch);
await p.setInt(claveAusenciasLicencia, ausencias);
return CambioLicencia.sinCambios;
}
if (ausencias >= ausenciasParaRevocar &&
separacion >= separacionMinimaAusencias) {
await p.setBool(claveCompraPremium, false);
await reiniciarAusenciasLicencia(p);
return CambioLicencia.revocada;
}
await p.setInt(claveAusenciasLicencia, ausencias);
return CambioLicencia.sinCambios;
}
/// Whether [clave]'s timestamp is less than [ventana] before [ahora]. A
/// timestamp in the future (clock moved backwards) does NOT throttle.
bool _dentroDeVentana(
SharedPreferences p,
String clave,
DateTime ahora,
Duration ventana,
) {
final ms = p.getInt(clave);
if (ms == null) return false;
final transcurrido = ahora.difference(
DateTime.fromMillisecondsSinceEpoch(ms),
);
return !transcurrido.isNegative && transcurrido < ventana;
}
+177 -95
View File
@@ -40,112 +40,194 @@ class HojaPremium extends StatelessWidget {
), ),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( // The sheet's height is data-dependent: five benefit bullets whose
mainAxisSize: MainAxisSize.min, // wrapped line count varies per locale, plus an optional
crossAxisAlignment: CrossAxisAlignment.stretch, // purchase/restore result banner. Together they already overflow a
children: [ // short viewport by a couple of pixels, and a translation one word
Row( // longer would make it worse. Scrolling is the only shape that
children: [ // cannot overflow, and it costs nothing when everything fits.
Icon( child: SingleChildScrollView(
Icons.workspace_premium_rounded, child: Column(
color: PluriWaveTokens.brand, mainAxisSize: MainAxisSize.min,
), crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(width: 10), children: [
Expanded( Row(
child: Text( children: [
l10n.funcionPremium, Icon(
style: Theme.of(context).textTheme.titleLarge?.copyWith( Icons.workspace_premium_rounded,
fontWeight: FontWeight.w900, color: PluriWaveTokens.brand,
),
), ),
), const SizedBox(width: 10),
], Expanded(
), child: Text(
const SizedBox(height: 20), l10n.premiumHojaTitulo,
// FIX 3 (code review): user-facing feedback for a failed style: Theme.of(context).textTheme.titleLarge?.copyWith(
// purchase/restore, or a restore that found nothing — before fontWeight: FontWeight.w900,
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
), ),
), ),
IconButton( ),
key: const ValueKey('hoja-premium-resultado-descartar'), // Explicit, obvious dismiss affordance (fix/import-alarmas-y-
icon: const Icon(Icons.close_rounded, size: 18), // paywall): a purchase sheet the user cannot easily escape is
onPressed: () => entitlement.consumirResultadoUsuario(), // a dark pattern and a Play policy risk. Reachable without
padding: EdgeInsets.zero, // buying or restoring, same weight as any other icon button.
constraints: const BoxConstraints(), IconButton(
visualDensity: VisualDensity.compact, key: const ValueKey('hoja-premium-cerrar'),
), icon: const Icon(Icons.close_rounded),
], tooltip: l10n.closeAction,
), onPressed: () => Navigator.of(context).maybePop(),
),
],
), ),
if (entitlement.esPremium) const SizedBox(height: 12),
Padding( // Concrete, honest value list — accuracy is non-negotiable here:
key: const ValueKey('hoja-premium-activo'), // these five are the ONLY things premium unlocks. The phone
padding: const EdgeInsets.only(bottom: 12), // equalizer stays free for everyone and must NEVER appear here;
child: Text( // only its Android Auto surface is affected, as a consequence of
l10n.premiumActivo, // Auto itself being gated.
style: Theme.of(context).textTheme.bodyMedium, //
// The Android Auto line describes what PRO adds in the CAR — the
// full catalogue, favourites, my stations, local music — because
// the free tier already gets a real, playable featured folder
// there. A bare "Android Auto" bullet sold something free users
// already have.
_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
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
),
),
IconButton(
key: const ValueKey('hoja-premium-resultado-descartar'),
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => entitlement.consumirResultadoUsuario(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
), ),
) if (entitlement.esPremium)
else Padding(
FilledButton.icon( key: const ValueKey('hoja-premium-activo'),
key: const ValueKey('hoja-premium-comprar'), padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.premiumActivo,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
FilledButton.icon(
key: const ValueKey('hoja-premium-comprar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.comprar(),
icon:
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
),
const SizedBox(height: 10),
OutlinedButton(
key: const ValueKey('hoja-premium-restaurar'),
onPressed: onPressed:
entitlement.compraEnCurso entitlement.compraEnCurso
? null ? null
: () => entitlement.comprar(), : () => entitlement.restaurar(),
icon: child: Text(l10n.restaurarCompras),
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
), ),
const SizedBox(height: 10), if (!entitlement.esPremium) ...[
OutlinedButton( const SizedBox(height: 4),
key: const ValueKey('hoja-premium-restaurar'), // Clearly-labelled, always-reachable decline — same weight as
onPressed: // any other secondary action, never made harder to find than
entitlement.compraEnCurso // buying (hard constraint: no dark patterns, no guilt-shaming
? null // decline copy).
: () => entitlement.restaurar(), TextButton(
child: Text(l10n.restaurarCompras), 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),
),
],
),
);
}
}
+35 -1
View File
@@ -32,6 +32,17 @@ class VisualizadorAudio extends StatefulWidget {
/// player: `.45`, t4 line 121), so this is a parameter, not a constant. /// player: `.45`, t4 line 121), so this is a parameter, not a constant.
final double gradienteFinAlpha; final double gradienteFinAlpha;
/// Whether the user has opted in to reading the REAL audio level.
///
/// Subscribing to the native `pluriwave/audio_visualizer` EventChannel is
/// what makes `MainActivity.startVisualizerWhenAllowed` request
/// `RECORD_AUDIO`, so this flag is the Dart-side gate on a sensitive
/// permission, not a cosmetic preference. Defaults to `false`: without the
/// opt-in the widget never touches the channel and animates the synthetic
/// wave instead, which is exactly what it already did whenever the
/// permission was denied.
final bool capturaRealHabilitada;
const VisualizadorAudio({ const VisualizadorAudio({
super.key, super.key,
required this.estadoStream, required this.estadoStream,
@@ -42,6 +53,7 @@ class VisualizadorAudio extends StatefulWidget {
this.anchuraTotal = double.infinity, this.anchuraTotal = double.infinity,
this.barrasDiscretas = false, this.barrasDiscretas = false,
this.gradienteFinAlpha = 0.3, this.gradienteFinAlpha = 0.3,
this.capturaRealHabilitada = false,
}); });
@override @override
@@ -101,9 +113,31 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
_sincronizarOndaReal(); _sincronizarOndaReal();
} }
@override
void didUpdateWidget(VisualizadorAudio oldWidget) {
super.didUpdateWidget(oldWidget);
// The opt-in can be revoked while this widget is mounted; dropping the
// subscription is what makes the native side release the Visualizer.
if (oldWidget.capturaRealHabilitada != widget.capturaRealHabilitada) {
if (!widget.capturaRealHabilitada) {
unawaited(_ondaSubscription?.cancel());
_ondaSubscription = null;
_ultimaOndaReal = null;
}
_sincronizarOndaReal();
}
}
void _sincronizarOndaReal() { void _sincronizarOndaReal() {
final sessionId = _sessionId; final sessionId = _sessionId;
final puedeCapturar = sessionId != null && sessionId > 0 && _activo; // `capturaRealHabilitada` first: subscribing to the EventChannel is what
// triggers the native RECORD_AUDIO request, so the opt-in gates the
// subscription itself, never just the rendering of its result.
final puedeCapturar =
widget.capturaRealHabilitada &&
sessionId != null &&
sessionId > 0 &&
_activo;
if (!puedeCapturar) { if (!puedeCapturar) {
unawaited(_ondaSubscription?.cancel()); unawaited(_ondaSubscription?.cancel());
@@ -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
+8 -1
View File
@@ -366,7 +366,7 @@ packages:
source: hosted source: hosted
version: "3.3.0" version: "3.3.0"
in_app_purchase_android: in_app_purchase_android:
dependency: transitive dependency: "direct main"
description: description:
name: in_app_purchase_android name: in_app_purchase_android
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905 sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
@@ -629,6 +629,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" 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: provider:
dependency: "direct main" dependency: "direct main"
description: description:
+14 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none' publish_to: 'none'
version: 1.3.0+151 version: 1.3.3+164
environment: environment:
sdk: ^3.7.0 sdk: ^3.7.0
@@ -55,6 +55,19 @@ dependencies:
# In-app purchase # In-app purchase
in_app_purchase: ^3.2.0 in_app_purchase: ^3.2.0
# Direct dependency only for `InAppPurchaseAndroidPlatformAddition
# .queryPastPurchases` (silent license re-verification in
# `ServicioComprasPlayBilling.consultarPropiedad`).
in_app_purchase_android: ^0.5.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) # Song recognition (activar con AudD key)
# permission_handler: ^11.3.1 # permission_handler: ^11.3.1
@@ -0,0 +1,43 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// Play production-readiness guard over the declared Android permissions.
///
/// There is no Dart seam around `Geolocator` (it is a static facade, not an
/// injectable port), so the only honest place to pin the permission surface
/// is the manifest file itself. That is deliberate: the risk this guards is
/// a *declaration* mismatch with the Data Safety form, which is a property
/// of the manifest, not of any Dart call.
void main() {
late String manifiesto;
setUpAll(() {
manifiesto =
File('android/app/src/main/AndroidManifest.xml').readAsStringSync();
});
bool declara(String permiso) =>
manifiesto.contains('android.permission.$permiso');
group('permisos de ubicacion', () {
test('NO declara ACCESS_FINE_LOCATION: la app solo resuelve un codigo ISO '
'de pais y el Data Safety enviado declara ubicacion aproximada', () {
expect(
declara('ACCESS_FINE_LOCATION'),
isFalse,
reason:
'The only location consumer is '
'EstadoBusqueda.cargarEmisorasCercanas, which asks for '
'LocationAccuracy.low and reduces the fix to '
'Placemark.isoCountryCode. Declaring FINE contradicts the '
'approved "approximate location" Data Safety declaration.',
);
});
test('SI declara ACCESS_COARSE_LOCATION: sin ninguno de los dos, el '
'plugin lanza PermissionUndefinedException', () {
expect(declara('ACCESS_COARSE_LOCATION'), isTrue);
});
});
}
@@ -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');
});
}
+269
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', () { group('EstadoEcualizador — bonded Bluetooth names', () {
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
@@ -1728,6 +1805,198 @@ void main() {
eq.dispose(); eq.dispose();
}); });
}); });
// ---------------------------------------------------------------------------
// eq-sync-superficies: car/notification-initiated EQ changes must reach
// EstadoEcualizador (and persist through ServicioEcualizador), not just
// the audio handler.
// ---------------------------------------------------------------------------
group('EstadoEcualizador — resync with handler-initiated EQ changes '
'(eq-sync-superficies)', () {
test(
'a handler-initiated toggle (car/notification) syncs activo and '
'notifies listeners',
() async {
final fakeAudio = FakeServicioAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(activo: true),
);
await eq.cargarPersistido();
expect(eq.activo, isTrue);
var avisos = 0;
eq.addListener(() => avisos++);
// Simulates `accionEqToggle` calling
// `PluriWaveAudioHandler.setEcualizadorActivo` directly, bypassing
// `ServicioAudio`/`EstadoEcualizador` entirely.
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.activo, isFalse);
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
test(
'a handler-initiated preset change (Android Auto) syncs presetActual '
'and notifies listeners',
() async {
final fakeAudio = FakeServicioAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(principal: PresetEcualizador.flat),
);
await eq.cargarPersistido();
expect(eq.presetActual, equals(PresetEcualizador.flat));
var avisos = 0;
eq.addListener(() => avisos++);
// Simulates `seleccionarPresetEqPorMediaId` calling
// `PluriWaveAudioHandler.aplicarPreset` directly.
fakeAudio.simularCambioEqDesdeHandler(preset: PresetEcualizador.jazz);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.jazz));
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
test(
'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);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
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();
},
);
test(
'resync does not cause an extra handler write (no feedback loop)',
() async {
final fakeAudio = FakeServicioAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(activo: true),
);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
fakeAudio.presetsAplicados.clear();
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The resync must only read from `audio` and write to `servicio` —
// never write BACK into `audio`, or a handler write would trigger
// another stream tick, which would resync again, forever.
expect(fakeAudio.cambiosEcualizadorActivo, isEmpty);
expect(fakeAudio.presetsAplicados, isEmpty);
eq.dispose();
},
);
test(
'a UI-initiated toggle still works exactly as before and persists '
'exactly once (regression)',
() async {
final fakeAudio = FakeServicioAudio();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
await eq.cambiarActivo(false);
// Give any (harmless, no-op) resync tick a chance to run too.
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.activo, isFalse);
expect(fakeServicio.config.activo, isFalse);
expect(fakeServicio.guardarActivoLlamadas, equals(1));
expect(fakeAudio.cambiosEcualizadorActivo, equals([false]));
eq.dispose();
},
);
});
// ---------------------------------------------------------------------------
// 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 /// Fake whose [guardarActivo] stays pending until released, and releases the
+187
View File
@@ -2,7 +2,10 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_entitlement.dart'; import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'
show registrarInvalidacionArbolAuto;
import 'package:pluriwave/servicios/servicio_compras.dart'; import 'package:pluriwave/servicios/servicio_compras.dart';
import 'package:pluriwave/servicios/verificacion_licencia.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`, /// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
@@ -11,6 +14,12 @@ class _PuertoComprasFalso implements PuertoCompras {
final _eventos = StreamController<EventoCompra>.broadcast(); final _eventos = StreamController<EventoCompra>.broadcast();
int comprasIntentadas = 0; int comprasIntentadas = 0;
int restaurosIntentados = 0; int restaurosIntentados = 0;
int consultasPropiedad = 0;
/// What the silent ownership query answers. Defaults to [desconocido] so
/// every pre-existing test keeps its old behavior (fail-open: no change).
ResultadoVerificacionLicencia propiedad =
ResultadoVerificacionLicencia.desconocido;
@override @override
Stream<EventoCompra> get eventos => _eventos.stream; Stream<EventoCompra> get eventos => _eventos.stream;
@@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras {
restaurosIntentados++; restaurosIntentados++;
} }
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
consultasPropiedad++;
return propiedad;
}
void emitir(EventoCompra evento) => _eventos.add(evento); void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close(); Future<void> dispose() => _eventos.close();
} }
/// Mutable clock for the throttle/spacing rules of the license check.
class _Reloj {
DateTime ahora = DateTime(2026, 9, 18, 10);
DateTime call() => ahora;
}
/// Lets every microtask/async continuation of the fire-and-forget license
/// check settle (mock prefs + fake port complete immediately).
Future<void> _asentar() async {
for (var i = 0; i < 10; i++) {
await Future<void>.delayed(Duration.zero);
}
}
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -159,6 +189,34 @@ void main() {
expect(estado.compraEnCurso, isFalse); 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( test(
'un error en el flujo de compra no bloquea al pagador (fail-open)', 'un error en el flujo de compra no bloquea al pagador (fail-open)',
() async { () async {
@@ -299,6 +357,135 @@ void main() {
}); });
}); });
group('verificacion silenciosa de licencia (reembolsos)', () {
late _PuertoComprasFalso compras;
late _Reloj reloj;
late int invalidaciones;
setUp(() {
compras = _PuertoComprasFalso();
reloj = _Reloj();
invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
});
tearDown(() async {
registrarInvalidacionArbolAuto(() {});
await compras.dispose();
});
Future<EstadoEntitlement> crear({required bool premium}) async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': premium});
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
reloj: reloj.call,
);
addTearDown(estado.dispose);
await _asentar();
return estado;
}
test('se dispara sola al cargar, sin bloquear la carga', () async {
final estado = await crear(premium: true);
expect(estado.esPremium, isTrue);
expect(compras.consultasPropiedad, 1);
});
test('desconocido (offline) conserva premium sin notificar nada', () async {
final estado = await crear(premium: true);
var notificaciones = 0;
estado.addListener(() => notificaciones++);
reloj.ahora = reloj.ahora.add(const Duration(days: 2));
await estado.refrescarLicencia();
expect(estado.esPremium, isTrue);
expect(notificaciones, 0);
expect(invalidaciones, 0);
});
test('una sola ausencia no revoca', () async {
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
final estado = await crear(premium: true);
expect(estado.esPremium, isTrue);
});
test('revocacion confirmada: notifica, invalida el arbol de Auto y NUNCA '
'toca resultadoUsuario ni compraEnCurso', () async {
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
final estado = await crear(premium: true);
final prefs = await SharedPreferences.getInstance();
// A restore the user started stays in flight, untouched.
unawaited(estado.restaurar());
await _asentar();
expect(estado.compraEnCurso, isTrue);
var notificaciones = 0;
estado.addListener(() => notificaciones++);
reloj.ahora = reloj.ahora.add(const Duration(days: 1));
await estado.refrescarLicencia();
expect(estado.esPremium, isFalse);
expect(prefs.getBool('compra_premium_v1'), isFalse);
expect(notificaciones, greaterThan(0));
expect(invalidaciones, 1);
expect(estado.resultadoUsuario, isNull);
expect(estado.compraEnCurso, isTrue);
});
test('poseida con la flag en false desbloquea en silencio', () async {
compras.propiedad = ResultadoVerificacionLicencia.poseida;
final estado = await crear(premium: false);
expect(estado.esPremium, isTrue);
expect(invalidaciones, 1);
expect(estado.resultadoUsuario, isNull);
expect(estado.compraEnCurso, isFalse);
});
test('refrescarLicencia recoge un cambio hecho por otra via (Android '
'Auto) en prefs', () async {
final estado = await crear(premium: true);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('compra_premium_v1', false);
await estado.refrescarLicencia();
expect(estado.esPremium, isFalse);
});
test('una compra real reinicia el contador de ausencias', () async {
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
final estado = await crear(premium: false);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(claveAusenciasLicencia, 1);
compras.emitir(const EventoCompra(TipoEventoCompra.comprada));
await _asentar();
expect(estado.esPremium, isTrue);
expect(prefs.getInt(claveAusenciasLicencia), isNull);
});
test('sin puerto de compras no verifica nada', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
reloj: reloj.call,
);
addTearDown(estado.dispose);
await _asentar();
await estado.refrescarLicencia();
expect(estado.esPremium, isTrue);
});
});
group('esPremiumPersistido (headless, sin BuildContext)', () { group('esPremiumPersistido (headless, sin BuildContext)', () {
test('lee la flag persistida directamente desde prefs', () async { test('lee la flag persistida directamente desde prefs', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true}); SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
@@ -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',
);
});
});
}
+40 -30
View File
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.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/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -77,7 +78,7 @@ void main() {
final porEmisora = {'fav-1': PresetEcualizador.rock}; final porEmisora = {'fav-1': PresetEcualizador.rock};
final estado = EstadoRadio( final estado = EstadoRadio(
esPremium: () => true, esPremium: () => true,
audio: FakeServicioAudio(ecualizadorActivo: false), audio: FakeServicioAudio(ecualizadorDisponible: false),
favoritos: FakeServicioFavoritos(), favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(), radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador( servicioEcualizador: FakeServicioEcualizador(
@@ -792,21 +793,16 @@ void main() {
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no ' '(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async { 'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM'); final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
final estadoUno = EstadoRadio( // The record is now written by the audio handler's `_cambiarFuente`
esPremium: () => true, // (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
audio: FakeServicioAudio(), // `ultima_emisora_v1` and the only one that also exists on the headless
favoritos: FakeServicioFavoritos(), // Android Auto engine — `EstadoRadio` used to write it too and no
radio: FakeServicioRadio(), // longer does. Seeded through that same production function here, so
servicioEcualizador: FakeServicioEcualizador(), // this test covers what `EstadoRadio` actually owns (the RESTORE) with
resolverArchivoCustom: _archivoCustomVacio, // a real payload instead of one a fake invented. The write itself is
iniciarAutomaticamente: false, // covered end to end in
); // `test/servicios/servicio_audio_ultima_emisora_test.dart`.
await estadoUno.inicializar(); await guardarUltimaEmisoraPersistida(emisora);
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);
final audioDos = FakeServicioAudio(); final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio( final estadoDos = EstadoRadio(
@@ -848,9 +844,18 @@ void main() {
}); });
test('una emisora seleccionada desde el auto (fuera de reproducir()) ' 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 audio = _AudioControlado();
final estadoUno = EstadoRadio( final estado = EstadoRadio(
esPremium: () => true, esPremium: () => true,
audio: audio, audio: audio,
favoritos: FakeServicioFavoritos(), favoritos: FakeServicioFavoritos(),
@@ -859,7 +864,16 @@ void main() {
resolverArchivoCustom: _archivoCustomVacio, resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false, 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( final desdeCoche = emisoraDemo(
uuid: 'auto-remembered', uuid: 'auto-remembered',
nombre: 'Recordada desde el auto', nombre: 'Recordada desde el auto',
@@ -867,18 +881,14 @@ void main() {
audio.seleccionarDesdeAuto(desdeCoche); audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio( expect(
esPremium: () => true, estado.emisoraActual?.uuid,
audio: FakeServicioAudio(), desdeCoche.uuid,
favoritos: FakeServicioFavoritos(), reason:
radio: FakeServicioRadio(), 'the car changed the station without going through reproducir(); '
servicioEcualizador: FakeServicioEcualizador(), 'the phone UI must follow it instead of keeping the previous '
resolverArchivoCustom: _archivoCustomVacio, 'selection on screen',
iniciarAutomaticamente: false,
); );
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
}); });
}); });
} }
+91 -6
View File
@@ -15,11 +15,14 @@ import 'package:pluriwave/servicios/servicio_presets_personalizados.dart';
import 'package:pluriwave/servicios/servicio_radio.dart'; import 'package:pluriwave/servicios/servicio_radio.dart';
class FakeServicioAudio extends ServicioAudio { class FakeServicioAudio extends ServicioAudio {
FakeServicioAudio({this.ecualizadorActivo = true}) { FakeServicioAudio({this.ecualizadorDisponible = true}) {
_estadoController.add(EstadoReproduccion.detenido); _estadoController.add(EstadoReproduccion.detenido);
} }
final bool ecualizadorActivo; /// Whether the native equalizer is available on this device — NOT whether
/// it is currently switched on (see [ecualizadorActivo] for that).
@override
final bool ecualizadorDisponible;
final _estadoController = StreamController<EstadoReproduccion>.broadcast(); final _estadoController = StreamController<EstadoReproduccion>.broadcast();
final List<PresetEcualizador> presetsAplicados = []; final List<PresetEcualizador> presetsAplicados = [];
final List<Emisora> emisorasReproducidas = []; final List<Emisora> emisorasReproducidas = [];
@@ -30,6 +33,35 @@ class FakeServicioAudio extends ServicioAudio {
Emisora? _emisoraActual; Emisora? _emisoraActual;
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido; EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
/// Mirrors `PluriWaveAudioHandler._ecualizadorActivo`/`_presetActual`:
/// the handler-side EQ state, settable independently of the
/// `ServicioAudio`-forwarded methods below so tests can simulate a
/// car/notification-initiated change (eq-sync-superficies).
bool _ecualizadorActivoValor = true;
PresetEcualizador _presetActualValor = PresetEcualizador.flat;
@override
bool get ecualizadorActivo => _ecualizadorActivoValor;
@override
PresetEcualizador get presetActual => _presetActualValor;
/// Simulates a car/notification-initiated EQ change: mutates the (fake)
/// handler's own state directly, the same way
/// `PluriWaveAudioHandler.customAction`/`seleccionarPresetEqPorMediaId`
/// call `setEcualizadorActivo`/`aplicarPreset` on the handler WITHOUT
/// going through `ServicioAudio` — then re-emits the current playback
/// state, mirroring `_actualizarControlesEq()`'s unconditional
/// `playbackState.add(...)` republish so a resync listener on
/// [estadoStream] picks it up. Deliberately does NOT append to
/// [cambiosEcualizadorActivo]/[presetsAplicados]: those track calls that
/// arrived through the `ServicioAudio`-forwarded (UI-initiated) path.
void simularCambioEqDesdeHandler({bool? activo, PresetEcualizador? preset}) {
if (activo != null) _ecualizadorActivoValor = activo;
if (preset != null) _presetActualValor = preset;
emitirEstado(_estadoActual);
}
@override @override
void configurarLocalizaciones(AppLocalizations l10n) { void configurarLocalizaciones(AppLocalizations l10n) {
// No global handler in tests; just record the call. // No global handler in tests; just record the call.
@@ -39,9 +71,6 @@ class FakeServicioAudio extends ServicioAudio {
@override @override
Emisora? get emisoraActual => _emisoraActual; Emisora? get emisoraActual => _emisoraActual;
@override
bool get ecualizadorDisponible => ecualizadorActivo;
@override @override
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream; Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
@@ -105,6 +134,7 @@ class FakeServicioAudio extends ServicioAudio {
@override @override
Future<void> aplicarPreset(PresetEcualizador preset) async { Future<void> aplicarPreset(PresetEcualizador preset) async {
presetsAplicados.add(preset); presetsAplicados.add(preset);
_presetActualValor = preset;
} }
@override @override
@@ -113,6 +143,7 @@ class FakeServicioAudio extends ServicioAudio {
@override @override
Future<void> setEcualizadorActivo(bool activo) async { Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo); cambiosEcualizadorActivo.add(activo);
_ecualizadorActivoValor = activo;
} }
@override @override
@@ -140,7 +171,45 @@ class FakeServicioFavoritos extends ServicioFavoritos {
@override @override
Future<void> agregar(Emisora emisora) async { Future<void> agregar(Emisora emisora) async {
_favoritos.removeWhere((e) => e.uuid == emisora.uuid); _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 @override
@@ -349,6 +418,10 @@ class FakeServicioEcualizador extends ServicioEcualizador {
ConfiguracionEcualizador _config; ConfiguracionEcualizador _config;
ConfiguracionEcualizador get config => _config; ConfiguracionEcualizador get config => _config;
/// Number of times [guardarActivo] has been called — lets tests assert a
/// persistence write happened exactly once (eq-sync-superficies).
int guardarActivoLlamadas = 0;
@override @override
Future<ConfiguracionEcualizador> cargar() async => _config; Future<ConfiguracionEcualizador> cargar() async => _config;
@@ -367,6 +440,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
@override @override
Future<void> guardarActivo(bool activo) async { Future<void> guardarActivo(bool activo) async {
guardarActivoLlamadas++;
_config = ConfiguracionEcualizador( _config = ConfiguracionEcualizador(
principal: _config.principal, principal: _config.principal,
porEmisora: _config.porEmisora, porEmisora: _config.porEmisora,
@@ -378,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 @override
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async { Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora); 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.
+9
View File
@@ -46,6 +46,9 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
('pt', 'welcomeBullet2Title'), ('pt', 'welcomeBullet2Title'),
('ru', 'welcomeBullet2Title'), ('ru', 'welcomeBullet2Title'),
('zh', 'welcomeBullet2Title'), ('zh', 'welcomeBullet2Title'),
// `premiumBeneficioAndroidAuto` used to live here as a bare "Android
// Auto" product name. It is now a real sentence describing what PRO adds
// in the car, so every locale translates it and no entry belongs here.
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Pure symbols / placeholders -- no translatable text at all. // Pure symbols / placeholders -- no translatable text at all.
@@ -286,4 +289,10 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
'restaurarCompras', 'restaurarCompras',
), // iap-freemium-unlock new key -- "Restaurar compras" is the standard ), // iap-freemium-unlock new key -- "Restaurar compras" is the standard
// Portuguese store wording and coincides with es word for word. // 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.
}; };
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'arb_test_helpers.dart';
/// Play "Deceptive Behavior" guard over the purchase sheet's copy.
///
/// The free Android Auto root is NOT a paywall: `ConstructorArbolAuto.raiz`
/// hands free users a real, browsable `idDestacadas` folder whose rows are
/// playable (`hijosDestacadas`), precisely because Google Play cited the old
/// "Premium feature" dead-end rows against the Android for Cars App Quality
/// Guidelines (see the comment at `navegacion_auto.dart`). What PRO adds in
/// the car is the rest of the tree: the full catalogue, favourites, my
/// stations and local music.
///
/// So a bullet reading just "Android Auto" claims the free tier has no
/// Android Auto at all, which is false in every locale.
void main() {
test('premiumBeneficioAndroidAuto no es el nombre pelado del producto en '
'ninguna de las 13 locales', () {
final ofensores = <String>[];
for (final locale in supportedArbLocales) {
final valor = readArb(locale)['premiumBeneficioAndroidAuto'] as String?;
if (valor == null) continue;
if (valor.trim() == 'Android Auto') ofensores.add(locale);
}
expect(
ofensores,
isEmpty,
reason:
'Free users already get a playable Android Auto folder, so a '
'bullet whose whole text is the product name sells them '
'something they have. Describe what PRO actually adds in the '
'car instead. Offending locales: $ofensores',
);
});
test('premiumBeneficioAndroidAuto nombra lo que PRO anade de verdad en el '
'coche (plantilla es)', () {
final es = readArb('es')['premiumBeneficioAndroidAuto'] as String;
final minusculas = es.toLowerCase();
expect(minusculas, contains('android auto'));
expect(
minusculas,
contains('catálogo'),
reason: 'the full catalogue (idTodas) is the headline PRO folder',
);
expect(minusculas, contains('favoritos'));
expect(minusculas, contains('mis emisoras'));
expect(minusculas, contains('música local'));
});
}
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.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:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -65,4 +68,81 @@ void main() {
expect(find.text('No folder selected'), findsOneWidget); 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',
);
});
});
} }
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_visualizador.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Play sensitive-permission contract for the waveform visualizer's
/// microphone opt-in.
///
/// Turning the switch ON is what eventually makes the native side ask for
/// `RECORD_AUDIO`, so the explanation must be on screen and accepted BEFORE
/// the flag flips — never a system dialog the user meets cold.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<EstadoVisualizador> montar(WidgetTester tester) async {
final prefs = await SharedPreferences.getInstance();
final estado = EstadoVisualizador(prefs: prefs);
addTearDown(estado.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<EstadoVisualizador>.value(
value: estado,
child: const MaterialApp(
locale: Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaAjustesVisualizador(),
),
),
);
await tester.pumpAndSettle();
return estado;
}
AppLocalizations textos(WidgetTester tester) => AppLocalizations.of(
tester.element(find.byType(PantallaAjustesVisualizador)),
);
testWidgets('arranca desactivado: la onda real es opt-in, nunca el defecto', (
tester,
) async {
final estado = await montar(tester);
expect(estado.ondaRealHabilitada, isFalse);
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
});
testWidgets(
'activar muestra PRIMERO la explicacion y NO cambia el ajuste todavia',
(tester) async {
final estado = await montar(tester);
final l10n = textos(tester);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
final dialogo = find.byKey(
const ValueKey('visualizador-explicacion-permiso'),
);
expect(dialogo, findsOneWidget);
expect(
find.descendant(
of: dialogo,
matching: find.text(l10n.visualizerRealWavePermissionExplanation),
),
findsOneWidget,
);
expect(
estado.ondaRealHabilitada,
isFalse,
reason:
'the flag must not flip while the explanation is still on '
'screen — flipping it is what triggers the permission request',
);
},
);
testWidgets('cancelar la explicacion deja el ajuste desactivado', (
tester,
) async {
final estado = await montar(tester);
final l10n = textos(tester);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.cancelAction));
await tester.pumpAndSettle();
expect(estado.ondaRealHabilitada, isFalse);
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
});
testWidgets('aceptar la explicacion activa el ajuste y lo persiste', (
tester,
) async {
final estado = await montar(tester);
final l10n = textos(tester);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.visualizerRealWaveEnableAction));
await tester.pumpAndSettle();
expect(estado.ondaRealHabilitada, isTrue);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool(EstadoVisualizador.claveOndaReal), isTrue);
});
testWidgets('desactivar NO pide explicacion: retirar un permiso es libre', (
tester,
) async {
SharedPreferences.setMockInitialValues({
EstadoVisualizador.claveOndaReal: true,
});
final estado = await montar(tester);
expect(estado.ondaRealHabilitada, isTrue);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('visualizador-explicacion-permiso')),
findsNothing,
);
expect(estado.ondaRealHabilitada, isFalse);
});
}
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/archivo_grabacion.dart'; import 'package:pluriwave/modelos/archivo_grabacion.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
@@ -108,6 +109,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>( ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null), create: (_) => EstadoEntitlement(prefs: null),
), ),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
], ],
child: MaterialApp( child: MaterialApp(
locale: const Locale('en'), locale: const Locale('en'),
+9 -1
View File
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
@@ -54,6 +55,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>( ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null), create: (_) => EstadoEntitlement(prefs: null),
), ),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
], ],
child: MaterialApp( child: MaterialApp(
locale: const Locale('en'), locale: const Locale('en'),
@@ -100,7 +104,7 @@ void main() {
// completes the remaining 5 sections, so the root is now exactly 4 // completes the remaining 5 sections, so the root is now exactly 4
// GrupoAjustes cards, under 400 lines. // GrupoAjustes cards, under 400 lines.
group('WU3a — AUDIO and EMISORAS groups', () { group('WU3a — AUDIO and EMISORAS groups', () {
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', ( testWidgets('AUDIO group renders exactly 4 nav rows, no inline controls', (
tester, tester,
) async { ) async {
setLargeSurface(tester); setLargeSurface(tester);
@@ -114,6 +118,10 @@ void main() {
expect(find.text('AUDIO'), findsOneWidget); expect(find.text('AUDIO'), findsOneWidget);
expect(find.text('Equalizer'), findsOneWidget); expect(find.text('Equalizer'), findsOneWidget);
expect(find.text('Advanced Equalization Options'), findsOneWidget); expect(find.text('Advanced Equalization Options'), findsOneWidget);
// The waveform visualizer's microphone opt-in is reachable from the
// root: the RECORD_AUDIO request must have a settings home the user
// can find, not only the moment they happen to press play.
expect(find.text('Real audio waveform'), findsOneWidget);
expect(find.text('Sleep timer'), findsOneWidget); expect(find.text('Sleep timer'), findsOneWidget);
// Zero inline controls: the old always-visible enable switch and // Zero inline controls: the old always-visible enable switch and
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/formato_fechas.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart'; import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
@@ -137,16 +138,29 @@ void main() {
) async { ) async {
final estado = crearEstado(); final estado = crearEstado();
addTearDown(estado.dispose); addTearDown(estado.dispose);
// The pill only renders for the ACTIVE or NEXT range
// (`EstadoAlarmas.vacacionesProximas` keeps `inicioDia.isAfter(hoy)`), so
// the range has to be in the future RELATIVE TO THE RUN. Hardcoded
// calendar dates silently rot into the past and turn this into a
// date-dependent failure; anchor on next month instead, days 4-18 so the
// range never straddles a month boundary and the label stays "dd MON".
final ahora = DateTime.now();
final mesSiguiente = DateTime(ahora.year, ahora.month + 1);
final inicio = DateTime(mesSiguiente.year, mesSiguiente.month, 4);
final fin = DateTime(mesSiguiente.year, mesSiguiente.month, 18);
await estado.crearRangoVacaciones( await estado.crearRangoVacaciones(
estado.servicio.crearRangoVacaciones( estado.servicio.crearRangoVacaciones(
inicio: DateTime(2026, 8, 4), inicio: inicio,
fin: DateTime(2026, 8, 18), fin: fin,
nombre: 'Summer', nombre: 'Summer',
), ),
); );
await montar(tester, estadoAlarmas: estado); await montar(tester, estadoAlarmas: estado);
expect(find.text('418 AUG'), findsOneWidget); // Assert against the same pure formatter the widget uses, so this stays
// a check that the pill IS rendered in the right shape rather than a
// duplicate of the formatter's own logic.
expect(find.text(rangoFechasCorto('en', inicio, fin)), findsOneWidget);
}); });
}); });
+139 -21
View File
@@ -15,16 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
/// WU15: the recordings library screen — storage usage, browsable rows /// WU15: the recordings library screen — storage usage, browsable rows
/// (name/date/duration/size) with inline playback, and a "⋮" menu /// (name/date/duration/size) with inline playback, and a "⋮" menu
/// constrained to exactly Rename/Share/Delete. /// constrained to exactly Rename/Open-in-another-app/Delete.
/// ///
/// [ReproductorGrabaciones] is always injected with a fake here: /// [ReproductorGrabaciones] is always injected with a fake here:
/// constructing a real `just_audio.AudioPlayer` needs platform /// constructing a real `just_audio.AudioPlayer` needs platform
/// `MethodChannel`s this suite does not mock — the same documented /// `MethodChannel`s this suite does not mock — the same documented
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`. /// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
/// Likewise, `compartir` is always injected with a fake recorder instead of /// Likewise, `abrirEnOtraApp` is always injected with a fake recorder
/// the real `share_plus` call, since this suite does not mock that channel /// instead of the real `pluriwave/file_actions` round trip, since this suite
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same /// does not mock that channel either (see `pantalla_ajustes_backup_test.dart`
/// constraint). /// for the same constraint).
/// ///
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`): /// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
/// PluriGlassSurface paints a background over ListTile's ink layer, which /// PluriGlassSurface paints a background over ListTile's ink layer, which
@@ -72,7 +72,7 @@ void main() {
Widget buildScreen({ Widget buildScreen({
required EstadoGrabacion estado, required EstadoGrabacion estado,
required ReproductorGrabaciones reproductor, required ReproductorGrabaciones reproductor,
Future<void> Function(String ruta)? compartir, Future<bool> Function(String ruta)? abrirEnOtraApp,
}) { }) {
return ListenableProvider<EstadoGrabacion>.value( return ListenableProvider<EstadoGrabacion>.value(
value: estado, value: estado,
@@ -82,7 +82,7 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: PantallaGrabaciones( home: PantallaGrabaciones(
reproductor: reproductor, reproductor: reproductor,
compartir: compartir ?? (_) async {}, abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
), ),
), ),
); );
@@ -117,6 +117,66 @@ void main() {
expect(find.text('My recordings'), findsOneWidget); expect(find.text('My recordings'), findsOneWidget);
}); });
group('aviso de uso privado', () {
/// Recording a broadcast is defensible as a private copy, and stops
/// being defensible the moment the product reads as a redistribution
/// tool. The library screen had no such statement at all, while the
/// manifest already exposes the recordings folder to the system file
/// manager, so the notice states the intended use in plain words.
testWidgets(
'la biblioteca muestra el aviso de uso personal con la lista vacia',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos(
const [],
maxBytesFijo: 200 * 1024 * 1024,
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
},
);
testWidgets('el aviso sigue presente con grabaciones en la lista', (
tester,
) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
fijaB,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
});
});
testWidgets( testWidgets(
'WU15b: tapping the settings icon pushes the folder/size settings ' 'WU15b: tapping the settings icon pushes the folder/size settings '
'screen (PantallaAjustesGrabaciones stays reachable, now from within ' 'screen (PantallaAjustesGrabaciones stays reachable, now from within '
@@ -379,9 +439,12 @@ void main() {
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing); expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
}); });
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', ( // The middle entry used to be Share, which handed the audio file to the
tester, // system share sheet. It is now a LOCAL open: play your own recording in
) async { // another app on the same device. The exact-count assertion is the guard
// that no off-device action creeps back in beside it.
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Open in another '
'app, Delete', (tester) async {
final estado = EstadoGrabacion( final estado = EstadoGrabacion(
esPremium: () => true, esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([ servicio: _FakeServicioGrabacionConArchivos([
@@ -405,7 +468,10 @@ void main() {
find.widgetWithText(PopupMenuItem<String>, 'Rename'), find.widgetWithText(PopupMenuItem<String>, 'Rename'),
findsOneWidget, findsOneWidget,
); );
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget); expect(
find.widgetWithText(PopupMenuItem<String>, 'Open in another app'),
findsOneWidget,
);
expect( expect(
find.widgetWithText(PopupMenuItem<String>, 'Delete'), find.widgetWithText(PopupMenuItem<String>, 'Delete'),
findsOneWidget, findsOneWidget,
@@ -555,10 +621,15 @@ void main() {
skip: true, skip: true,
); );
testWidgets( /// The row menu used to hand the audio file to the system share sheet,
'15.4-C: Share invokes the injected share callback with the file path', /// which is redistribution of someone else's broadcast. What the owner
(tester) async { /// actually wanted is to play your own recording in another app on the
final compartidos = <String>[]; /// same device, so the action is a local ACTION_VIEW instead.
group('15.4-C: abrir la grabacion en otra app del dispositivo', () {
testWidgets('invoca el seam de apertura local con la ruta del archivo', (
tester,
) async {
final abiertos = <String>[];
final estado = EstadoGrabacion( final estado = EstadoGrabacion(
esPremium: () => true, esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([ servicio: _FakeServicioGrabacionConArchivos([
@@ -571,21 +642,68 @@ void main() {
buildScreen( buildScreen(
estado: estado, estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}), reproductor: _ReproductorGrabacionesFake(const {}),
compartir: (ruta) async { abrirEnOtraApp: (ruta) async {
compartidos.add(ruta); abiertos.add(ruta);
return true;
}, },
), ),
); );
await pumpStable(tester); await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded)); await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share')); await tester.tap(
find.widgetWithText(PopupMenuItem<String>, l10n.recordingActionOpenIn),
);
await pumpStable(tester); await pumpStable(tester);
expect(compartidos, [fijaA.ruta]); expect(abiertos, [fijaA.ruta]);
}, });
);
testWidgets(
'si ningun reproductor del dispositivo puede abrirla, lo dice en vez '
'de fallar en silencio',
(tester) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
abrirEnOtraApp: (_) async => false,
),
);
await pumpStable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaGrabaciones)),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(
PopupMenuItem<String>,
l10n.recordingActionOpenIn,
),
);
await pumpStable(tester);
expect(
find.widgetWithText(SnackBar, l10n.recordingOpenNoAppError),
findsOneWidget,
);
},
);
});
} }
// ── Infrastructure ────────────────────────────────────────────────────────── // ── Infrastructure ──────────────────────────────────────────────────────────
+4
View File
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_navegacion.dart'; import 'package:pluriwave/estado/estado_navegacion.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart'; import 'package:pluriwave/pantallas/pantalla_inicio.dart';
@@ -804,6 +805,9 @@ Widget _conProviders(
ChangeNotifierProvider<EstadoRadio>.value(value: estado), ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador), ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion), ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda), ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
ChangeNotifierProvider<EstadoNavegacionRaiz>.value( ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
value: navegacion ?? EstadoNavegacionRaiz(), value: navegacion ?? EstadoNavegacionRaiz(),
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
@@ -106,6 +107,9 @@ void main() {
ChangeNotifierProvider<EstadoRadio>.value(value: estado), ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador), ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion), ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
], ],
child: MaterialApp( child: MaterialApp(
locale: const Locale('en'), locale: const Locale('en'),
@@ -482,6 +486,32 @@ void main() {
}, },
); );
// This tile shares the STATION (its name and its url) — never an audio
// file. It used to borrow `recordingActionShare`, the recordings
// library's own menu label, so one key stood for two different
// actions and the tile could not say which one it performed. Its
// label is now its own key, and it names the station.
testWidgets('la etiqueta del boton de compartir nombra la emisora, no un '
'generico "Compartir" compartido con la biblioteca de grabaciones', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaReproductor)),
);
expect(
find.descendant(
of: find.byKey(const Key('player-tool-share')),
matching: find.text(l10n.stationActionShare),
),
findsOneWidget,
);
});
testWidgets( testWidgets(
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type', 'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
(tester) async { (tester) async {
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
@@ -80,6 +81,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>( ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null), create: (_) => EstadoEntitlement(prefs: null),
), ),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
Provider<ServicioAnuncios>( Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => true), create: (_) => ServicioAnuncios(esPremium: () => true),
), ),
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/estado/estado_grabacion.dart'; import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.dart'; import 'package:pluriwave/estado/estado_idioma.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/estado/estado_visualizador.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart'; import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart'; import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
@@ -78,6 +79,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>( ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null), create: (_) => EstadoEntitlement(prefs: null),
), ),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
Provider<ServicioAnuncios>( Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => true), create: (_) => ServicioAnuncios(esPremium: () => true),
), ),
+142
View File
@@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Requested: the Android Auto playback screen must offer previous/next for /// 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', () { group('esArchivoAudio', () {
test('acepta cualquier MIME audio/*, en cualquier capitalización', () { test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue); 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:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier /// Android Auto entitlement gating — the id-shape matrix.
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content", ///
/// design.md ADR-4). All pure — no handler instantiation needed /// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test). /// 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() { void main() {
group('raiz(premium:) — root keeps its labels for every tier', () { const gratuitas = [
test('premium: identical to today\'s tree (regression guard)', () { Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
final constructor = ConstructorArbolAuto(); Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
];
final premiumConLocal = constructor.raiz( group('idPermitidoEnFree', () {
incluirMusicaLocal: true, test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
premium: true, 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', () { test('la carpeta gratuita pasa', () {
final constructor = ConstructorArbolAuto(); expect(
idPermitidoEnFree(
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false); ConstructorArbolAuto.idDestacadas,
destacadas: gratuitas,
expect(libre, isNotEmpty); ),
expect(libre.map((m) => m.id), [ isTrue,
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,
); );
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 [ for (final id in [
ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal, ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador, 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( expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
parentMediaId: id, reason: id);
premium: false,
);
expect(respuesta, hasLength(1));
expect(respuesta!.single.id, 'premium:info');
} }
}); });
test('cualquier id no-root, en premium, no es bloqueada', () { test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
final respuesta = respuestaBloqueadaPorEntitlement( 'ésa es la propiedad de seguridad que el rediseño tenía que '
parentMediaId: ConstructorArbolAuto.idFavoritos, 'conservar', () {
premium: true, 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';
}

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