21 Commits
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
74 changed files with 4559 additions and 457 deletions
+57 -13
View File
@@ -68,7 +68,18 @@ jobs:
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
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: |
BRANCH="${CURRENT_REF#refs/heads/}"
git config user.name "ShanaiaBot"
@@ -77,12 +88,20 @@ jobs:
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
NEW_BUILD=$((BUILD + 1))
# If the triggering commit explicitly pins the version name via the
# [version set] marker, ship that semver as-is (a milestone like 1.0.0
# or a major/minor jump the automatic patch bump cannot reach) and only
# advance the build number, which Google Play requires to stay
# monotonic. Otherwise keep the default automatic patch+build bump.
if git log -1 --pretty=%B | grep -q '\[version set\]'; then
# Look for [version set] across EVERY commit this push introduced,
# not just the tip. `git pull` inserts an auto-generated merge commit
# whose message carries no marker, which silently discarded a pinned
# version name and bumped 1.3.0 to 1.3.1 behind our backs.
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}"
else
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
@@ -91,6 +110,8 @@ jobs:
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
fi
echo "rama=${BRANCH} marcador=${MARCADOR} ${CURRENT} -> ${NEW_VERSION}"
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
@@ -255,7 +276,29 @@ jobs:
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
# 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 -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
@@ -265,8 +308,11 @@ jobs:
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
# La ruta se imprime desde ${APP}, no a mano: la version anterior tenia
# "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.
#
@@ -319,9 +365,7 @@ jobs:
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
# Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
# se omite si falta el secreto, y un aviso que dice "publicado"
# cuando no se publico es peor que no avisar.
# 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"
elif [ "$BRANCH" = "PRO" ]; then
+8 -1
View File
@@ -12,8 +12,15 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- 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_FINE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!--
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
+11
View File
@@ -10,6 +10,7 @@ import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
import 'estado/estado_navegacion.dart';
import 'estado/estado_visualizador.dart';
import 'servicios/servicio_anuncios.dart';
import 'servicios/servicio_compras.dart';
import 'widgets/banner_anuncio_superior.dart';
@@ -122,6 +123,12 @@ class PluriWaveApp extends StatelessWidget {
ChangeNotifierProvider(
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`
// watches this instead of owning `_indice` locally.
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
// (no-op when multi-device EQ is off).
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
+17
View File
@@ -663,12 +663,29 @@ class EstadoEcualizador extends ChangeNotifier {
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
/// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about.
///
/// The handler can also REFUSE the change: when the native `setEnabled`
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
/// flag back and skips its persistence write, so the value we optimistically
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
/// learn that: on divergence we adopt the handler's real value and return
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
/// to disk that would resurrect it on the next start. The supersede check
/// runs FIRST so a newer tap still owns the outcome; the read-back only
/// speaks for a call nobody overtook.
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
notifyListeners();
await audio.setEcualizadorActivo(activo);
if (_activo != activo) return;
final aceptado = audio.ecualizadorActivo;
if (aceptado != activo) {
_activo = aceptado;
notifyListeners();
return;
}
if (activo) {
await audio.aplicarPreset(_presetActual);
if (_activo != activo) return;
+77 -8
View File
@@ -5,12 +5,14 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
import '../servicios/verificacion_licencia.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds").
const _keyPremium = 'compra_premium_v1';
/// builds"). Shared with the silent license re-verification
/// (`verificacion_licencia.dart`), which may revoke it after a refund.
const _keyPremium = claveCompraPremium;
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
: _prefs = prefs,
_compras = compras {
EstadoEntitlement({
SharedPreferences? prefs,
PuertoCompras? compras,
DateTime Function()? reloj,
}) : _prefs = prefs,
_compras = compras,
_reloj = reloj {
final flujo = _compras;
if (flujo != null) {
_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"),
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
final SharedPreferences? _prefs;
final PuertoCompras? _compras;
/// Injectable clock for the license check's throttle/spacing rules.
final DateTime Function()? _reloj;
StreamSubscription<EventoCompra>? _comprasSub;
bool _desechado = false;
bool _esPremium = false;
bool _compraEnCurso = false;
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
Future<SharedPreferences> _resolverPrefs() async =>
_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
/// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted.
@@ -171,11 +231,19 @@ class EstadoEntitlement extends ChangeNotifier {
}
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;
_esPremium = true;
_compraEnCurso = false;
final prefs = await _resolverPrefs();
await prefs.setBool(_keyPremium, true);
final escritura = 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();
if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively
@@ -187,6 +255,7 @@ class EstadoEntitlement extends ChangeNotifier {
@override
void dispose() {
_desechado = true;
_comprasSub?.cancel();
super.dispose();
}
+27 -10
View File
@@ -182,24 +182,41 @@ class EstadoGrabacion extends ChangeNotifier {
return launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimoArchivo;
if (archivo == null || !await archivo.exists()) {
debugPrint('[PluriWave][recordings] last recording missing');
/// Hands the recording at [ruta] to whatever player the user already has
/// on THIS device (`ACTION_VIEW` through the native `openFile` method,
/// over the app's own `FileProvider`). Nothing leaves the device: this is
/// 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;
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
debugPrint('[PluriWave][recordings] opening file: $ruta');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path,
'path': ruta,
'mimeType': 'audio/*',
});
return abierto ?? false;
}
return launchUrl(
Uri.file(archivo.path),
mode: LaunchMode.externalApplication,
);
return launchUrl(Uri.file(ruta), 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
+13 -25
View File
@@ -338,24 +338,6 @@ class EstadoRadio extends ChangeNotifier {
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
@@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
// Issue 4's write used to live here as well. It is gone: the handler
// persists every station itself from `_cambiarFuente`, which is the
// same source change that moved `audio.emisoraActual` and is the
// reason this branch runs at all. Writing again here would make the
// key's final value depend on how two independent fire-and-forget
// chains interleave on a fast station switch.
}
notifyListeners();
});
@@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier {
}
_emisoraSeleccionada = emisora;
notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
// below reaches for this very station — see
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
// left the key with TWO fire-and-forget writers whose relative order
// decides the value after a fast A -> B switch, and this one cannot see
// the revision guard that already cancels a superseded change.
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
+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();
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "تعذّر تحميل الدول.",
"recordingActionDelete": "حذف",
"recordingActionRename": "إعادة تسمية",
"recordingActionShare": "مشاركة",
"stationActionShare": "مشاركة المحطة",
"recordingActionOpenIn": "فتح في تطبيق آخر",
"recordingOpenNoAppError": "لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.",
"recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟",
"recordingRenameDialogTitle": "إعادة تسمية التسجيل",
@@ -907,7 +909,7 @@
"premiumActivo": "النسخة المميزة مفعّلة",
"premiumHojaTitulo": "افتح PluriWave Premium",
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioAndroidAuto": "الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)",
"premiumBeneficioGrabacion": "تسجيل المحطات",
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
"autoReproducirCarpeta": "تشغيل المجلد",
"autoReproducirAleatorio": "تشغيل عشوائي",
"autoPistaSinNombre": "مقطع بلا اسم"
"autoPistaSinNombre": "مقطع بلا اسم",
"recordingsPrivateUseNotice": "التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.",
"visualizerRealWaveTitle": "الموجة الحقيقية للصوت",
"visualizerRealWaveSubtitle": "تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.",
"visualizerRealWavePermissionExplanation": "لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.",
"visualizerRealWaveEnableAction": "تفعيل"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "দেশগুলো লোড করা যায়নি।",
"recordingActionDelete": "মুছে ফেলুন",
"recordingActionRename": "নাম পরিবর্তন করুন",
"recordingActionShare": "শেয়ার করুন",
"stationActionShare": "স্টেশন শেয়ার করুন",
"recordingActionOpenIn": "অন্য অ্যাপে খুলুন",
"recordingOpenNoAppError": "এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।",
"recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?",
"recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন",
@@ -907,7 +909,7 @@
"premiumActivo": "প্রিমিয়াম সক্রিয়",
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioAndroidAuto": "Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)",
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
"autoReproducirCarpeta": "ফোল্ডার চালান",
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
"autoPistaSinNombre": "নামহীন ট্র্যাক"
"autoPistaSinNombre": "নামহীন ট্র্যাক",
"recordingsPrivateUseNotice": "রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।",
"visualizerRealWaveTitle": "আসল অডিও তরঙ্গ",
"visualizerRealWaveSubtitle": "বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।",
"visualizerRealWavePermissionExplanation": "মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।",
"visualizerRealWaveEnableAction": "চালু করুন"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Die Länder konnten nicht geladen werden.",
"recordingActionDelete": "Löschen",
"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.",
"recordingDeleteConfirmTitle": "Aufnahme löschen?",
"recordingRenameDialogTitle": "Aufnahme umbenennen",
@@ -907,7 +909,7 @@
"premiumActivo": "Premium aktiv",
"premiumHojaTitulo": "PluriWave Premium freischalten",
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
"autoReproducirCarpeta": "Ordner abspielen",
"autoReproducirAleatorio": "Zufallswiedergabe",
"autoPistaSinNombre": "Unbenannter Titel"
"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"
}
+10 -3
View File
@@ -237,7 +237,9 @@
"recordingsLibraryEmptyTitle": "No recordings yet",
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
"recordingActionRename": "Rename",
"recordingActionShare": "Share",
"stationActionShare": "Share station",
"recordingActionOpenIn": "Open in another app",
"recordingOpenNoAppError": "No app on this device can play this recording.",
"recordingActionDelete": "Delete",
"recordingRenameDialogTitle": "Rename recording",
"recordingRenameLabel": "Name",
@@ -907,7 +909,7 @@
"premiumActivo": "Premium active",
"premiumHojaTitulo": "Unlock PluriWave Premium",
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Sort by quality",
"autoReproducirCarpeta": "Play folder",
"autoReproducirAleatorio": "Shuffle play",
"autoPistaSinNombre": "Untitled track"
"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"
}
+10 -3
View File
@@ -237,7 +237,9 @@
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
"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",
"recordingRenameDialogTitle": "Renombrar grabación",
"recordingRenameLabel": "Nombre",
@@ -866,7 +868,7 @@
"premiumActivo": "Premium activo",
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -884,5 +886,10 @@
"autoOrdenarPorCalidad": "Ordenar por calidad",
"autoReproducirCarpeta": "Reproducir carpeta",
"autoReproducirAleatorio": "Reproducir aleatorio",
"autoPistaSinNombre": "Pista sin nombre"
"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"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Impossible de charger les pays.",
"recordingActionDelete": "Supprimer",
"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.",
"recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?",
"recordingRenameDialogTitle": "Renommer l'enregistrement",
@@ -907,7 +909,7 @@
"premiumActivo": "Premium actif",
"premiumHojaTitulo": "Débloquer PluriWave Premium",
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Trier par qualité",
"autoReproducirCarpeta": "Lire le dossier",
"autoReproducirAleatorio": "Lecture aléatoire",
"autoPistaSinNombre": "Piste sans nom"
"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"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "देश लोड नहीं हो सके।",
"recordingActionDelete": "हटाएं",
"recordingActionRename": "नाम बदलें",
"recordingActionShare": "शेयर करें",
"stationActionShare": "स्टेशन शेयर करें",
"recordingActionOpenIn": "दूसरे ऐप में खोलें",
"recordingOpenNoAppError": "इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।",
"recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?",
"recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें",
@@ -907,7 +909,7 @@
"premiumActivo": "प्रीमियम सक्रिय",
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioAndroidAuto": "Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)",
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
"autoReproducirAleatorio": "शफ़ल चलाएँ",
"autoPistaSinNombre": "बिना नाम का ट्रैक"
"autoPistaSinNombre": "बिना नाम का ट्रैक",
"recordingsPrivateUseNotice": "रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।",
"visualizerRealWaveTitle": "असली ऑडियो तरंग",
"visualizerRealWaveSubtitle": "बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।",
"visualizerRealWavePermissionExplanation": "Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।",
"visualizerRealWaveEnableAction": "चालू करें"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Negara tidak dapat dimuat.",
"recordingActionDelete": "Hapus",
"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.",
"recordingDeleteConfirmTitle": "Hapus rekaman?",
"recordingRenameDialogTitle": "Ganti nama rekaman",
@@ -907,7 +909,7 @@
"premiumActivo": "Premium aktif",
"premiumHojaTitulo": "Buka PluriWave Premium",
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
"autoReproducirCarpeta": "Putar folder",
"autoReproducirAleatorio": "Putar acak",
"autoPistaSinNombre": "Trek tanpa nama"
"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"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Non è stato possibile caricare i paesi.",
"recordingActionDelete": "Elimina",
"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.",
"recordingDeleteConfirmTitle": "Eliminare la registrazione?",
"recordingRenameDialogTitle": "Rinomina registrazione",
@@ -907,7 +909,7 @@
"premiumActivo": "Premium attivo",
"premiumHojaTitulo": "Sblocca PluriWave Premium",
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Ordina per qualità",
"autoReproducirCarpeta": "Riproduci cartella",
"autoReproducirAleatorio": "Riproduzione casuale",
"autoPistaSinNombre": "Traccia senza nome"
"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"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "国の一覧を読み込めませんでした。",
"recordingActionDelete": "削除",
"recordingActionRename": "名前を変更",
"recordingActionShare": "共有",
"stationActionShare": "放送局を共有",
"recordingActionOpenIn": "別のアプリで開く",
"recordingOpenNoAppError": "この端末には、この録音を再生できるアプリがありません。",
"recordingDeleteConfirmMessage": "この操作は元に戻せません。",
"recordingDeleteConfirmTitle": "録音を削除しますか?",
"recordingRenameDialogTitle": "録音の名前を変更",
@@ -907,7 +909,7 @@
"premiumActivo": "プレミアム有効",
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioAndroidAuto": "Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)",
"premiumBeneficioGrabacion": "放送局の録音",
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "音質順に並べ替え",
"autoReproducirCarpeta": "フォルダを再生",
"autoReproducirAleatorio": "シャッフル再生",
"autoPistaSinNombre": "名称未設定のトラック"
"autoPistaSinNombre": "名称未設定のトラック",
"recordingsPrivateUseNotice": "録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。",
"visualizerRealWaveTitle": "実際の音声波形",
"visualizerRealWaveSubtitle": "バーが再生中の音に合わせて動きます。オフのときは独自に動きます。",
"visualizerRealWavePermissionExplanation": "Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。",
"visualizerRealWaveEnableAction": "オンにする"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Não foi possível carregar os países.",
"recordingActionDelete": "Excluir",
"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.",
"recordingDeleteConfirmTitle": "Excluir gravação?",
"recordingRenameDialogTitle": "Renomear gravação",
@@ -907,7 +909,7 @@
"premiumActivo": "Premium ativo",
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
"premiumBeneficioAndroidAuto": "Android Auto",
"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)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Ordenar por qualidade",
"autoReproducirCarpeta": "Reproduzir pasta",
"autoReproducirAleatorio": "Reprodução aleatória",
"autoPistaSinNombre": "Faixa sem nome"
"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"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "Не удалось загрузить страны.",
"recordingActionDelete": "Удалить",
"recordingActionRename": "Переименовать",
"recordingActionShare": "Поделиться",
"stationActionShare": "Поделиться станцией",
"recordingActionOpenIn": "Открыть в другом приложении",
"recordingOpenNoAppError": "На этом устройстве нет приложения, способного воспроизвести эту запись.",
"recordingDeleteConfirmMessage": "Это действие нельзя отменить.",
"recordingDeleteConfirmTitle": "Удалить запись?",
"recordingRenameDialogTitle": "Переименовать запись",
@@ -907,7 +909,7 @@
"premiumActivo": "Премиум активен",
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioAndroidAuto": "Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)",
"premiumBeneficioGrabacion": "Запись радиостанций",
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "Сортировать по качеству",
"autoReproducirCarpeta": "Воспроизвести папку",
"autoReproducirAleatorio": "Случайное воспроизведение",
"autoPistaSinNombre": "Трек без названия"
"autoPistaSinNombre": "Трек без названия",
"recordingsPrivateUseNotice": "Записи предназначены только для личного использования. Уважайте права станций и их контента.",
"visualizerRealWaveTitle": "Реальная звуковая волна",
"visualizerRealWaveSubtitle": "Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.",
"visualizerRealWavePermissionExplanation": "Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.",
"visualizerRealWaveEnableAction": "Включить"
}
+10 -3
View File
@@ -769,7 +769,9 @@
"radioCountriesError": "无法加载国家列表。",
"recordingActionDelete": "删除",
"recordingActionRename": "重命名",
"recordingActionShare": "分享",
"stationActionShare": "分享电台",
"recordingActionOpenIn": "用其他应用打开",
"recordingOpenNoAppError": "此设备上没有可播放该录音的应用。",
"recordingDeleteConfirmMessage": "此操作无法撤销。",
"recordingDeleteConfirmTitle": "删除录音?",
"recordingRenameDialogTitle": "重命名录音",
@@ -907,7 +909,7 @@
"premiumActivo": "高级版已解锁",
"premiumHojaTitulo": "解锁 PluriWave Premium",
"premiumBeneficioSinAnuncios": "全应用无广告",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioAndroidAuto": "Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)",
"premiumBeneficioGrabacion": "电台录音",
"premiumBeneficioVacaciones": "闹钟的假期时间段",
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
@@ -925,5 +927,10 @@
"autoOrdenarPorCalidad": "按音质排序",
"autoReproducirCarpeta": "播放文件夹",
"autoReproducirAleatorio": "随机播放",
"autoPistaSinNombre": "未命名曲目"
"autoPistaSinNombre": "未命名曲目",
"recordingsPrivateUseNotice": "录音仅供你个人使用。请尊重各电台及其内容的权利。",
"visualizerRealWaveTitle": "真实音频波形",
"visualizerRealWaveSubtitle": "音条会跟随正在播放的声音。关闭时,音条会自行跳动。",
"visualizerRealWavePermissionExplanation": "Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。",
"visualizerRealWaveEnableAction": "开启"
}
+46 -4
View File
@@ -904,11 +904,23 @@ abstract class AppLocalizations {
/// **'Renombrar'**
String get recordingActionRename;
/// No description provided for @recordingActionShare.
/// No description provided for @stationActionShare.
///
/// In es, this message translates to:
/// **'Compartir'**
String get recordingActionShare;
/// **'Compartir emisora'**
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.
///
@@ -3383,7 +3395,7 @@ abstract class AppLocalizations {
/// No description provided for @premiumBeneficioAndroidAuto.
///
/// In es, this message translates to:
/// **'Android Auto'**
/// **'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.
@@ -3493,6 +3505,36 @@ abstract class AppLocalizations {
/// 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
+28 -2
View File
@@ -456,7 +456,14 @@ class AppLocalizationsAr extends AppLocalizations {
String get recordingActionRename => 'إعادة تسمية';
@override
String get recordingActionShare => 'مشاركة';
String get stationActionShare => 'مشاركة المحطة';
@override
String get recordingActionOpenIn => 'فتح في تطبيق آخر';
@override
String get recordingOpenNoAppError =>
'لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.';
@override
String get recordingActionDelete => 'حذف';
@@ -1871,7 +1878,8 @@ class AppLocalizationsAr extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)';
@override
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
@@ -1930,4 +1938,22 @@ class AppLocalizationsAr extends AppLocalizations {
@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 => 'تفعيل';
}
+28 -2
View File
@@ -461,7 +461,14 @@ class AppLocalizationsBn extends AppLocalizations {
String get recordingActionRename => 'নাম পরিবর্তন করুন';
@override
String get recordingActionShare => 'শেয়ার করুন';
String get stationActionShare => 'স্টেশন শেয়ার করুন';
@override
String get recordingActionOpenIn => 'অন্য অ্যাপে খুলুন';
@override
String get recordingOpenNoAppError =>
'এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।';
@override
String get recordingActionDelete => 'মুছে ফেলুন';
@@ -1882,7 +1889,8 @@ class AppLocalizationsBn extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)';
@override
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
@@ -1942,4 +1950,22 @@ class AppLocalizationsBn extends AppLocalizations {
@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 => 'চালু করুন';
}
+28 -2
View File
@@ -464,7 +464,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get recordingActionRename => 'Umbenennen';
@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
String get recordingActionDelete => 'Löschen';
@@ -1896,7 +1903,8 @@ class AppLocalizationsDe extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
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';
@@ -1955,4 +1963,22 @@ class AppLocalizationsDe extends AppLocalizations {
@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';
}
+28 -2
View File
@@ -458,7 +458,14 @@ class AppLocalizationsEn extends AppLocalizations {
String get recordingActionRename => 'Rename';
@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
String get recordingActionDelete => 'Delete';
@@ -1875,7 +1882,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
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';
@@ -1935,4 +1943,22 @@ class AppLocalizationsEn extends AppLocalizations {
@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';
}
+28 -2
View File
@@ -462,7 +462,14 @@ class AppLocalizationsEs extends AppLocalizations {
String get recordingActionRename => 'Renombrar';
@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
String get recordingActionDelete => 'Eliminar';
@@ -1889,7 +1896,8 @@ class AppLocalizationsEs extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
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';
@@ -1950,4 +1958,22 @@ class AppLocalizationsEs extends AppLocalizations {
@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';
}
+28 -2
View File
@@ -467,7 +467,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get recordingActionRename => 'Renommer';
@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
String get recordingActionDelete => 'Supprimer';
@@ -1903,7 +1910,8 @@ class AppLocalizationsFr extends AppLocalizations {
'Aucune publicité dans toute l\'application';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
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';
@@ -1964,4 +1972,22 @@ class AppLocalizationsFr extends AppLocalizations {
@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';
}
+28 -2
View File
@@ -459,7 +459,14 @@ class AppLocalizationsHi extends AppLocalizations {
String get recordingActionRename => 'नाम बदलें';
@override
String get recordingActionShare => 'शेयर करें';
String get stationActionShare => 'स्टेशन शेयर करें';
@override
String get recordingActionOpenIn => 'दूसरे ऐप में खोलें';
@override
String get recordingOpenNoAppError =>
'इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।';
@override
String get recordingActionDelete => 'हटाएं';
@@ -1875,7 +1882,8 @@ class AppLocalizationsHi extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)';
@override
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
@@ -1935,4 +1943,22 @@ class AppLocalizationsHi extends AppLocalizations {
@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 => 'चालू करें';
}
+28 -2
View File
@@ -459,7 +459,14 @@ class AppLocalizationsId extends AppLocalizations {
String get recordingActionRename => 'Ganti nama';
@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
String get recordingActionDelete => 'Hapus';
@@ -1886,7 +1893,8 @@ class AppLocalizationsId extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)';
@override
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
@@ -1946,4 +1954,22 @@ class AppLocalizationsId extends AppLocalizations {
@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';
}
+28 -2
View File
@@ -463,7 +463,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get recordingActionRename => 'Rinomina';
@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
String get recordingActionDelete => 'Elimina';
@@ -1900,7 +1907,8 @@ class AppLocalizationsIt extends AppLocalizations {
'Nessuna pubblicità in tutta l\'app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
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';
@@ -1961,4 +1969,22 @@ class AppLocalizationsIt extends AppLocalizations {
@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';
}
+26 -2
View File
@@ -445,7 +445,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get recordingActionRename => '名前を変更';
@override
String get recordingActionShare => '共有';
String get stationActionShare => '放送局を共有';
@override
String get recordingActionOpenIn => '別のアプリで開く';
@override
String get recordingOpenNoAppError => 'この端末には、この録音を再生できるアプリがありません。';
@override
String get recordingActionDelete => '削除';
@@ -1820,7 +1826,8 @@ class AppLocalizationsJa extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)';
@override
String get premiumBeneficioGrabacion => '放送局の録音';
@@ -1877,4 +1884,21 @@ class AppLocalizationsJa extends AppLocalizations {
@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 => 'オンにする';
}
+28 -2
View File
@@ -461,7 +461,14 @@ class AppLocalizationsPt extends AppLocalizations {
String get recordingActionRename => 'Renomear';
@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
String get recordingActionDelete => 'Excluir';
@@ -1886,7 +1893,8 @@ class AppLocalizationsPt extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
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';
@@ -1946,4 +1954,22 @@ class AppLocalizationsPt extends AppLocalizations {
@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';
}
+28 -2
View File
@@ -461,7 +461,14 @@ class AppLocalizationsRu extends AppLocalizations {
String get recordingActionRename => 'Переименовать';
@override
String get recordingActionShare => 'Поделиться';
String get stationActionShare => 'Поделиться станцией';
@override
String get recordingActionOpenIn => 'Открыть в другом приложении';
@override
String get recordingOpenNoAppError =>
'На этом устройстве нет приложения, способного воспроизвести эту запись.';
@override
String get recordingActionDelete => 'Удалить';
@@ -1893,7 +1900,8 @@ class AppLocalizationsRu extends AppLocalizations {
'Никакой рекламы во всём приложении';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)';
@override
String get premiumBeneficioGrabacion => 'Запись радиостанций';
@@ -1953,4 +1961,22 @@ class AppLocalizationsRu extends AppLocalizations {
@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 => 'Включить';
}
+25 -2
View File
@@ -443,7 +443,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get recordingActionRename => '重命名';
@override
String get recordingActionShare => '分享';
String get stationActionShare => '分享电台';
@override
String get recordingActionOpenIn => '用其他应用打开';
@override
String get recordingOpenNoAppError => '此设备上没有可播放该录音的应用。';
@override
String get recordingActionDelete => '删除';
@@ -1805,7 +1811,8 @@ class AppLocalizationsZh extends AppLocalizations {
String get premiumBeneficioSinAnuncios => '全应用无广告';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
String get premiumBeneficioAndroidAuto =>
'在 Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)';
@override
String get premiumBeneficioGrabacion => '电台录音';
@@ -1861,4 +1868,20 @@ class AppLocalizationsZh extends AppLocalizations {
@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 => '开启';
}
+28 -2
View File
@@ -11,6 +11,7 @@ import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
@@ -19,6 +20,7 @@ import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'servicios/verificacion_licencia.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
@@ -145,6 +147,16 @@ Future<void> main() async {
// injected into every state/service below.
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
// injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a
@@ -197,6 +209,12 @@ Future<void> main() async {
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
@@ -204,6 +222,15 @@ Future<void> main() async {
// context the car could never have.
leerContextoSalto: contextoSaltoPersistido,
guardarContextoSalto: guardarContextoSalto,
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
// reason as the skip context: `EstadoRadio` — which used to be its only
// writer — belongs to the widget tree, and the Android Auto engine
// builds none, so a session that happened only in the car never updated
// the key and the head unit was offered whatever the PHONE last played.
// The write port is now the key's single writer; the read port feeds the
// cold-start metadata seed and the bare-`play()` resume.
leerUltimaEmisora: ultimaEmisoraPersistida,
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
@@ -338,8 +365,7 @@ bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
}) => !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
@@ -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_entitlement.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_visualizador.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.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_salida_audio.dart';
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
import 'ajustes/pantalla_ajustes_visualizador.dart';
import 'ajustes/widgets/fila_ajuste.dart';
import 'pantalla_grabaciones.dart';
@@ -104,6 +106,9 @@ class _AjustesContent extends StatelessWidget {
final esPremium = context.select<EstadoEntitlement, bool>(
(e) => e.esPremium,
);
final ondaRealActiva = context.select<EstadoVisualizador, bool>(
(e) => e.ondaRealHabilitada,
);
return Column(
children: [
@@ -137,6 +142,22 @@ class _AjustesContent extends StatelessWidget {
(_) => 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(
icon: Icons.bedtime_rounded,
titulo: l10n.timerSectionTitle,
+53 -13
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
@@ -90,19 +89,24 @@ class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
/// WU15: the recordings library — storage usage, browsable rows with
/// inline playback, and a "⋮" menu constrained to exactly
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// Rename/Open-in-another-app/Delete (`recordings-library` spec). Distinct
/// from `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// SETTINGS screen, not this browsable file list.
class PantallaGrabaciones extends StatefulWidget {
const PantallaGrabaciones({
super.key,
ReproductorGrabaciones? reproductor,
Future<void> Function(String ruta)? compartir,
Future<bool> Function(String ruta)? abrirEnOtraApp,
}) : _reproductorInyectado = reproductor,
_compartirInyectado = compartir;
_abrirEnOtraAppInyectada = abrirEnOtraApp;
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
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
@@ -111,8 +115,9 @@ class PantallaGrabaciones extends StatefulWidget {
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
late final ReproductorGrabaciones _reproductor =
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
late final Future<void> Function(String ruta) _compartir =
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
late final Future<bool> Function(String ruta) _abrirEnOtraApp =
widget._abrirEnOtraAppInyectada ??
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(ruta);
late Future<List<ArchivoGrabacion>> _grabaciones;
final Map<String, Future<Duration?>> _duracionCache = {};
@@ -153,8 +158,8 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
await _renombrar(archivo);
return;
}
if (accion == 'share') {
await _compartir(archivo.ruta);
if (accion == 'open') {
await _abrirLocalmente(archivo);
return;
}
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 {
final nuevoNombre = await showDialog<String>(
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
// favicon, so this is a themed fallback square, not invented artwork),
// 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(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
@@ -466,8 +506,8 @@ class _FilaGrabacion extends StatelessWidget {
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
value: 'open',
child: Text(l10n.recordingActionOpenIn),
),
PopupMenuItem(
value: 'delete',
+9
View File
@@ -6,6 +6,7 @@ import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_ecualizador.dart';
import '../estado/estado_navegacion.dart';
import '../estado/estado_radio.dart';
import '../estado/estado_visualizador.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
@@ -471,6 +472,14 @@ class _EscucharHero extends StatelessWidget {
// Audit 1.7 (t4 lines 66-68): 30 discrete
// bottom-anchored bars, not a continuous stroke.
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_grabacion.dart';
import '../estado/estado_radio.dart';
import '../estado/estado_visualizador.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
@@ -185,6 +186,10 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
color: tokens.warmCoral,
altura: 40,
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,
).pluriFadeIn(
context,
@@ -1133,7 +1138,11 @@ class _BandejaHerramientas extends StatelessWidget {
child: _TileHerramienta(
key: const Key('player-tool-share'),
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}'),
),
),
+22
View File
@@ -155,6 +155,28 @@ Future<bool> esEmisoraGratuitaPorUuid(
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 {
+574 -74
View File
@@ -20,6 +20,7 @@ import 'emisoras_destacadas.dart';
import 'musica_local_auto.dart';
import 'navegacion_auto.dart';
import 'servicio_audio_session.dart';
import 'verificacion_licencia.dart' show CambioLicencia;
/// Estado de reproducción expuesto al UI.
enum EstadoReproduccion {
@@ -56,10 +57,34 @@ typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
/// tests, fakes), which simply falls back to deriving the context on the spot.
typedef LeerContextoSaltoPersistido = Future<ContextoSalto?> Function();
/// Read port for the equalizer's persisted PRESET, the exact sibling of
/// [LeerEqActivoPersistido]. Bound to `ServicioEcualizador.leerPresetPrincipal`
/// in `main.dart`; `null` for any caller with no disk (widget tests, fakes).
typedef LeerPresetPersistido = Future<PresetEcualizador?> Function();
/// Write port for the same context. Bound to `guardarContextoSalto`.
typedef GuardarContextoSaltoPersistido =
Future<void> Function(ContextoSalto contexto);
/// Read port for the persisted last-played station (`ultima_emisora_v1`).
/// Bound to `ultimaEmisoraPersistida` in `main.dart`; `null` for any caller
/// with no disk (widget tests, fakes), which then neither seeds the cold-start
/// metadata nor resumes anything from a bare `play()`.
typedef LeerUltimaEmisoraPersistida = Future<Emisora?> Function();
/// Write port for the same key, and — since this seam exists — its ONLY
/// writer.
///
/// It had none: `EstadoRadio._persistirUltimaEmisora` was the sole writer and
/// `EstadoRadio` is built by the lazy `ChangeNotifierProvider` in `app.dart`,
/// which a headless Android Auto engine (`AudioServicePlugin.java:75-111`
/// builds `new FlutterEngine(applicationContext)` with no Activity) never
/// reaches. So a session that happened ONLY in the car never updated the key,
/// and on the next connect the head unit was offered the station from the
/// last time the PHONE was used — the same stale record
/// `resolverEmisorasDestacadas` puts first in the free tier's featured folder.
typedef GuardarUltimaEmisoraPersistida = Future<void> Function(Emisora emisora);
/// Last value read from disk for the equalizer on/off flag, or `null` while
/// nothing has been read yet.
///
@@ -86,6 +111,22 @@ bool? _eqActivoPersistido;
/// equalizer on, and the app has always behaved that way.
bool estadoEqInicial({required bool? persistido}) => persistido ?? true;
/// The two native operations an equalizer on/off transition is made of, as
/// values so their ORDER is a testable fact rather than the incidental shape
/// of a method body.
///
/// Off-device neither operation is observable (`_eqDisponible` is `false`, and
/// `AndroidEqualizer.parameters` never completes without an attached player),
/// so before this enum the sequence could only be asserted by reading the
/// source — which is how the wrong one shipped.
enum PasoEcualizador {
/// Write the current preset's band levels into the native effect.
ganancias,
/// Flip the native effect on or off (`AudioEffect.setEnabled`).
habilitacion,
}
/// Reads the persisted equalizer flag through [leer] exactly once and seeds
/// [handler] with it, without ever writing back.
///
@@ -110,6 +151,37 @@ Future<void> _sembrarEcualizadorDesdeDisco(
);
}
/// Reads the persisted equalizer PRESET through [leer] exactly once and seeds
/// [handler] with it.
///
/// The exact sibling of [_sembrarEcualizadorDesdeDisco], and it exists for the
/// exact same reason. eq-estado-unico gave the on/off FLAG a UI-independent
/// link to disk; the preset never got one, so `_presetActual` stayed on its
/// hardcoded `PresetEcualizador.flat`. On a phone that is invisible —
/// `EstadoEcualizador` owns the real preset and pushes it into the handler as
/// soon as the widget tree exists. On the headless engine Android Auto starts
/// there is no widget tree and no `EstadoEcualizador`, so a car toggle
/// enabled the equalizer and applied FLAT.
///
/// Never throws: an unreadable preference store leaves the handler on the
/// historical default rather than taking down the audio bootstrap.
Future<void> _sembrarPresetDesdeDisco(
PluriWaveAudioHandler handler,
LeerPresetPersistido leer,
) async {
PresetEcualizador? persistido;
try {
persistido = await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer el preset EQ persistido: $e',
);
persistido = null;
}
if (persistido == null) return;
await handler.sembrarPresetEcualizador(persistido);
}
/// Wires the freshly built handler into the module-level seams.
///
/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler
@@ -123,8 +195,11 @@ void registrarHandler(
PluriWaveAudioHandler handler, {
LeerEqActivoPersistido? leerEqActivoPersistido,
GuardarEqActivoPersistido? guardarEqActivoPersistido,
LeerPresetPersistido? leerPresetPersistido,
LeerContextoSaltoPersistido? leerContextoSalto,
GuardarContextoSaltoPersistido? guardarContextoSalto,
LeerUltimaEmisoraPersistida? leerUltimaEmisora,
GuardarUltimaEmisoraPersistida? guardarUltimaEmisora,
}) {
_handlerGlobal = handler;
// Registered BEFORE the seeding below is awaited so that a toggle arriving
@@ -139,9 +214,26 @@ void registrarHandler(
leer: leerContextoSalto,
guardar: guardarContextoSalto,
);
// Same seam shape again for the last-played station. The WRITE half is
// registered before anything is awaited for the same reason the equalizer's
// is: a station change arriving during the read below must still be
// persisted.
handler.registrarPersistenciaUltimaEmisora(
leer: leerUltimaEmisora,
guardar: guardarUltimaEmisora,
);
// Cold-start metadata (A3). Seeded eagerly, like the equalizer flag and
// unlike the skip context: a head unit asks for the now-playing metadata
// the moment it binds, and `audio_service` cannot send any while
// `mediaItem` is null. Fire-and-forget and internally guarded, so it is a
// no-op without a read port and never clobbers a live station.
unawaited(handler.sembrarUltimaEmisoraDesdeDisco());
if (leerEqActivoPersistido != null) {
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
}
if (leerPresetPersistido != null) {
unawaited(_sembrarPresetDesdeDisco(handler, leerPresetPersistido));
}
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved),
// generalizado en fix/android-auto-musica-local item 4: invalida
// activamente todo id de nivel raíz que un head unit pueda tener cacheado
@@ -292,6 +384,36 @@ void invalidarArbolAuto() {
_invalidarArbolAutoGlobal?.call();
}
/// Silent license re-verification for the headless Android Auto engine,
/// registered from `main.dart` (it owns the purchase port and the prefs).
/// `null` until registered — [dispararVerificacionLicenciaAuto] is then a
/// no-op.
Future<CambioLicencia> Function()? _verificarLicenciaAutoGlobal;
/// Registers (or, with `null`, clears) the verification
/// [dispararVerificacionLicenciaAuto] runs. Module-level like every other
/// `registrar*` seam here, so tests need no real handler.
void registrarVerificacionLicenciaAuto(
Future<CambioLicencia> Function()? verificar,
) {
_verificarLicenciaAutoGlobal = verificar;
}
/// Runs the registered license check and, if it changed the persisted flag
/// in either direction, invalidates the cached car tree (local music is
/// premium-gated). Never throws. The browse path calls it UNAWAITED so a
/// head unit's browse answer is never delayed by a Play query.
Future<void> dispararVerificacionLicenciaAuto() async {
final verificar = _verificarLicenciaAutoGlobal;
if (verificar == null) return;
try {
final cambio = await verificar();
if (cambio != CambioLicencia.sinCambios) invalidarArbolAuto();
} catch (e) {
debugPrint('[PluriWave][licencia] verificacion Auto fallida $e');
}
}
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
///
@@ -596,44 +718,51 @@ DecisionToggleEq decidirToggleEq({
requiereLlamadaNativa: eqDisponible,
);
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
/// device's native equalizer actually reports
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
/// `Equalizer.getBandLevelRange()`).
/// Delivers a gain from the app's ±12 dB slider to the device's native
/// equalizer, clamped by what the device reports it can do
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself
/// `Equalizer.getBandLevelRange()` in millibels divided by 1000).
///
/// Top-level and pure so the mapping is testable without a device.
///
/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
/// alto»: the previous implementation normalised across the whole range and
/// interpolated linearly,
/// THE CONTRACT: the decibels the user reads are the decibels the device is
/// asked for. The native range BOUNDS the request; it is not a scale to
/// normalise into. Both sides are already the same unit — `just_audio`
/// documents `setGain` as taking decibels and its Android bridge does
/// `setBandLevel(band, round(gain * 1000.0))`, plain dB to millibels with no
/// normalisation — so multiplying by the device's headroom was a unit error.
///
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
/// WHY IT MATTERS, in the app's own terms. The slider is hard-coded
/// `min: -12.0, max: 12.0`, the label under each band prints
/// `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads `equalizerBandValue`
/// = "{value} decibels": one promise, made three ways. Presets are persisted
/// and exported as those same raw slider values (`PresetEcualizador.toJson`),
/// so scaling at this boundary made an exported backup mean a different SOUND
/// on a different phone while displaying identical numbers — and on the
/// common asymmetric shape [-12, +19] it multiplied boosts by 1.58 and cuts
/// by 1.0, deforming a preset's shape rather than just its depth.
///
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
/// the range is symmetric, and Android guarantees no such thing — the
/// Equalizer contract only promises a min/max pair. On a device reporting,
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
/// real boost: audibly louder, with the on/off button still reading "off"
/// and nothing in the UI to explain it.
///
/// The contract here instead: 0 dB is always exactly 0, and each side of the
/// scale is stretched independently against its own end of the native range,
/// so a cut can never become a boost. A range with no headroom on one side
/// (or none at all) collapses that side to 0 rather than inverting it.
/// WHAT IS DELIBERATELY KEPT from the mapping this replaces — every invariant
/// the «suena muy alto» fix earned. Note the clamp window is widened to
/// always contain 0: a naive `db.clamp(minDecibels, maxDecibels)` would, on a
/// device reporting a wholly positive range such as [+3, +19], turn a FLAT
/// preset's 0 dB into +3 dB of real boost on every band — exactly the bug
/// that was fixed. So 0 dB is always exactly 0, the sign of the user's intent
/// is never inverted, the result never escapes the native range, a device
/// with no headroom above unity can never boost, and a zero-width range
/// collapses to 0.
double mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final limitado = db.clamp(-12.0, 12.0);
if (limitado == 0) return 0;
if (limitado > 0) {
// Only genuine headroom above unity counts as boost.
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return (limitado / 12.0) * techo;
}
// The clamp window is the device's range widened to include 0, so that a
// device reporting no headroom on one side collapses that side to "no
// change" instead of forcing a gain the user never asked for.
final suelo = minDecibels < 0 ? minDecibels : 0.0;
return (limitado.abs() / 12.0) * suelo;
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return limitado.clamp(suelo, techo);
}
/// Advances to the NEXT factory preset after [actual] in [presets] order
@@ -1057,7 +1186,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
),
);
AndroidEqualizer _eq = AndroidEqualizer();
AndroidEqualizer _eq = _crearEq();
late AudioPlayer _player = _crearPlayer();
StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub;
@@ -1222,6 +1351,39 @@ class PluriWaveAudioHandler extends BaseAudioHandler
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
/// Last [AndroidEqualizerParameters] resolved by [_activarEcualizador].
///
/// Cached rather than re-awaited because `AndroidEqualizer.parameters` is a
/// `Completer` future that only completes when the platform player attaches
/// (`just_audio.dart` `AndroidEqualizer._activate`). Awaiting it from a
/// toggle path therefore does not "read the device", it BLOCKS until the
/// next successful load — potentially forever if that load fails — which
/// would leave the car's equalizer button pending and its icon stale.
/// `null` means "not resolved yet on this player": the gains are skipped and
/// [_activarEcualizador] pushes them as soon as the player attaches.
AndroidEqualizerParameters? _paramsEq;
/// The [PasoEcualizador]s the LAST on/off transition actually executed, in
/// execution order. Reset at the start of every transition, so it stays
/// bounded and says exactly what the most recent toggle did.
///
/// This is the only way a test can see the order: both operations are
/// invisible off-device. Asserting "both happened" would have stayed green
/// against the very bug this exists for.
@visibleForTesting
List<PasoEcualizador> get pasosEcualizadorEjecutados =>
List.unmodifiable(_pasosEqEjecutados);
final _pasosEqEjecutados = <PasoEcualizador>[];
/// How many native equalizer calls have thrown.
///
/// The native effect is write-only (`just_audio` exposes no
/// `Equalizer.getEnabled()`), so a failure used to be indistinguishable
/// from success both in a logcat and in a test.
@visibleForTesting
int get fallosNativosEcualizador => _fallosNativosEq;
int _fallosNativosEq = 0;
/// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE
/// in-memory owner. `EstadoEcualizador._activo` is now a pure display
/// mirror of this field, and `ServicioEcualizador` is its durable copy.
@@ -1284,6 +1446,127 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_guardarContextoSalto = guardar;
}
LeerUltimaEmisoraPersistida? _leerUltimaEmisora;
GuardarUltimaEmisoraPersistida? _guardarUltimaEmisora;
/// Injects the last-played station's persistence ports (see
/// [GuardarUltimaEmisoraPersistida]). Both accept `null` — a handler with no
/// disk simply never remembers and never restores, exactly as before this
/// seam existed.
void registrarPersistenciaUltimaEmisora({
LeerUltimaEmisoraPersistida? leer,
GuardarUltimaEmisoraPersistida? guardar,
}) {
_leerUltimaEmisora = leer;
_guardarUltimaEmisora = guardar;
}
/// Whether [item] is a RADIO STATION rather than a local track.
///
/// `ultima_emisora_v1` is read back as an `emisora:<uuid>` row by the car's
/// recent root and by `resolverEmisorasDestacadas`, so a `content://` local
/// track written there would occupy that slot with a row that resolves to
/// nothing when tapped. Every station path builds its item through
/// [mediaItemParaEmisora] or `reproducirPorMediaId`, both of which stamp
/// `extras['uuid']`; `construirMediaItemColaLocal`/`reproducirPistaLocal`
/// stamp `extras['documentId']` instead. Private: it is asserted through
/// the real source-change path (a local track must leave the record
/// untouched), not as a predicate in isolation.
static bool _esMediaItemDeEmisora(MediaItem item) {
final uuid = item.extras?['uuid'];
return uuid is String && uuid.isNotEmpty;
}
/// Best-effort write of the last-played station through the injected port.
///
/// Never throws and never blocks the source change: a persistence failure
/// must cost the driver a stale resume row, never the station they just
/// asked for. Traced rather than swallowed, so a dead write channel is
/// visible in a car logcat instead of looking exactly like a working one.
Future<void> _persistirUltimaEmisora(MediaItem item) async {
if (!_esMediaItemDeEmisora(item)) return;
final guardar = _guardarUltimaEmisora;
if (guardar == null) return;
try {
await guardar(emisoraDesdeMediaItem(item));
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo guardar la ultima emisora: $e',
);
}
}
/// The persisted last-played station, or `null` when there is no port, no
/// record, or the read failed. Never throws — an unreadable record must
/// mean "nothing to resume", not a dead Play button.
Future<Emisora?> _ultimaEmisoraRecordada() async {
final leer = _leerUltimaEmisora;
if (leer == null) return null;
try {
return await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer la ultima emisora: $e',
);
return null;
}
}
/// Whether a source has actually been opened on this handler — set by
/// [_cambiarFuente] once it is past its revision guard, cleared by [stop].
///
/// Deliberately NOT `mediaItem.value != null`: since
/// [sembrarUltimaEmisoraDesdeDisco] publishes metadata on a cold start
/// WITHOUT loading anything, the two questions stopped being the same one.
/// Reading the metadata there would send a bare `play()` straight into
/// `_player.play()` on a player with no source, which is defect A2 all over
/// again.
bool _fuenteAbierta = false;
/// Publishes the persisted station's metadata on a COLD start, without
/// touching the player.
///
/// The handler constructor only wires streams, and the only `mediaItem.add`
/// sites are the duration update (which needs an item to already exist),
/// [_cambiarFuente] and [stop] (which publishes `null`). So on a headless
/// bind `mediaItem` was null, `audio_service.dart:1029-1033` returned before
/// `setMediaItem`, and the head unit received no metadata at all — no title,
/// no artwork, nothing to put on the now-playing surface.
///
/// Checked before AND after the disk read: a station that started while the
/// read was in flight owns the metadata, and renaming what the driver is
/// actually listening to would be far worse than a blank tile.
Future<void> sembrarUltimaEmisoraDesdeDisco() async {
if (mediaItem.value != null || _fuenteAbierta) return;
final ultima = await _ultimaEmisoraRecordada();
if (ultima == null) return;
if (mediaItem.value != null || _fuenteAbierta) return;
mediaItem.add(mediaItemParaEmisora(ultima, l10n: _textos));
}
/// Resolves the persisted station and starts it through the ordinary play
/// path. Returns `false` when there was nothing to resume.
///
/// Routed through [playMediaItem] on purpose — the revision guard, the
/// queue clearing, the skip-context recording and the terminal-state floor
/// all live behind that choke point, and a parallel path would have to
/// re-earn every one of them.
Future<bool> _reanudarUltimaEmisora() async {
final ultima = await _ultimaEmisoraRecordada();
if (ultima == null) return false;
try {
await playMediaItem(mediaItemParaEmisora(ultima, l10n: _textos));
} catch (e) {
// The failure is already published to `playbackState` by
// `_cambiarFuente`; a transport button must not additionally throw out
// of the handler (Spec "never propagate from the handler").
debugPrint(
'[PluriWave][ServicioAudio] no se pudo reanudar la ultima emisora: $e',
);
}
return true;
}
/// The remembered context: memory first, then the read port ONCE.
///
/// Never throws — an unreadable context must mean "derive it again", not a
@@ -1401,6 +1684,36 @@ class PluriWaveAudioHandler extends BaseAudioHandler
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
/// True once anybody has chosen a preset on this handler. Guards the disk
/// seed against clobbering a live choice — see [_sembrarPresetDesdeDisco].
bool _presetElegido = false;
/// The ordered native steps an on/off transition performs.
///
/// Pure and public so the ORDER is asserted directly.
@visibleForTesting
static List<PasoEcualizador> pasosEcualizador({required bool activo}) =>
activo
// GAINS FIRST. `AudioEffect.setEnabled(true)` re-activates the
// native `android.media.audiofx.Equalizer`, which still holds the
// band levels the PREVIOUS preset left in it — so enabling first
// means the driver hears the old equalization and then, one native
// round trip per band, the new one sliding in over it. That is the
// «doubled equalization» the owner reports from the car. Writing
// the levels while the effect is still bypassed makes the
// transition a single audible step.
? const [PasoEcualizador.ganancias, PasoEcualizador.habilitacion]
// DISABLING DOES NOT RESET THE GAINS, on purpose.
// `AudioEffect.setEnabled(false)` (just_audio's
// `AudioPlayer.java:820-822` → `AudioEffect.setEnabled`) BYPASSES
// the effect; it neither releases it nor clears its band levels,
// and a bypassed effect is inaudible whatever they hold. Zeroing
// them would be one `setBandLevel` IPC per band for no audible
// difference, and the enable path above rewrites them all before
// re-enabling anyway — so there is no stale-gain window left for a
// reset to close.
: const [PasoEcualizador.habilitacion];
int? get androidAudioSessionId => _androidAudioSessionId;
Stream<int?> get androidAudioSessionIdStream =>
_androidAudioSessionIdController.stream;
@@ -1575,6 +1888,21 @@ class PluriWaveAudioHandler extends BaseAudioHandler
)?
fabricaReproductorPrueba;
/// Same seam as [fabricaReproductorPrueba], for the native equalizer effect.
///
/// `AudioEffect.setEnabled` is a silent no-op while the player is detached
/// (`just_audio.dart` gates it on `_player._active`), so off-device a
/// failing native equalizer cannot otherwise be simulated at all — which is
/// why the silent `catch (_) {}` on that path shipped with zero coverage.
/// Static for the same reason as [fabricaReproductorPrueba]: `_eq` is a
/// field initializer, so the factory must already be installed before
/// `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`.
@visibleForTesting
static AndroidEqualizer Function()? fabricaEcualizadorPrueba;
static AndroidEqualizer _crearEq() =>
fabricaEcualizadorPrueba?.call() ?? AndroidEqualizer();
AudioPlayer _crearPlayer() {
final pipeline = AudioPipeline(androidAudioEffects: [_eq]);
final fabrica = fabricaReproductorPrueba;
@@ -2196,6 +2524,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (revision != _revisionFuente) return;
this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
// A source is now genuinely open on this handler — see [_fuenteAbierta].
_fuenteAbierta = true;
// THE SINGLE WRITER of `ultima_emisora_v1`. Placed here, past the
// revision guard and beside the `mediaItem` publish, because this is the
// one point EVERY play path funnels through: the phone (`EstadoRadio.
// reproducir` -> `ServicioAudio.reproducir` -> `playMediaItem`), a car
// browse tap (`playFromMediaId`), voice (`playFromSearch`), a skip, a
// queue advance and the bare-`play()` resume below.
//
// `EstadoRadio._persistirUltimaEmisora` was deleted rather than kept
// alongside this. Two writers of one key is exactly the shape that
// produced the equalizer divergence twice: both wrote fire-and-forget, so
// on a fast A -> B station switch the interleaving of two independent
// unawaited chains decided the final value, and the phone's copy could
// not see the revision guard that already cancels a superseded change.
// One writer behind one serialized queue has neither problem, and it is
// the only writer that exists on the engine Android Auto starts.
unawaited(_persistirUltimaEmisora(mediaItem));
// A new source is being opened, so no previous terminal error owns the
// screen any more (see [_errorTerminal]).
_errorTerminal = false;
@@ -2330,8 +2676,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await anterior.dispose().timeout(_timeoutCierrePlayer);
} catch (_) {}
_eq = AndroidEqualizer();
_eqDisponible = false;
_eq = _crearEq();
// `_eqDisponible` is deliberately NOT reset here. It answers "does this
// DEVICE have a usable native Equalizer effect", which no station change
// can alter — and resetting it on every source change is what made a car
// toggle land in a window where every native EQ path was gated off (the
// reported «does nothing») and made the EQ custom action disappear from
// the now-playing screen and come back seconds later
// (`controlesEcualizadorPersonalizados` returns `const []` when
// unavailable). [_activarEcualizador] is the only writer now: it sets it
// true when the fresh effect reports bands, false when it throws.
//
// Keeping it true across the rebuild cannot lie or throw, and that was
// verified against just_audio 0.9.46 rather than assumed:
// - `AudioEffect.setEnabled` short-circuits on `_player._active`, so on
// the detached fresh player it only records the Dart-side intent and
// never reaches the platform — no throw, no native call.
// - that recorded intent is NOT lost: the effect's `_toMessage()` is
// only read when the player attaches (`AudioPlayer._setPlatformActive`
// → `InitRequest.androidAudioEffects`), so a toggle made inside this
// window is carried into the new native pipeline verbatim.
// - the one call that WOULD hang is `await AndroidEqualizer.parameters`:
// its `Completer` only completes in `_activate`, i.e. when the player
// attaches. No toggle path awaits it any more — they read the
// [_paramsEq] cache cleared just below and skip while it is null.
_paramsEq = null;
// Resets alongside its siblings above: the fresh player starts detached,
// so the next non-idle event is a genuine idle -> active edge that
// [debeReasertarEcualizadorNativo] must see. A value stuck at `true`
@@ -2389,10 +2758,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
try {
final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty;
// eq-estado-unico item E: the ONE number that decides whether
// [mapearGananciaNativa] can be silently boosting a FLAT preset on
// this device. `Equalizer.getBandLevelRange()` is not required to be
// symmetric, and nothing else in the app can observe what it returned.
// eq-estado-unico item E: the ONE number that decides how much of the
// ±12 dB slider [mapearGananciaNativa] can actually honour on this
// device — anything past this range is clamped, so a report of "the
// slider stops doing anything past N" is answered from this line.
// `Equalizer.getBandLevelRange()` is not required to be symmetric, and
// nothing else in the app can observe what it returned.
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
// the release build, which is the only one that ever runs in a car:
//
@@ -2403,8 +2774,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
);
if (_eqDisponible) {
await _eq.setEnabled(_ecualizadorActivo);
await aplicarPreset(_presetActual);
_paramsEq = params;
await _conmutarEcualizadorNativo(_ecualizadorActivo);
}
} catch (_) {
_eqDisponible = false;
@@ -2484,26 +2855,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
// A preset chosen by anyone (car folder, phone screen) claims ownership:
// a disk seed still in flight must not overwrite it. See
// [_sembrarPresetDesdeDisco].
_presetElegido = true;
if (_eqDisponible) {
try {
// Enable-then-gains here does NOT contradict [pasosEcualizador]'s
// gains-then-enable. That order matters only on an on/off TRANSITION,
// where enabling first un-bypasses an effect still holding the
// previous preset. Choosing a preset is not a transition: the effect
// is already in its final on/off state, so this `setEnabled` is the
// idempotent re-assert that keeps the native effect honest after a
// `stop()` (see [debeReasertarEcualizadorNativo]) and opens no
// stale-gain window of its own.
await _eq.setEnabled(_ecualizadorActivo);
if (_ecualizadorActivo) {
final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
await _empujarGananciasNativas(preset);
}
} catch (_) {}
} catch (e) {
_registrarFalloEq('aplicarPreset(${preset.nombre})', e);
}
}
// Item 4: keeps the EQ custom action's preset-cycle label in sync
// regardless of WHO changed the preset (a car customAction tap or the
@@ -2518,9 +2890,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
bandas[index] = db;
_presetActual = _presetActual.copyWithBandas(bandas);
}
_presetElegido = true;
if (!_eqDisponible || !_ecualizadorActivo) return;
final params = _paramsEq;
if (params == null) return;
try {
final params = await _eq.parameters;
if (index < params.bands.length) {
await params.bands[index].setGain(
mapearGananciaNativa(
@@ -2530,7 +2904,68 @@ class PluriWaveAudioHandler extends BaseAudioHandler
),
);
}
} catch (_) {}
} catch (e) {
_registrarFalloEq('setBanda($index)', e);
}
}
/// Writes [preset]'s band levels into the native effect.
///
/// Skips silently while [_paramsEq] is `null` (the player has not attached
/// since the last rebuild): the gains have nowhere to go yet and
/// [_activarEcualizador] pushes them the moment it does.
Future<void> _empujarGananciasNativas(PresetEcualizador preset) async {
final params = _paramsEq;
if (params == null) return;
for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
await params.bands[i].setGain(
mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
}
/// The native operations an on/off transition performs, in
/// [pasosEcualizador] order.
///
/// Returns `false` when the [PasoEcualizador.habilitacion] step itself
/// threw, i.e. when the device did NOT adopt [activo]. A failed gains step
/// does not make the transition dishonest: the effect really is in the
/// requested on/off state, just carrying stale band levels.
Future<bool> _conmutarEcualizadorNativo(bool activo) async {
_pasosEqEjecutados.clear();
var conmutado = true;
for (final paso in pasosEcualizador(activo: activo)) {
try {
switch (paso) {
case PasoEcualizador.ganancias:
await _empujarGananciasNativas(_presetActual);
case PasoEcualizador.habilitacion:
await _eq.setEnabled(activo);
}
_pasosEqEjecutados.add(paso);
} catch (e) {
_registrarFalloEq('$paso(activo=$activo)', e);
if (paso == PasoEcualizador.habilitacion) conmutado = false;
}
}
return conmutado;
}
/// Single trace/count point for every native equalizer failure.
///
/// [debugPrint] and never `dart:developer`'s `log`, for the same reason as
/// the rest of this file: `log()` writes to the VM service, which the
/// RELEASE build a car runs does not have.
void _registrarFalloEq(String operacion, Object error) {
_fallosNativosEq++;
debugPrint(
'[PluriWave][ServicioAudio] fallo nativo del ecualizador en '
'$operacion: $error',
);
}
/// Sets the equalizer on/off state AND persists it — the single entry
@@ -2539,6 +2974,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> setEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: true);
/// Adopts a PRESET that came from disk, the sibling of
/// [sembrarEcualizadorActivo]. Bound through
/// `registrarHandler(leerPresetPersistido: ...)`.
///
/// Unlike the on/off flag's seed this one YIELDS to a live choice. The flag
/// has exactly one persisted value and the handler owns writing it, so
/// seeding it can never contradict anybody. The preset does not: the phone
/// UI resolves a richer value (per-station, and per-Bluetooth-device when
/// the multi-device toggle is on) that this narrow "principal preset" read
/// knows nothing about. The seed's disk read is `unawaited`, so without the
/// [_presetElegido] guard a slow read could land after `EstadoEcualizador`
/// had already pushed the right preset and silently replace it with the
/// principal one. The seed exists to fill a VOID, never to overrule.
Future<void> sembrarPresetEcualizador(PresetEcualizador preset) async {
if (_presetElegido) return;
await aplicarPreset(preset);
}
/// Adopts a value that came FROM disk (eq-estado-unico item A). Identical
/// to [setEcualizadorActivo] except that it does not write back — seeding
/// is a read, and echoing it to disk would only add a pointless write on
@@ -2550,14 +3003,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
bool activo, {
required bool persistir,
}) async {
final anterior = _ecualizadorActivo;
_ecualizadorActivo = activo;
if (_eqDisponible) {
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} catch (_) {}
if (_eqDisponible && !await _conmutarEcualizadorNativo(activo)) {
// The device REFUSED the on/off call. Publishing `activo` anyway would
// put an icon on the car's now-playing screen claiming a state the
// audio does not have — and persisting it would resurrect that lie on
// the next engine start. Rolling back is cheap here because
// `_ecualizadorActivo` is the single in-memory owner (eq-estado-unico)
// and the controls are rebuilt from it one line below; the toggle then
// honestly reads "unchanged" and the failure is in the logcat.
_ecualizadorActivo = anterior;
_actualizarControlesEq();
return;
}
// Item 4: keeps the EQ custom action's on/off label in sync regardless
// of WHO toggled it (a car customAction tap or the phone settings
@@ -2624,7 +3082,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> reaplicarEcualizador() => _activarEcualizador();
@override
Future<void> play() {
Future<void> play() async {
// NO SOURCE LOADED — the cold-engine case, and the reason this override
// is no longer a one-liner.
//
// `AudioService.java:920` routes the car's `KEYCODE_MEDIA_PLAY` straight
// in here, and there is no `prepare`/`onPrepare`/`prepareFromMediaId`
// override anywhere in this app to have loaded anything first. Handed to
// `_player.play()`, `just_audio.dart:937-967` publishes
// `_playingSubject.add(true)` BEFORE its `_audioSource != null` gate: the
// platform is never touched, the returned Future NEVER completes, and yet
// `playing: true` is forwarded by [manejarEstadoPlayer] over
// `processingState: idle`. `AudioService.java:559-560` then runs
// `enterPlayingState()` while `getPlaybackState()` is `STATE_NONE` — a
// PluriWave notification with a pause button, no audio, no title and no
// artwork, or a `ForegroundServiceStartNotAllowedException` on API 31+.
//
// So: resolve the persisted station and go through the ordinary play
// path, and when there is nothing to resume touch neither the player nor
// `playbackState` and complete immediately. Doing nothing is the correct
// answer there — a phantom foreground session is strictly worse than a
// Play button that did not find anything to play.
if (!_fuenteAbierta) {
await _reanudarUltimaEmisora();
return;
}
_intencionReproducir = true;
// Fresh user intent: whatever terminal error was standing no longer owns
// the screen, so stop masking the player's `idle` (see [_errorTerminal]).
@@ -2674,6 +3156,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// The session is over: whatever this run proved about the mount does not
// carry into the next one (see [_reproduccionEstablecida]).
_reproduccionEstablecida = false;
// The session is over and `mediaItem` is cleared below, so the next bare
// `play()` — a car transport button on a torn-down session — must resolve
// a station again instead of calling `_player.play()` on nothing (see
// [_fuenteAbierta] and [play]).
_fuenteAbierta = false;
_revisionFuente++;
await _player.stop();
// Publish `idle` OURSELVES rather than trusting the player to emit it.
@@ -3016,6 +3503,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// [resolverLocalizacionesRespaldo], so this works on the engine
// Android Auto starts without an Activity -- which is the only engine
// a Play reviewer ever gets.
// Silent license re-verification (refund revocation), on the root
// only — every car connection asks for it — and fire-and-forget: the
// browse answer below is served from the persisted flag right away,
// and the check is throttled to at most once a day.
if (parentMediaId == AudioService.browsableRootId) {
unawaited(dispararVerificacionLicenciaAuto());
}
final etiquetas = etiquetasArbolAutoDesde(_textos);
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
// The "recent" root, resolved BEFORE the entitlement gate.
@@ -3150,12 +3644,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler
@override
Future<MediaItem?> getMediaItem(String mediaId) async {
try {
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return null;
final universo = await _universoCompleto(fuente);
final constructor = ConstructorArbolAuto();
final emisora = constructor.resolver(mediaId, universo);
return emisora == null ? null : constructor.itemEmisora(emisora);
final uuid = uuidDeMediaIdEmisora(mediaId);
// Not a station id at all (`pista:`, `carpeta_local_*:`, `eq_preset:`,
// a folder, or `emisora:` with an empty tail) — unchanged behaviour.
if (uuid == null) return null;
// Was `_universoCompleto` (favoritos + misEmisoras + todas) alone, which
// is EMPTY on a headless bind, while `porUuid` has always also fallen
// back to the featured set. The car could therefore BROWSE a featured
// station and then fail to resolve its media item — an asymmetry, not a
// policy. Delegating to `porUuid` removes it (and short-circuits on the
// first list that matches instead of always awaiting all three), and the
// `FuenteEmisorasAutoDestacadas` stand-in covers the window before
// `main.dart` registers the real source, exactly as [playFromMediaId]
// already does.
final fuente =
_fuenteNavegacionGlobal ??
FuenteEmisorasAutoDestacadas(await resolverEmisorasDestacadas());
final emisora = await fuente.porUuid(uuid);
return emisora == null
? null
: ConstructorArbolAuto().itemEmisora(emisora);
} catch (_) {
return null;
}
@@ -3449,12 +3957,4 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_ => Future.value(const []),
};
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
final listas = await Future.wait([
fuente.favoritos(),
fuente.misEmisoras(),
fuente.todas(),
]);
return listas.expand((lista) => lista).toList();
}
}
+96 -2
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
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
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
@@ -38,6 +39,25 @@ class EventoCompra {
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
/// this port, never on `in_app_purchase` directly — matches
/// `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.
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
/// depends on [PuertoCompras] instead.
class ServicioComprasPlayBilling implements PuertoCompras {
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
: _iap = inAppPurchase ?? InAppPurchase.instance {
ServicioComprasPlayBilling({
InAppPurchase? inAppPurchase,
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
_consultarComprasPasadasInyectada = consultarComprasPasadas,
_limiteConsultaPropiedad = limiteConsultaPropiedad {
_sub = _iap.purchaseStream.listen(
_alRecibirCompras,
onError: (Object error) {
@@ -77,6 +108,15 @@ class ServicioComprasPlayBilling implements PuertoCompras {
static const idProducto = 'pluriwave_premium';
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();
StreamSubscription<List<PurchaseDetails>>? _sub;
@@ -125,6 +165,33 @@ 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) {
if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
@@ -176,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> {
T? get firstOrNull => isEmpty ? null : first;
}
+21
View File
@@ -256,6 +256,27 @@ class ServicioEcualizador {
return prefs.getBool(_keyActivo);
}
/// The persisted principal preset, or `null` when the user has never saved
/// one.
///
/// The exact sibling of [leerActivo] and narrow for the same reason: its
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
/// engine — including the headless one Android Auto starts, where there is
/// no widget tree and `EstadoEcualizador` never exists to push a preset
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
/// and mutates nothing.
///
/// `null` (nothing saved, or an unreadable value) is preserved rather than
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
/// not this service — decides what "never persisted" means, and so a seed
/// with nothing to say does not overwrite anything.
Future<PresetEcualizador?> leerPresetPrincipal() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) return null;
return _leerPresetPrincipal(prefs);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
+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;
}
+148 -134
View File
@@ -40,150 +40,164 @@ class HojaPremium extends StatelessWidget {
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.workspace_premium_rounded,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.premiumHojaTitulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
// The sheet's height is data-dependent: five benefit bullets whose
// wrapped line count varies per locale, plus an optional
// purchase/restore result banner. Together they already overflow a
// short viewport by a couple of pixels, and a translation one word
// longer would make it worse. Scrolling is the only shape that
// cannot overflow, and it costs nothing when everything fits.
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.workspace_premium_rounded,
color: PluriWaveTokens.brand,
),
),
// Explicit, obvious dismiss affordance (fix/import-alarmas-y-
// paywall): a purchase sheet the user cannot easily escape is
// a dark pattern and a Play policy risk. Reachable without
// buying or restoring, same weight as any other icon button.
IconButton(
key: const ValueKey('hoja-premium-cerrar'),
icon: const Icon(Icons.close_rounded),
tooltip: l10n.closeAction,
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
const SizedBox(height: 12),
// Concrete, honest value list — accuracy is non-negotiable here:
// these five are the ONLY things premium unlocks. The phone
// equalizer stays free for everyone and must NEVER appear here;
// only its Android Auto surface is affected, as a consequence of
// Auto itself being gated.
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
const SizedBox(height: 12),
Text(
l10n.premiumPagoUnico,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
// 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,
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.premiumHojaTitulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
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,
),
],
),
),
// Explicit, obvious dismiss affordance (fix/import-alarmas-y-
// paywall): a purchase sheet the user cannot easily escape is
// a dark pattern and a Play policy risk. Reachable without
// buying or restoring, same weight as any other icon button.
IconButton(
key: const ValueKey('hoja-premium-cerrar'),
icon: const Icon(Icons.close_rounded),
tooltip: l10n.closeAction,
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
if (entitlement.esPremium)
Padding(
key: const ValueKey('hoja-premium-activo'),
padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.premiumActivo,
style: Theme.of(context).textTheme.bodyMedium,
const SizedBox(height: 12),
// Concrete, honest value list — accuracy is non-negotiable here:
// these five are the ONLY things premium unlocks. The phone
// equalizer stays free for everyone and must NEVER appear here;
// only its Android Auto surface is affected, as a consequence of
// Auto itself being gated.
//
// 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,
),
],
),
),
)
else
FilledButton.icon(
key: const ValueKey('hoja-premium-comprar'),
if (entitlement.esPremium)
Padding(
key: const ValueKey('hoja-premium-activo'),
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:
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:
entitlement.compraEnCurso
? null
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
),
],
],
],
),
),
),
);
+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.
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({
super.key,
required this.estadoStream,
@@ -42,6 +53,7 @@ class VisualizadorAudio extends StatefulWidget {
this.anchuraTotal = double.infinity,
this.barrasDiscretas = false,
this.gradienteFinAlpha = 0.3,
this.capturaRealHabilitada = false,
});
@override
@@ -101,9 +113,31 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
_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() {
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) {
unawaited(_ondaSubscription?.cancel());
+1 -1
View File
@@ -366,7 +366,7 @@ packages:
source: hosted
version: "3.3.0"
in_app_purchase_android:
dependency: transitive
dependency: "direct main"
description:
name: in_app_purchase_android
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
+5 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.3.3+161
version: 1.3.3+164
environment:
sdk: ^3.7.0
@@ -55,6 +55,10 @@ dependencies:
# In-app purchase
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
@@ -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);
});
});
}
+53
View File
@@ -1944,6 +1944,59 @@ void main() {
},
);
});
// ---------------------------------------------------------------------------
// The handler REJECTED the toggle (native setEnabled threw): the handler
// rolls its own flag back, so this class must not keep — nor persist — a
// value the engine refused.
// ---------------------------------------------------------------------------
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
test(
'adopta el valor real del handler y NO persiste el valor rechazado',
() async {
final fakeAudio = _FakeAudioEqRechazaConmutacion();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
fakeServicio.guardarActivoLlamadas = 0;
var avisos = 0;
eq.addListener(() => avisos++);
await eq.cambiarActivo(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The native call failed, so the handler kept the equalizer ON.
expect(fakeAudio.ecualizadorActivo, isTrue);
expect(
eq.activo,
isTrue,
reason: 'the UI must show what the engine really does',
);
expect(avisos, greaterThanOrEqualTo(1));
expect(
fakeServicio.guardarActivoLlamadas,
equals(0),
reason: 'a rejected value must never reach disk',
);
expect(fakeServicio.config.activo, isTrue);
eq.dispose();
},
);
});
}
/// Fake handler that REFUSES every on/off change: it records the call (the
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
/// rolling its flag back when the native `setEnabled` throws.
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
}
}
/// Fake whose [guardarActivo] stays pending until released, and releases the
+159
View File
@@ -2,7 +2,10 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.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/verificacion_licencia.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
@@ -11,6 +14,12 @@ class _PuertoComprasFalso implements PuertoCompras {
final _eventos = StreamController<EventoCompra>.broadcast();
int comprasIntentadas = 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
Stream<EventoCompra> get eventos => _eventos.stream;
@@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras {
restaurosIntentados++;
}
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
consultasPropiedad++;
return propiedad;
}
void emitir(EventoCompra evento) => _eventos.add(evento);
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() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -327,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)', () {
test('lee la flag persistida directamente desde prefs', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
+39 -29
View File
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -792,21 +793,16 @@ void main() {
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
final estadoUno = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
// The record is now written by the audio handler's `_cambiarFuente`
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
// `ultima_emisora_v1` and the only one that also exists on the headless
// Android Auto engine — `EstadoRadio` used to write it too and no
// longer does. Seeded through that same production function here, so
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
// a real payload instead of one a fake invented. The write itself is
// covered end to end in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
await guardarUltimaEmisoraPersistida(emisora);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
@@ -848,9 +844,18 @@ void main() {
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'también se recuerda para la próxima instancia', () async {
'deja de estar ensombrecida por la seleccion previa del telefono',
() async {
// The PERSISTENCE half of this scenario moved to the handler, which is
// the only writer that exists on a car-only session — it is covered by
// «playFromMediaId desde el coche persiste ESA emisora» in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
// `EstadoRadio` still owns here, and what this test now pins, is the
// shadowing fix: a car selection bypasses `reproducir()`, so without
// the `estadoStream` listener `_emisoraSeleccionada` would keep
// shadowing the car's station on the `emisoraActual` getter.
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
@@ -859,7 +864,16 @@ void main() {
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estado.inicializar();
final desdeElTelefono = emisoraDemo(
uuid: 'phone-picked',
nombre: 'Elegida en el telefono',
);
unawaited(estado.reproducir(desdeElTelefono));
audio.completar(desdeElTelefono.uuid);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
@@ -867,18 +881,14 @@ void main() {
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
expect(
estado.emisoraActual?.uuid,
desdeCoche.uuid,
reason:
'the car changed the station without going through reproducir(); '
'the phone UI must follow it instead of keeping the previous '
'selection on screen',
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
}
+3 -14
View File
@@ -46,20 +46,9 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
('pt', 'welcomeBullet2Title'),
('ru', 'welcomeBullet2Title'),
('zh', 'welcomeBullet2Title'),
(
'ar',
'premiumBeneficioAndroidAuto',
), // "Android Auto" -- Google product name (fix/import-alarmas-y-paywall)
('bn', 'premiumBeneficioAndroidAuto'),
('de', 'premiumBeneficioAndroidAuto'),
('fr', 'premiumBeneficioAndroidAuto'),
('hi', 'premiumBeneficioAndroidAuto'),
('id', 'premiumBeneficioAndroidAuto'),
('it', 'premiumBeneficioAndroidAuto'),
('ja', 'premiumBeneficioAndroidAuto'),
('pt', 'premiumBeneficioAndroidAuto'),
('ru', 'premiumBeneficioAndroidAuto'),
('zh', 'premiumBeneficioAndroidAuto'),
// `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.
+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'));
});
}
@@ -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_idioma.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/modelos/archivo_grabacion.dart';
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
@@ -108,6 +109,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: MaterialApp(
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_idioma.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/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
@@ -54,6 +55,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: MaterialApp(
locale: const Locale('en'),
@@ -100,7 +104,7 @@ void main() {
// completes the remaining 5 sections, so the root is now exactly 4
// GrupoAjustes cards, under 400 lines.
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,
) async {
setLargeSurface(tester);
@@ -114,6 +118,10 @@ void main() {
expect(find.text('AUDIO'), findsOneWidget);
expect(find.text('Equalizer'), 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);
// Zero inline controls: the old always-visible enable switch and
+139 -21
View File
@@ -15,16 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
/// WU15: the recordings library screen — storage usage, browsable rows
/// (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:
/// constructing a real `just_audio.AudioPlayer` needs platform
/// `MethodChannel`s this suite does not mock — the same documented
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
/// Likewise, `compartir` is always injected with a fake recorder instead of
/// the real `share_plus` call, since this suite does not mock that channel
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
/// constraint).
/// Likewise, `abrirEnOtraApp` is always injected with a fake recorder
/// instead of the real `pluriwave/file_actions` round trip, since this suite
/// does not mock that channel either (see `pantalla_ajustes_backup_test.dart`
/// for the same constraint).
///
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
/// PluriGlassSurface paints a background over ListTile's ink layer, which
@@ -72,7 +72,7 @@ void main() {
Widget buildScreen({
required EstadoGrabacion estado,
required ReproductorGrabaciones reproductor,
Future<void> Function(String ruta)? compartir,
Future<bool> Function(String ruta)? abrirEnOtraApp,
}) {
return ListenableProvider<EstadoGrabacion>.value(
value: estado,
@@ -82,7 +82,7 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaGrabaciones(
reproductor: reproductor,
compartir: compartir ?? (_) async {},
abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
),
),
);
@@ -117,6 +117,66 @@ void main() {
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(
'WU15b: tapping the settings icon pushes the folder/size settings '
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
@@ -379,9 +439,12 @@ void main() {
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
});
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
tester,
) async {
// The middle entry used to be Share, which handed the audio file to the
// system share sheet. It is now a LOCAL open: play your own recording in
// 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(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
@@ -405,7 +468,10 @@ void main() {
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
findsOneWidget,
);
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
expect(
find.widgetWithText(PopupMenuItem<String>, 'Open in another app'),
findsOneWidget,
);
expect(
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
findsOneWidget,
@@ -555,10 +621,15 @@ void main() {
skip: true,
);
testWidgets(
'15.4-C: Share invokes the injected share callback with the file path',
(tester) async {
final compartidos = <String>[];
/// The row menu used to hand the audio file to the system share sheet,
/// which is redistribution of someone else's broadcast. What the owner
/// actually wanted is to play your own recording in another app on the
/// 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(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
@@ -571,21 +642,68 @@ void main() {
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
compartir: (ruta) async {
compartidos.add(ruta);
abrirEnOtraApp: (ruta) async {
abiertos.add(ruta);
return true;
},
),
);
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>, 'Share'));
await tester.tap(
find.widgetWithText(PopupMenuItem<String>, l10n.recordingActionOpenIn),
);
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 ──────────────────────────────────────────────────────────
+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_navegacion.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/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
@@ -804,6 +805,9 @@ Widget _conProviders(
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
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_grabacion.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/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
@@ -106,6 +107,9 @@ void main() {
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: MaterialApp(
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(
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
(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_idioma.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/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
@@ -80,6 +81,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
Provider<ServicioAnuncios>(
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_idioma.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/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
@@ -78,6 +79,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => true),
),
@@ -0,0 +1,374 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
///
/// Reported by the owner: the toggle behaves correctly from the phone screen
/// but from the car it «sometimes sounds like a doubled equalization and
/// sometimes does nothing».
///
/// Three independent causes, one per group below:
///
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
/// `eq-estado-unico` item A); the preset never did. On a headless Android
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
/// to push the real preset — enabling the equalizer from the car applied
/// FLAT.
///
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
/// the preset's gains, so the native effect was re-activated carrying
/// whatever band levels the previous preset had left in it and only
/// afterwards were the intended ones written, band by band. That audible
/// gap is the «doubled equalization».
///
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
/// change and never restored it until the fresh player attached. Every
/// native EQ path is gated on that flag, so a car toggle landing inside
/// the window flipped the icon and the flag but never touched the audio —
/// the «does nothing» — and the EQ button itself vanished from the car's
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
/// `const []` when unavailable) and came back seconds later.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductorEq guion;
setUp(() {
guion = _GuionReproductorEq();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
});
group('A — the preset is seeded from disk on a headless engine', () {
test('registrarHandler consults the injected preset port exactly once '
'and seeds the handler with it, with no widget tree', () async {
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
handler,
leerPresetPersistido: () async {
lecturas++;
return PresetEcualizador.jazz;
},
);
await pumpEventQueue();
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.presetActual,
PresetEcualizador.jazz,
reason:
'from the car the handler is the ONLY owner of the preset — '
'nothing else ever pushes one on a headless engine',
);
});
test('a read failure leaves the historical default instead of '
'propagating', () async {
final handler = crearHandler();
registrarHandler(
handler,
leerPresetPersistido: () async => throw StateError('sin disco'),
);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.flat);
});
test('without a preset port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
await handler.aplicarPreset(PresetEcualizador.rock);
registrarHandler(handler);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.rock);
});
test('a preset already chosen while the disk read was in flight WINS — '
'seeding never clobbers a live choice', () async {
final handler = crearHandler();
final lectura = Completer<PresetEcualizador?>();
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
// pushes it while the seed's disk read is still pending.
await handler.aplicarPreset(PresetEcualizador.pop);
lectura.complete(PresetEcualizador.jazz);
await pumpEventQueue();
expect(
handler.presetActual,
PresetEcualizador.pop,
reason:
'the seed exists to fill a VOID, not to overrule the richer '
'per-station/per-device preset the phone UI resolves',
);
});
});
group('B — the preset is pushed BEFORE the effect is enabled', () {
test('enabling applies the gains first and only then flips the native '
'effect on', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: true),
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'enabling first would re-activate the native Equalizer carrying '
'the PREVIOUS preset gains, which is the doubled equalization '
'the owner hears',
);
});
test('disabling only flips the effect off — the band gains are NOT '
'reset', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: false),
[PasoEcualizador.habilitacion],
reason:
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
'the effect and RETAINS its band levels, and the enable path '
'rewrites them before re-enabling anyway — zeroing them would be '
'one native round trip per band for no audible difference',
);
});
test('the real toggle path executes those steps IN THAT ORDER', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.pasosEcualizadorEjecutados,
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'the ORDER is the fix; asserting only that both happened would '
'stay green against the exact bug being fixed',
);
});
test('the real disable path executes only the habilitacion step', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(false);
expect(handler.pasosEcualizadorEjecutados, [
PasoEcualizador.habilitacion,
]);
});
});
group('C — a station change no longer drops the equalizer', () {
test('once the EQ was available, no state published across a station '
'change and a car toggle has zero custom actions', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
// The EQ action is on the car's now-playing screen before the station
// changes — that is the state the driver is looking at.
await handler.setEcualizadorActivo(true);
final acciones = <int>[];
final sub = handler.playbackState.listen(
(estado) => acciones.add(
estado.controls.where((c) => c.customAction != null).length,
),
);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
// The car tap that used to land inside the window `_recrearPlayer`
// opened. It republishes the controls from `_eqDisponible`, so a flag
// reset to `false` shows up here as an EQ button that disappeared.
await handler.customAction(accionEqToggle);
await sub.cancel();
expect(
acciones,
isNotEmpty,
reason: 'the station change must publish at least one state',
);
expect(
acciones.every((n) => n > 0),
isTrue,
reason:
'the EQ button vanished and reappeared on every station change '
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
'a DEVICE property and does not change with the station. Got '
'$acciones',
);
});
test('the availability flag survives the player rebuild, so a car toggle '
'inside the window still reaches the native effect', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
expect(
handler.ecualizadorDisponible,
isTrue,
reason:
'this is the flag every native EQ path is gated on; false here '
'is exactly the reported «does nothing»',
);
});
});
group('D — a failed native call is traced and never lies', () {
test('a throwing setEnabled is traced instead of swallowed', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.fallosNativosEcualizador,
greaterThan(0),
reason:
'the silent `catch (_) {}` made a dead native equalizer '
'indistinguishable from a working one in a car logcat',
);
});
test('a failed on/off call leaves the published state honest instead of '
'claiming a state the audio does not have', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
handler.ecualizadorActivo,
isFalse,
reason:
'the native effect refused, so the car icon must not read "on" '
'over audio that is not equalized',
);
});
test('a failed on/off call is not persisted', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
isEmpty,
reason:
'persisting a state the device rejected would resurrect it on '
'the next engine start',
);
});
});
}
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
/// device whose native `Equalizer` effect refuses the call. Nothing else is
/// overridden, so the rest of the handler runs unchanged.
class _EcualizadorQueFalla extends AndroidEqualizer {
@override
Future<void> setEnabled(bool enabled) async {
throw StateError('el efecto nativo rechazo la llamada');
}
}
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
/// the handler builds (it rebuilds its player on every source change).
class _GuionReproductorEq {
int llamadasSetUrl = 0;
_ReproductorFalsoEq? ultimoReproductor;
}
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
/// station change can be driven under `flutter test`. Mirrors the double in
/// `servicio_audio_transporte_test.dart`.
class _ReproductorFalsoEq extends AudioPlayer {
_ReproductorFalsoEq(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductorEq _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -1,28 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
/// app's fixed ±12 dB slider scale to whatever range the device's native
/// `Equalizer.getBandLevelRange()` reports.
/// `mapearGananciaNativa` the hand-off from the app's ±12 dB slider to the
/// device's native `Equalizer`, whose capability is reported as
/// `AndroidEqualizerParameters.min/maxDecibels`
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
///
/// This is the only source-plausible explanation for the reported «suena muy
/// alto» half of the bug. The original implementation normalised the input
/// across the WHOLE range and mapped it linearly:
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
/// slider against its own end of the native range, so `+6` on a device
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
/// are already the SAME unit, so that multiplication was a unit error:
///
/// normalizado = (db.clamp(-12, 12) + 12) / 24
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
/// decibels", and its Android bridge does `setBandLevel(band,
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
/// bound on the control, not a scale to normalise into.
/// * The app makes the user a decibel promise in three places at once: the
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
/// a lie on every device whose range is not exactly ±12.
/// * Presets are persisted and EXPORTED as those same raw slider dB
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
/// restored on a wider-range phone showed identical numbers and played
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
/// rather than merely its depth.
///
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
/// the range happens to be symmetric. Android does not guarantee that: the
/// AudioEffect Equalizer contract only requires a min/max pair, and real
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
/// band 0 dB — was silently pushing a positive boost into every band, which
/// is audibly louder while the on/off button still reads "off".
/// So: the number the user reads is the number the device is asked for. The
/// native range only CLAMPS it.
///
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
/// sides of the scale are stretched INDEPENDENTLY against their own end of
/// the native range, so the sign of the user's intent is never inverted and
/// the extremes still reach the device's real limits.
/// What this deliberately KEEPS from the previous contract — every invariant
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
/// positive reported range, turning a FLAT preset into a boost again), the
/// sign of the user's intent is never inverted, the result never escapes the
/// native range, a device with no headroom above unity can never boost, and a
/// zero-width range collapses to 0.
void main() {
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
test('symmetric range (the common case) is unchanged', () {
@@ -43,6 +58,10 @@ void main() {
});
test('a wholly positive range still cannot boost a FLAT preset', () {
// This is precisely why the mapping cannot be a plain
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
// bring the «suena muy alto» bug straight back. The clamp window has
// to be widened so that it always contains 0.
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
});
@@ -51,36 +70,61 @@ void main() {
});
});
group('mapearGananciaNativa — the extremes reach the native limits', () {
test('+12 dB maps to the native maximum', () {
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
group('mapearGananciaNativa — the slider dB reach the device literally', () {
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the
// device's headroom. The slider says "12.0dB" and the accessibility
// label says "12.0 decibels", so 12 dB is what the device must be
// asked for. The 7 dB of extra hardware headroom is unreachable by
// design until the slider itself is widened and says so.
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
});
test('-12 dB maps to the native minimum', () {
test('-12 dB is delivered as -12 dB', () {
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
});
test('values beyond the slider scale are clamped, not extrapolated', () {
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
test('values beyond the slider scale clamp to the slider limit', () {
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
// The slider scale is the first bound; the device range is the second.
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
});
});
group('mapearGananciaNativa — each side scales against its own end', () {
test('half boost is half of the positive headroom', () {
group('mapearGananciaNativa — the label is the value the device gets', () {
test('+6 dB on a wide-range device is +6 dB, never 10', () {
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
// half of the positive headroom". A slider reading "6.0dB" that
// produced +10 dB of real boost is exactly what made a restored backup
// sound different on a different phone.
expect(
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
closeTo(10, 1e-9),
closeTo(6, 1e-9),
);
});
test('half cut is half of the negative headroom', () {
test('-6 dB on that same device is -6 dB', () {
expect(
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
closeTo(-6, 1e-9),
);
});
test('the six factory presets keep their shape on an asymmetric device', () {
// Jazz, authored in true dB before any scaling existed. Under the old
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
// different tonal curve, not merely a louder one.
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
final entregado = jazz
.map(
(db) =>
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
)
.toList();
expect(entregado, jazz);
});
test('the sign of the user intent is never inverted', () {
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
final nativo = mapearGananciaNativa(
@@ -97,12 +141,44 @@ void main() {
});
});
group('mapearGananciaNativa — a device narrower than the slider', () {
test('a request that fits is still delivered literally', () {
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
// so a modest device silently under-delivered every request too.
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
});
test('a request beyond the device range clamps to the device limit', () {
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
});
test('a very narrow device still gets a sane, in-range value', () {
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -1.5,
maxDecibels: 1.5,
);
expect(nativo, greaterThanOrEqualTo(-1.5));
expect(nativo, lessThanOrEqualTo(1.5));
expect(nativo.sign, db.sign);
}
});
});
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
test('a range with no headroom on one side clamps that side to 0', () {
// A device that reports max == 0 can only cut. Asking for a boost must
// resolve to "no change", never to a negative value.
test('a device with no headroom above unity can never boost', () {
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0);
});
test('a cut the device could honour exactly is not over-delivered', () {
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
// range on a request for -12 dB. The user asked for -12; -12 is
// representable here, so -12 is what is sent.
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
});
test('a zero-width range collapses everything to 0', () {
@@ -0,0 +1,507 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Resuming the last station in Android Auto — the three defects that made a
/// car-only session unable to remember, restart or even NAME what it was
/// playing.
///
/// Every test here runs with NO widget tree and NO browse source registered:
/// that is the engine Android Auto actually starts
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
/// no Activity), so `EstadoRadio` — the only thing that used to write
/// `ultima_emisora_v1` — is never constructed there.
///
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
/// session that happened only in the car never updated the key and the
/// head unit was offered the station from the last time the PHONE was
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
/// tier's featured folder was stale too.
///
/// A2. `play()` with no source called `_player.play()`, and
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
/// BEFORE the `_audioSource != null` gate — so the platform was never
/// touched, the returned Future never completed, and `playing: true`
/// was forwarded over `processingState: idle`.
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
/// button, no audio, no title and no artwork (or a
/// `ForegroundServiceStartNotAllowedException` on API 31+).
///
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
/// are the duration update, `_cambiarFuente` and `stop` — so
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
/// native side got no metadata at all.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
/// The free set's first station: resolvable from the binary alone, so it
/// works on a bind where no browse source was ever registered — which is
/// the whole point of these tests.
const emisoraFip = Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
);
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
// false`) and no `ultima_emisora_v1`.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('A1 — el coche escribe la ultima emisora', () {
test(
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
'inyectado, sin arbol de widgets',
() async {
final prefs = await SharedPreferences.getInstance();
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async {
guardadas.add(emisora);
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
},
);
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
await pumpEventQueue();
expect(
guardadas.map((e) => e.uuid),
[emisoraFip.uuid],
reason:
'a car-only session must update `ultima_emisora_v1` itself — '
'`EstadoRadio` is never built on a headless engine',
);
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
expect(persistida?.uuid, emisoraFip.uuid);
expect(
persistida?.url,
emisoraFip.url,
reason:
'the record has to be PLAYABLE: it is what the recent root and '
'`resolverEmisorasDestacadas` hand back to the head unit',
);
},
);
test('playMediaItem directo (voz, telefono) persiste igual', () async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
expect(guardadas.single.url, 'https://ejemplo/stream');
});
test(
'una pista local NO se persiste como ultima emisora',
() async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'content://media/audio/7',
title: 'Pista local',
extras: {'documentId': 'doc-7'},
),
);
await pumpEventQueue();
expect(
guardadas,
isEmpty,
reason:
'`ultima_emisora_v1` feeds the recent root and the featured '
'folder as an `emisora:<uuid>` row — a `content://` track '
'there is a row that does nothing when tapped',
);
},
);
test('un fallo del puerto se traza y NUNCA propaga', () async {
final handler = crearHandler();
registrarHandler(
handler,
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
await pumpEventQueue();
});
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
final handler = crearHandler();
registrarHandler(handler);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
});
});
group('A2 — play() sin fuente no publica una sesion fantasma', () {
test(
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
'reproductor recibe su url',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
contains(emisoraFip.url),
reason:
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
'straight into play(); on a cold engine there is no source, so '
'it has to resolve the persisted station instead',
);
},
);
test(
'y NINGUN estado publicado lleva playing:true sobre processingState '
'idle',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
await sub.cancel();
expect(
fantasmas,
isEmpty,
reason:
'playing:true over idle is what makes `AudioService.java:559` '
'call enterPlayingState() with STATE_NONE — a PluriWave '
'notification with a pause button, no audio and no title',
);
},
);
test(
'sin nada persistido: no se toca el reproductor, no hay estado '
'fantasma y play() no se queda colgado',
() async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
await expectLater(
handler.play().timeout(const Duration(seconds: 2)),
completes,
);
await pumpEventQueue();
await sub.cancel();
expect(
guion.llamadasPlay,
0,
reason:
'with nothing to restore the player must not be touched at '
'all: `just_audio` publishes playing:true before its source '
'gate and never completes the future it returns',
);
expect(guion.llamadasSetUrl, 0);
expect(fantasmas, isEmpty);
},
);
test(
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
'siempre (pausa -> play no reabre nada)',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
await handler.pause();
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
await handler.play();
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
urlsAntes,
reason:
'a resume must NOT re-open the source, and must never replace '
'the live station with the persisted one',
);
expect(handler.intencionReproducir, isTrue);
},
);
});
group('A3 — arranque en frio: el coche recibe metadatos', () {
test(
'con una emisora persistida se publica su mediaItem SIN arrancar '
'reproduccion',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
expect(
handler.mediaItem.value,
isNotNull,
reason:
'`audio_service.dart:1029-1033` returns before setMediaItem '
'when mediaItem is null, so a cold engine sent the head unit '
'no metadata whatsoever',
);
expect(handler.mediaItem.value?.id, emisoraFip.url);
expect(handler.playbackState.value.playing, isFalse);
expect(
guion.llamadasSetUrl,
0,
reason:
'publishing metadata must not open a stream: a cold bind '
'happens on every reconnect and must stay silent',
);
},
);
test('sin nada persistido el mediaItem sigue vacio', () async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
expect(handler.mediaItem.value, isNull);
});
test(
'una emisora que ya empezo a sonar NO es pisada por la siembra',
() async {
final handler = crearHandler();
final lectura = Completer<Emisora?>();
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
await handler.playMediaItem(
const MediaItem(
id: 'https://enVivo/stream',
title: 'En vivo',
extras: {'uuid': 'uuid-en-vivo'},
),
);
lectura.complete(emisoraFip);
await pumpEventQueue();
expect(
handler.mediaItem.value?.id,
'https://enVivo/stream',
reason:
'the seed exists to fill a VOID; clobbering the live station '
'would rename what the driver is listening to',
);
},
);
});
group('getMediaItem resuelve tambien el set destacado', () {
test(
'sin fuente de navegacion registrada, una emisora destacada resuelve',
() async {
final handler = crearHandler();
registrarHandler(handler);
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
expect(
item,
isNotNull,
reason:
'`porUuid` already falls back to the featured set, so the car '
'could BROWSE a featured station and not resolve its media '
'item — the asymmetry is the bug',
);
expect(item?.id, 'emisora:${emisoraFip.uuid}');
expect(item?.title, emisoraFip.nombre);
},
);
test('un id que no es de emisora sigue devolviendo null', () async {
final handler = crearHandler();
registrarHandler(handler);
expect(await handler.getMediaItem('pista:doc-1'), isNull);
expect(await handler.getMediaItem('emisora:'), isNull);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
}
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
/// it never touches the platform and never completes the future it returned.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// A fresh player has no source, exactly like the real one.
bool _fuenteCargada = false;
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
_fuenteCargada = true;
return null;
}
@override
Future<void> play() {
_guion.llamadasPlay++;
if (!_estados.isClosed) {
_estados.add(
PlayerState(
true,
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
),
);
}
// The dangling future: with no source, upstream `play()` awaits a
// `_playingSubject` transition the platform will never produce.
if (!_fuenteCargada) return Completer<void>().future;
return Future<void>.value();
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
+189 -2
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
@@ -17,8 +19,12 @@ import 'package:pluriwave/servicios/servicio_compras.dart';
class _InAppPurchaseFalso implements InAppPurchase {
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
int restauracionesPedidas = 0;
bool disponible = true;
final completadas = <PurchaseDetails>[];
@override
Future<bool> isAvailable() async => disponible;
@override
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
@@ -52,6 +58,40 @@ PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
status: status,
);
/// A Play Billing past purchase as `queryPastPurchases` returns it.
GooglePlayPurchaseDetails _compraPasada(
PurchaseStatus status, {
String productId = ServicioComprasPlayBilling.idProducto,
}) => GooglePlayPurchaseDetails(
purchaseID: 'GPA.1',
productID: productId,
verificationData: PurchaseVerificationData(
localVerificationData: '{}',
serverVerificationData: 'token',
source: 'google_play',
),
transactionDate: '0',
status: status,
billingClientPurchase: PurchaseWrapper(
orderId: 'GPA.1',
packageName: 'es.freetimelab.pluriwave',
purchaseTime: 0,
purchaseToken: 'token',
signature: 'firma',
products: <String>[productId],
isAutoRenewing: false,
originalJson: '{}',
isAcknowledged: true,
purchaseState: PurchaseStateWrapper.purchased,
),
);
IAPError _errorBilling() => IAPError(
source: 'google_play',
code: 'restore_transactions_failed',
message: 'BillingResponse.serviceUnavailable',
);
void main() {
group('eventoDesdeEstadoCompra', () {
test('purchased -> comprada', () {
@@ -137,8 +177,8 @@ void main() {
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final compra =
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
final compra = _compraFalsa(PurchaseStatus.purchased)
..pendingCompletePurchase = true;
iap.emitir(<PurchaseDetails>[compra]);
await Future<void>.delayed(Duration.zero);
@@ -146,6 +186,153 @@ void main() {
});
});
group('ServicioComprasPlayBilling.consultarPropiedad (verificacion '
'silenciosa)', () {
late _InAppPurchaseFalso iap;
setUp(() => iap = _InAppPurchaseFalso());
tearDown(() => iap.dispose());
ServicioComprasPlayBilling servicioCon(
Future<QueryPurchaseDetailsResponse> Function() consulta, {
Duration limite = const Duration(seconds: 10),
}) {
final servicio = ServicioComprasPlayBilling(
inAppPurchase: iap,
consultarComprasPasadas: consulta,
limiteConsultaPropiedad: limite,
);
addTearDown(servicio.dispose);
return servicio;
}
test('producto comprado -> poseida', () async {
final servicio = servicioCon(
() async => QueryPurchaseDetailsResponse(
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.poseida,
);
});
test('respuesta correcta sin el producto -> noPoseida', () async {
final servicio = servicioCon(
() async => QueryPurchaseDetailsResponse(
pastPurchases: [
_compraPasada(PurchaseStatus.purchased, productId: 'otro'),
],
),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.noPoseida,
);
});
test('respuesta con error y sin producto -> desconocido', () async {
final servicio = servicioCon(
() async => QueryPurchaseDetailsResponse(
pastPurchases: const [],
error: _errorBilling(),
),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.desconocido,
);
});
test(
'respuesta con error parcial pero con el producto -> poseida',
() async {
final servicio = servicioCon(
() async => QueryPurchaseDetailsResponse(
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
error: _errorBilling(),
),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.poseida,
);
},
);
test('compra pendiente -> desconocido (nunca revoca)', () async {
final servicio = servicioCon(
() async => QueryPurchaseDetailsResponse(
pastPurchases: [_compraPasada(PurchaseStatus.pending)],
),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.desconocido,
);
});
test('billing no disponible -> desconocido, sin consultar', () async {
var consultas = 0;
iap.disponible = false;
final servicio = servicioCon(() async {
consultas++;
return QueryPurchaseDetailsResponse(pastPurchases: const []);
});
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.desconocido,
);
expect(consultas, 0);
});
test('excepcion -> desconocido', () async {
final servicio = servicioCon(
() async => throw Exception('BillingClient desconectado'),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.desconocido,
);
});
test('timeout -> desconocido', () async {
final servicio = servicioCon(
() => Completer<QueryPurchaseDetailsResponse>().future,
limite: const Duration(milliseconds: 10),
);
expect(
await servicio.consultarPropiedad(),
ResultadoVerificacionLicencia.desconocido,
);
});
test('es silenciosa: no emite eventos de compra ni usa '
'restorePurchases', () async {
final servicio = servicioCon(
() async => QueryPurchaseDetailsResponse(pastPurchases: const []),
);
final eventos = <EventoCompra>[];
final sub = servicio.eventos.listen(eventos.add);
addTearDown(sub.cancel);
await servicio.consultarPropiedad();
await Future<void>.delayed(Duration.zero);
expect(eventos, isEmpty);
expect(iap.restauracionesPedidas, 0);
});
});
test('idProducto es el identificador unico no-consumible', () {
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
});
@@ -0,0 +1,84 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/verificacion_licencia.dart';
/// Android Auto (headless) trigger of the silent license check: the
/// `registrar*` seam `main.dart` wires, exercised without a real
/// `PluriWaveAudioHandler`.
void main() {
late int invalidaciones;
setUp(() {
invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
});
tearDown(() {
registrarInvalidacionArbolAuto(() {});
registrarVerificacionLicenciaAuto(null);
});
test('sin verificador registrado es un no-op que nunca lanza', () async {
registrarVerificacionLicenciaAuto(null);
await dispararVerificacionLicenciaAuto();
expect(invalidaciones, 0);
});
test('una revocacion invalida el arbol de Auto', () async {
registrarVerificacionLicenciaAuto(() async => CambioLicencia.revocada);
await dispararVerificacionLicenciaAuto();
expect(invalidaciones, 1);
});
test('un desbloqueo silencioso tambien invalida el arbol', () async {
registrarVerificacionLicenciaAuto(() async => CambioLicencia.desbloqueada);
await dispararVerificacionLicenciaAuto();
expect(invalidaciones, 1);
});
test('sin cambios no invalida nada', () async {
registrarVerificacionLicenciaAuto(() async => CambioLicencia.sinCambios);
await dispararVerificacionLicenciaAuto();
expect(invalidaciones, 0);
});
test('un fallo del verificador se traga en silencio', () async {
registrarVerificacionLicenciaAuto(() async => throw StateError('boom'));
await expectLater(dispararVerificacionLicenciaAuto(), completes);
expect(invalidaciones, 0);
});
test(
'no retiene al llamador: devuelve antes de que la consulta acabe',
() async {
final pendiente = Completer<CambioLicencia>();
registrarVerificacionLicenciaAuto(() => pendiente.future);
// The browse path calls this unawaited; the returned future being
// pending here proves the verification runs in the background.
var terminado = false;
unawaited(
dispararVerificacionLicenciaAuto().then((_) => terminado = true),
);
await Future<void>.delayed(Duration.zero);
expect(terminado, isFalse);
pendiente.complete(CambioLicencia.revocada);
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(terminado, isTrue);
expect(invalidaciones, 1);
},
);
}
@@ -0,0 +1,272 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/verificacion_licencia.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Silent license re-verification policy (refund revocation): pure,
/// headless-safe, driven by an injectable clock and a fake ownership query —
/// zero plugin channels.
/// Scripted ownership query: returns [resultados] in order and counts calls.
class _ConsultaFalsa {
_ConsultaFalsa(this.resultados);
final List<ResultadoVerificacionLicencia> resultados;
int llamadas = 0;
Future<ResultadoVerificacionLicencia> call() async {
final resultado = resultados[llamadas.clamp(0, resultados.length - 1)];
llamadas++;
return resultado;
}
}
/// Mutable clock so each test can move time forward between checks.
class _Reloj {
_Reloj(this.ahora);
DateTime ahora;
DateTime call() => ahora;
void avanzar(Duration d) => ahora = ahora.add(d);
}
void main() {
late _Reloj reloj;
setUp(() {
SharedPreferences.setMockInitialValues({});
reloj = _Reloj(DateTime(2026, 9, 18, 10));
});
Future<SharedPreferences> prefsCon({required bool premium}) async {
SharedPreferences.setMockInitialValues({claveCompraPremium: premium});
return SharedPreferences.getInstance();
}
Future<CambioLicencia> verificar(
SharedPreferences prefs,
Future<ResultadoVerificacionLicencia> Function() consultar,
) => verificarLicencia(consultar: consultar, prefs: prefs, reloj: reloj.call);
group('verificarLicencia', () {
test('desconocido (offline / sin billing) conserva premium y no toca el '
'contador de ausencias', () async {
final prefs = await prefsCon(premium: true);
final consulta = _ConsultaFalsa([
ResultadoVerificacionLicencia.noPoseida,
]);
await verificar(prefs, consulta.call);
expect(prefs.getInt(claveAusenciasLicencia), 1);
reloj.avanzar(const Duration(days: 2));
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.desconocido,
);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isTrue);
expect(prefs.getInt(claveAusenciasLicencia), 1);
});
test('una excepcion en la consulta no cambia nada (fail-open)', () async {
final prefs = await prefsCon(premium: true);
final cambio = await verificar(
prefs,
() async => throw Exception('BillingClient desconectado'),
);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isTrue);
expect(prefs.getInt(claveAusenciasLicencia), isNull);
});
test('poseida conserva premium y reinicia el contador', () async {
final prefs = await prefsCon(premium: true);
await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
expect(prefs.getInt(claveAusenciasLicencia), 1);
reloj.avanzar(const Duration(days: 1));
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.poseida,
);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isTrue);
expect(prefs.getInt(claveAusenciasLicencia), isNull);
});
test('poseida con la flag en false desbloquea en silencio '
'(reinstalacion)', () async {
final prefs = await prefsCon(premium: false);
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.poseida,
);
expect(cambio, CambioLicencia.desbloqueada);
expect(prefs.getBool(claveCompraPremium), isTrue);
});
test('una sola ausencia NO revoca', () async {
final prefs = await prefsCon(premium: true);
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isTrue);
});
test('dos ausencias separadas por el umbral revocan', () async {
final prefs = await prefsCon(premium: true);
await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
reloj.avanzar(intervaloVerificacionLicencia);
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
expect(cambio, CambioLicencia.revocada);
expect(prefs.getBool(claveCompraPremium), isFalse);
expect(prefs.getInt(claveAusenciasLicencia), isNull);
});
test('dos ausencias demasiado juntas NO revocan', () async {
final prefs = await prefsCon(premium: true);
await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
// Simulates the throttle having been bypassed (e.g. prefs cleared by
// a second engine): only the spacing guard stands between a transient
// empty Play cache and a false revocation.
await prefs.remove(claveUltimaVerificacionLicencia);
await prefs.remove(claveUltimoIntentoLicencia);
reloj.avanzar(separacionMinimaAusencias - const Duration(minutes: 1));
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isTrue);
});
test('ausencia seguida de poseida reinicia: la siguiente ausencia '
'vuelve a contar desde cero', () async {
final prefs = await prefsCon(premium: true);
final consulta = _ConsultaFalsa([
ResultadoVerificacionLicencia.noPoseida,
ResultadoVerificacionLicencia.poseida,
ResultadoVerificacionLicencia.noPoseida,
]);
await verificar(prefs, consulta.call);
reloj.avanzar(const Duration(days: 1));
await verificar(prefs, consulta.call);
reloj.avanzar(const Duration(days: 1));
final cambio = await verificar(prefs, consulta.call);
expect(consulta.llamadas, 3);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isTrue);
expect(prefs.getInt(claveAusenciasLicencia), 1);
});
test('noPoseida con la flag ya en false no hace nada', () async {
final prefs = await prefsCon(premium: false);
final cambio = await verificar(
prefs,
() async => ResultadoVerificacionLicencia.noPoseida,
);
expect(cambio, CambioLicencia.sinCambios);
expect(prefs.getBool(claveCompraPremium), isFalse);
expect(prefs.getInt(claveAusenciasLicencia), isNull);
});
test('throttle: no vuelve a consultar dentro de las 24h tras una '
'verificacion con respuesta', () async {
final prefs = await prefsCon(premium: true);
final consulta = _ConsultaFalsa([ResultadoVerificacionLicencia.poseida]);
await verificar(prefs, consulta.call);
reloj.avanzar(intervaloVerificacionLicencia - const Duration(minutes: 1));
await verificar(prefs, consulta.call);
expect(consulta.llamadas, 1);
reloj.avanzar(const Duration(minutes: 1));
await verificar(prefs, consulta.call);
expect(consulta.llamadas, 2);
});
test('throttle: tras un desconocido reintenta pasada la ventana corta, '
'no antes', () async {
final prefs = await prefsCon(premium: true);
final consulta = _ConsultaFalsa([
ResultadoVerificacionLicencia.desconocido,
]);
await verificar(prefs, consulta.call);
reloj.avanzar(intervaloReintentoLicencia - const Duration(minutes: 1));
await verificar(prefs, consulta.call);
expect(consulta.llamadas, 1);
reloj.avanzar(const Duration(minutes: 1));
await verificar(prefs, consulta.call);
expect(consulta.llamadas, 2);
});
test(
'un reloj que retrocede no bloquea la verificacion para siempre',
() async {
final prefs = await prefsCon(premium: true);
final consulta = _ConsultaFalsa([
ResultadoVerificacionLicencia.poseida,
]);
await verificar(prefs, consulta.call);
reloj.avanzar(const Duration(days: -3));
await verificar(prefs, consulta.call);
expect(consulta.llamadas, 2);
},
);
test('solo UNA verificacion en vuelo: llamadas concurrentes comparten la '
'misma consulta', () async {
final prefs = await prefsCon(premium: true);
final respuesta = Completer<ResultadoVerificacionLicencia>();
var llamadas = 0;
Future<ResultadoVerificacionLicencia> consultar() {
llamadas++;
return respuesta.future;
}
final a = verificar(prefs, consultar);
final b = verificar(prefs, consultar);
respuesta.complete(ResultadoVerificacionLicencia.poseida);
await Future.wait([a, b]);
expect(llamadas, 1);
});
});
}
@@ -24,6 +24,10 @@ class _PuertoComprasFalso implements PuertoCompras {
@override
Future<void> restaurar() async {}
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
ResultadoVerificacionLicencia.desconocido;
void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close();
+18 -2
View File
@@ -31,6 +31,10 @@ class _PuertoComprasFalso implements PuertoCompras {
@override
Future<void> restaurar() async {}
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
ResultadoVerificacionLicencia.desconocido;
void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close();
@@ -216,6 +220,19 @@ void main() {
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
});
testWidgets(
'the Android Auto bullet names what PRO adds in the car, not the '
'product itself (free already gets a playable featured folder)',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(l10n.premiumBeneficioAndroidAuto, isNot('Android Auto'));
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
},
);
testWidgets('states this is a one-time purchase, not a subscription', (
tester,
) async {
@@ -334,8 +351,7 @@ void main() {
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create:
(_) => EstadoEntitlement(prefs: null, compras: servicio),
create: (_) => EstadoEntitlement(prefs: null, compras: servicio),
),
],
child: MaterialApp(
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_idioma.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/pantallas/pantalla_ajustes.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
@@ -222,6 +223,9 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: testApp(const PantallaAjustes()),
),
@@ -0,0 +1,143 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
/// Play sensitive-permission guard for the waveform visualizer.
///
/// `MainActivity.startVisualizerWhenAllowed` asks for `RECORD_AUDIO` the
/// instant Dart subscribes to the `pluriwave/audio_visualizer` EventChannel.
/// The home screen's "Escuchar" hero mounts this widget, so before this the
/// microphone dialog appeared on the user's FIRST play, with no context at
/// all, in a radio app — the worst possible moment to ask.
///
/// The subscription (and therefore the permission request) is now gated on
/// an explicit opt-in the user gives in Settings, after reading what the
/// permission is for. These tests pin the Dart half of that: no opt-in, no
/// channel subscription.
void main() {
const canal = MethodChannel('pluriwave/audio_visualizer');
late List<MethodCall> llamadas;
setUp(() {
llamadas = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
llamadas.add(call);
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
});
Future<void> montar(
WidgetTester tester, {
required Stream<EstadoReproduccion> estadoStream,
required Stream<int?> sessionStream,
required bool capturaRealHabilitada,
}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VisualizadorAudio(
estadoStream: estadoStream,
androidAudioSessionIdStream: sessionStream,
barras: 12,
capturaRealHabilitada: capturaRealHabilitada,
),
),
),
);
await tester.pump();
}
testWidgets(
'sin el opt-in, reproducir NO suscribe el EventChannel (y por tanto no '
'dispara la peticion de RECORD_AUDIO)',
(tester) async {
final estado = StreamController<EstadoReproduccion>.broadcast();
final sesion = StreamController<int?>.broadcast();
addTearDown(estado.close);
addTearDown(sesion.close);
await montar(
tester,
estadoStream: estado.stream,
sessionStream: sesion.stream,
capturaRealHabilitada: false,
);
sesion.add(42);
estado.add(EstadoReproduccion.reproduciendo);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(
llamadas.where((c) => c.method == 'listen'),
isEmpty,
reason:
'subscribing is what makes MainActivity call requestPermissions '
'for RECORD_AUDIO; with no opt-in it must never happen',
);
},
);
testWidgets(
'con el opt-in, reproducir SI suscribe el EventChannel (la onda real '
'sigue disponible para quien la pide)',
(tester) async {
final estado = StreamController<EstadoReproduccion>.broadcast();
final sesion = StreamController<int?>.broadcast();
addTearDown(estado.close);
addTearDown(sesion.close);
await montar(
tester,
estadoStream: estado.stream,
sessionStream: sesion.stream,
capturaRealHabilitada: true,
);
sesion.add(42);
estado.add(EstadoReproduccion.reproduciendo);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(llamadas.where((c) => c.method == 'listen'), hasLength(1));
},
);
testWidgets('el visualizador sigue dibujando sus barras sin el opt-in', (
tester,
) async {
final estado = StreamController<EstadoReproduccion>.broadcast();
final sesion = StreamController<int?>.broadcast();
addTearDown(estado.close);
addTearDown(sesion.close);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: VisualizadorAudio(
estadoStream: estado.stream,
androidAudioSessionIdStream: sesion.stream,
barras: 12,
barrasDiscretas: true,
),
),
),
);
await tester.pump();
for (var i = 0; i < 12; i++) {
expect(find.byKey(Key('visualizador-barra-$i')), findsOneWidget);
}
});
}