main
260
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b1bf289e0d |
fix(eq): entregar los decibelios que pide el usuario, sin estirarlos
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 |
||
|
|
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. |
||
|
|
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.
|
||
|
|
5f35ab7d6a |
feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche. Los grupos de favoritos no volvian al importar La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar, que fuerza "sin asignar" a proposito, porque una emisora recien marcada como favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se separa la ruta de restaurar, que respeta el grupo_id del backup y su orden. Ningun test lo detectaba porque el doble de pruebas era infiel: el fake conservaba el grupo que la implementacion real destruia, y no tenia restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble. No hace falta subir la version del formato: el envoltorio ya llevaba todo. El coche perdia la lista al reconectar El contexto de reproduccion vivia solo en memoria, y el motor que arranca Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve contra las listas vivas en cada salto: si el grupo cambia de contenido entre sesiones, el coche ve lo actual y no una foto vieja. Cadena de repliegue, decidida por el propietario: grupo vivo con la emisora dentro -> se recorre el grupo grupo vivo sin la emisora -> se permanece en el grupo, primera grupo vivo pero vacio -> se ensancha a favoritos grupo borrado -> favoritos sin favoritos -> comportamiento actual Honrar un grupo de una sola emisora exigia levantar dos barreras, no una: el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora se salio del grupo" se trata en el flujo del salto. La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta en vez de recorrerse. Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes. |
||
|
|
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. |
||
|
|
3449e2cb79 |
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
|
||
|
|
4ca2813267 |
feat(ecualizador): include equalizer on/off toggle in export/import
The backup envelope carried favorites, EQ presets, alarms and the multi-device toggle but not EstadoEcualizador's own on/off flag, so restoring a backup on another device silently kept that device's existing toggle state instead of the source device's. Bumps the backup schema to v4 (additive over v3): the flag is only written when explicitly provided, so old exports stay at v2/v3. Importing an old backup without the field leaves the current toggle untouched rather than defaulting it. Applying the imported value reuses EstadoEcualizador.cambiarActivo so it persists and pushes to the live audio engine exactly like a manual toggle. |
||
|
|
a2bed18937 |
fix(paywall): add dismiss controls and honest premium copy
The premium sheet had no close affordance or way to defer, and its copy only said "Función Premium" without stating what it unlocks. Adds a header close (X) button and a "not now" secondary action so dismissal is never harder than purchasing, and replaces the bare title with a concrete, honest breakdown of the 5 things premium unlocks (no ads, Android Auto, station recording, alarm vacation ranges, unlimited alarms) plus the one-time-purchase framing. The phone equalizer is never listed, since it stays free for everyone. New l10n keys added to all 13 locales. |
||
|
|
e57f7bb17b |
fix(alarmas): reload and re-sync alarms after backup import
Importing a backup wrote the alarm/vacation/exception block straight to SharedPreferences but never told EstadoAlarmas about it, so the UI kept showing the pre-import alarms, a later edit could persist that stale state back over the imported one, and imported alarms were never (re)scheduled with the Android native layer. The backup screen now calls EstadoAlarmas.cargarPersistidasSinRecalcular() followed by refrescarProgramacion() after a successful import, extracted into a directly-testable aplicarImportacionConfig() function. |
||
|
|
2e15d05431 |
fix(ads): force test ad units in release during closed testing
Closed-testing human testers cannot be registered as AdMob test devices, so release builds serving real ad units risked invalid traffic against an AdMob account that currently earns essentially nothing. Add usarAnunciosDePruebaEnRelease, a single boolean switch defaulted to true, that keeps bannerAdUnitId/interstitialAdUnitId on Google's official test ids even in kReleaseMode. Flipping it to false is the only change needed to go live. AndroidManifest's AdMob application id is untouched, as it only initializes the SDK. |
||
|
|
e9f47d47c2 |
fix(eq): resync EstadoEcualizador with car/notification-initiated changes
A toggle from the Android Auto notification or a preset picked from the car's EQ folder mutated PluriWaveAudioHandler state directly, leaving EstadoEcualizador (and therefore the phone UI) unaware and never persisting the change, so it was lost on the next app restart. Forward the handler's ecualizadorActivo flag through ServicioAudio and, mirroring EstadoRadio's existing playFromMediaId resync, diff it plus presetActual against the cached values on every estadoStream tick, adopting and persisting a divergence via ServicioEcualizador. |
||
|
|
9cfa5ac17d |
fix(iap): address code review defects in freemium/IAP change
Fixes 9 of 10 review findings (10th requires a manual Play Console step, no code change): 1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside BannerAnuncioSuperior so it only reserves status-bar height when an ad actually renders, restoring edge-to-edge layout for premium and free-unloaded users. 2. servicio_anuncios.dart: bound every interstitial await (load, presentation, and the injected implementation itself) with injectable timeouts so a callback that never fires can no longer hang a caller. 3. estado_entitlement.dart/hoja_premium.dart: expose a typed resultadoUsuario signal for purchase/restore failures and restore-found-nothing, with dedicated localized messages (compraError, restauracionSinCompras) across all 13 locales -- never the raw developer/exception string. 4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on canRequestAds(); premium users never see a consent form; failures degrade to no ads instead of crashing or blocking startup. 6. servicio_anuncios.dart: track real ad presentation (onAdShowedFullScreenContent) so a failed-to-show interstitial no longer consumes a session cap slot. 7. banner_anuncio_superior.dart: add an explicit load-attempted guard so repeated didChangeDependencies (e.g. entitlement notifyListeners during a purchase) can only ever trigger one banner load attempt. 8. servicio_anuncios.dart: make esPremium a required constructor parameter, matching the hardened contract already applied to EstadoAlarmas/EstadoGrabacion/EstadoRadio. 9. hoja_premium.dart: add a dedicated premiumActivo localized string instead of reusing the equalizer's equalizerActive translation, across all 13 locales. All fixes implemented RED-first (failing test before production code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures. flutter analyze: 5 pre-existing issues only, 0 new. [version set] |
||
|
|
94f354a7c1 |
feat(iap): wire real AdMob app id, banner and interstitial units
App id always uses the real value (SDK init only, no ad-serving risk). Banner/interstitial pick the real unit id in release builds and Google's test unit id everywhere else, so debug/profile builds can never serve (or accidentally tap) a real ad. |
||
|
|
d81fabbe27 |
refactor(iap): make esPremium a required constructor parameter
EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to `() => true`, so any construction site that forgot to wire entitlement compiled fine and silently ran ungated — failing OPEN to premium and disabling the paywall with no test able to catch it. The parameter is now required with no default. Production wiring in app.dart was already correct and is unchanged; the 184 pre-existing test call sites now pass `() => true` explicitly, which is exactly the old implicit default, so every assertion is untouched. EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it inherits the same contract. The one test that existed to pin the old default is renamed to describe what it still covers (the premium path through iniciar() with no duracion); its assertions are unchanged. |
||
|
|
aa0b242374 |
feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement + PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks alarm vacations, alarms past a 5-alarm free cap, recording start, and full Android Auto browsing. The phone equalizer stays free for everyone. - Entitlement is prefs-backed (compra_premium_v1), fail-open, and resolvable headlessly via esPremiumPersistido() for the Android Auto audio handler, which registers before runApp. - Android Auto reduced mode keeps the real root folder labels for free users; browsing into any of them (and playFromMediaId/playFromSearch/ skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio choke points, with a locked "Función Premium" item as the backstop. Current-station play/pause/stop stays untouched. A free -> premium transition actively invalidates the head unit's cached browse tree. - Ads (top banner + capped interstitial before adding a station or an alarm) are gated behind entitlement via ServicioAnuncios, using official Google test ad unit IDs pending AdMob provisioning. - Alarm cap UX shows an explanatory message with a secondary unlock action rather than a bare paywall jump; existing data is grandfathered. - 4 new localization keys translated across all 13 supported locales. Co-located tests use strict TDD (RED test before implementation) for every new pure-logic unit; full existing suite passes unchanged. |
||
|
|
d754e28ddf |
fix(audio): keep local skips local, and stop a failed station blanking Auto
Three reported suspicions. Two confirmed by reading, one not.
1. CONFIRMED, self-inflicted. Playing a song from the phone and pressing
NEXT jumped to a radio station.
|
||
|
|
f01c0911f7 |
fix(auto): guard shipped resources, walk favourite groups when skipping
History review requested by the owner: when and why did the Android Auto UI stop working. ANSWER: 31 July, commit |
||
|
|
54d87190fe |
fix(grabacion): reject a local track as a recording source
Reported, with this on screen: No se pudo iniciar la grabación: Invalid argument(s): Unsupported scheme 'content' in URI content://com.android.externalstorage.documents/tree/ primary%3AMusic/document/primary%3AMusic%2F...%2FNew Limit - Smile.mp3 The URI in that message is a local MP3, not a station. PluriWaveAudioHandler._cambiarFuente sets `emisoraActual` for EVERY source it plays, so a local track surfaces as an Emisora whose `url` is the SAF content:// document URI it was opened from. EstadoGrabacion.iniciar only checked for null, handed that straight to the recorder, and the HTTP client failed with a message no user can act on. "It used to work" is exactly right: before local music playback existed, whatever was playing was always a real station, so the case could not arise. The recorder never changed. iniciar() now also requires a real network stream (esEmisoraGrabable) and falls back to the existing "select a station first" message, which is the correct guidance here -- recording a local file makes no sense anyway, it is already on the device. No new l10n key, so no 13-locale churn for a message that already says the right thing. Tests: 1158 -> 1161. |
||
|
|
1d5332453f |
fix(audio): make the audio diagnostics visible in release builds
Every diagnostic line in the audio path used `dart:developer`'s `log()`. That function writes to the VM service, which a RELEASE build does not have — so in the only build that ever runs in a car, all eleven of them went nowhere. `debugPrint`/`print` do reach logcat in release; `log()` does not. That includes the two channels built specifically to end the guessing: - `registrarErrorAudioService`, which subscribes to `AudioService.asyncError` so the plugin's swallowed platform exceptions stop vanishing ( |
||
|
|
3398d02a43 |
fix(auto): keep the service alive through interruptions, restore local music
Four car reports, two root causes.
1. Local music vanished from the Android Auto menu. Self-inflicted, by
|
||
|
|
c1afe72aec |
fix(arranque): stop a headless engine from dying before runApp
Reported: with Android Auto connected, the car screen sometimes came up completely BLACK, and opening the app on the phone then showed a completely WHITE screen until the app was force-killed and reopened. Never without Android Auto. The user guessed portrait-only plus a landscape phone made the app "go a bit crazy". Right file and right trigger, different mechanism -- a broken layout renders overflow stripes or a red error box, never white. White means nothing was ever built, so runApp had not run. Verified in the plugin source: AudioServiceActivity.provideFlutterEngine returns AudioServicePlugin.getFlutterEngine(context), which CREATES the engine and executes the Dart entrypoint the first time it is asked. When the car binds the MediaBrowserService before the app is opened, that first ask is the service -- so main() runs HEADLESS, with no Activity. SystemChrome.setPreferredOrientations travels the flutter/platform channel, whose handler (PlatformPlugin) is installed by the Activity. Headless there is nobody to answer it, so the call throws MissingPluginException or never settles. It was the FIRST await in main(), which made it fatal twice over: registrarFuenteNavegacion sits below it and never ran, leaving getChildren with no source (black car screen), and runApp was never reached. Opening the app then reused that same cached, already-dead engine -- white screen. Only a force-kill, which disposes the cached engine, recovered it. That is exactly the workaround that was reported, and it is what makes the diagnosis fit every detail rather than most of them. Three changes, smallest first: - The Android Auto browse registration moves above every await. It depends on nothing, and anything before it is a place to get stuck. - The orientation call is no longer awaited. It is a display preference, never a prerequisite for runApp, and _OrientacionResponsiveApp already re-applies it in didChangeDependencies -- the only moment it can take effect anyway. - aplicarPoliticaOrientacion swallows everything and logs, so the headless failure can never propagate again. The policy itself is unchanged and now pure and tested (orientacionesPara): phones portrait, >=600dp everything. Tests: 1141 -> 1146. |
||
|
|
a6cdf0e72c |
fix(auto): advertise the transport actions Android for Cars requires
Reported: on the Android Auto playback screen the play/pause button stays
on PLAY while audio is audibly playing, and "it used to work, in the
latest versions it doesn't".
Previous rounds looked for a regression in this repo's audio commits and
found none: every playbackState.add site publishes playing:true with a
ready processingState, and AudioService.getPlaybackState maps that to
STATE_PLAYING. That search was aimed at the wrong thing.
The Android for Cars guide ("Enable playback control") is explicit:
"Android Auto and AAOS display playback controls based on the actions
that are enabled in the PlaybackStateCompat object. By default, your app
must support the following actions: ACTION_PLAY, ACTION_PAUSE,
ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH."
systemActions has carried only `seek` + `stop` since
|
||
|
|
80538900db |
fix(alarmas,auto): guard the last unguarded snooze path, surface car progress
Continuation of |
||
|
|
7054a4c871 |
fix(alarmas,auto): guard the native snooze anchor, surface the EQ in the car
Three reported issues, two fixed and one instrumented. 1. Posponer left the alarm snoozed ~1444 minutes (24h04m). Traced end to end in Kotlin. onAlarmFired runs from the receiver BEFORE the ringing notification exists, and persists snoozeOriginMillis = null plus a triggerAtMillis already advanced to TOMORROW by computeNextTriggerMillis. snooze() then anchored on `spec.snoozeOriginMillis ?: spec.triggerAtMillis` and picked up tomorrow. The existing clamp could not catch it: it only rescues anchors in the PAST, so an anchor +24h out sails through. The countdown text is honest -- ceilMinutes(snoozeUntil - now) over Dart's own template -- the corrupt value is snoozeUntil. With N=5 and a tap at T+1min the arithmetic lands on 1444 exactly. This is the defect |
||
|
|
04300592e0 |
fix(alarmas): heal alarms already poisoned by the old Detener anchor
The anchor fix stops NEW damage, but devices that ran the buggy build still carry a future occurrence in ultimaEjecucionGestionada in SharedPreferences. _esValida rejects any candidate matching it, so the affected alarm would keep skipping that day with nothing in the UI to explain it -- which reads as "still broken" rather than "fixed". _recalcular now drops an ultimaEjecucionGestionada that is meaningfully in the future. An occurrence cannot have been handled before it happens, so such a value is corrupt by definition, and dropping it can only ever restore a real future ring: the double-fire guard it also feeds needs a PAST occurrence to do its job, and those are untouched. Placed in the recalculation that every load and every mutation already funnels through, so an affected alarm heals on the next app open with no user action -- no delete-and-recreate. Tests: 1122 -> 1124, including one proving a genuine past occurrence is still preserved. |
||
|
|
a9da855601 |
fix(alarmas): stop Detener from consuming an occurrence that never rang
Reported: an alarm set for Monday 16:20 never rang, and the "next alarm"
banner showed a different alarm (the next morning's) instead. No
vacation range involved, both alarms active.
finalizarEjecucion anchored the completed occurrence to proximaEjecucion
with no check that it was the one actually ringing. On the native-fire
path the fire-time sync advances proximaEjecucion to the NEXT occurrence
before the user can reach the ring screen, so tapping Detener recorded a
FUTURE occurrence in ultimaEjecucionGestionada.
ServicioProgramacionAlarmas._esValida then rejects that occurrence for
real: a Monday-only alarm stopped today simply never rings next Monday,
and every sibling outranks it in the banner because its own
proximaEjecucion is a week out.
Reproduced at its purest in the second test: with nothing ringing at
09:01 on Monday, Detener pushed a 16:20 alarm to the FOLLOWING Monday.
posponerAlarma already had exactly this guard --
|
||
|
|
f2f706b342 |
fix(auto): restore the equalizer toggle and list the user's own presets
Two Android Auto regressions reported from the car. 1. The on/off equalizer action disappeared from the playback screen. That was self-inflicted: commit |
||
|
|
cacd3ece57 |
fix(audio): keep custom actions out of the media notification controls
The equalizer toggle appended to the transport controls was aborting the whole notification. controls feeds BOTH the phone notification and the car playback screen, and AudioService.setState walks every control through createCustomAction (AudioService.java:513-520) BEFORE reaching mediaSession.setPlaybackState (:552) and enterPlayingState (:559) -- the only place the notification is ever posted. createCustomAction resolves the icon by name via getIdentifier (:415-420), which returns 0 on a miss, and passes it to PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an empty label. That throw aborts setState, so the media session is never published: no shade widget, no lock-screen controls, not even the small status-bar icon. ExoPlayer runs independently so audio keeps playing, and until asyncError got a subscriber the exception was dropped silently. Nothing is lost in the car: the Ecualizador browse folder already lists Desactivar plus every preset by name, which is Auto's own idiom for choosing among options. |
||
|
|
1b126d5147 |
fix(audio): publish idle from stop() instead of trusting the player
just_audio's playerStateStream is .distinct() over a value-equal PlayerState, so stopping an already-idle player emits nothing. Paired with the source-change mask -- which writes loading into playbackState rather than filtering at read time -- a stop landing before native init completed would leave the state at loading forever. audio_service only tears the foreground service down on a non-idle to idle transition, so that window produced an unkillable notification stuck on "cargando" with a dead Stop button: strictly worse than the teardown this branch removes. Additive and idempotent -- when the player does emit its own idle, this just lands first. |
||
|
|
6da3e69f7e |
fix(audio): stop emitting a transient idle during a source change
Root cause of the disappearing media notification, and it is self-inflicted
on EVERY Android version — no plugin patch involved.
`audio_service`'s `_observePlaybackState` (audio_service.dart:1131-1136) calls
`AudioService._stop()` on ANY transition into `idle` from a non-idle state.
That reaches `stopService()` -> `deactivateMediaSession()` ->
`notificationManager.cancel(NOTIFICATION_ID)`. The notification is re-posted
at exactly one place, `internalStartForeground()`, reachable only from the
`!wasPlaying && playing` edge in `setState()`, and its FIRST statement is
`ContextCompat.startForegroundService(...)` — which on API 31+ throws
`ForegroundServiceStartNotAllowedException` whenever the process is not in a
foreground state.
Every station change walked straight into that. `_cambiarFuente` pushes
`loading`, then `_recrearPlayer` disposes the old `AudioPlayer` and builds a
FRESH one; a fresh player's first `playerStateStream` event is always `idle`,
and the listener forwarded it verbatim. So `loading -> idle` tore the
foreground service down mid-source-change, and recovery depended on the
following `playing: true` edge restarting it. Screen off, lock screen, or an
Android Auto / Bluetooth-initiated start is precisely where the platform
refuses that restart: audio keeps playing, the notification never returns.
That is exactly what the user reports.
The mapping decision moves out of the private `_mapProcState` into a pure
top-level `mapearEstadoProceso(proc, {required bool cambiandoFuente})`, so the
one line that decides whether the foreground service dies is unit-testable
without instantiating the handler (which needs MethodChannels). It is
byte-for-byte identical to the old switch in every case except `idle` while a
source change is in flight, which now maps to `loading`. The test asserts the
full ProcessingState x cambiandoFuente matrix against a literal transcription
of the previous mapping, and asserts both directions explicitly: a real stop
still yields `idle`, a source-change idle yields `loading`, and `idle` is the
only case where the two branches differ at all.
The only risk this introduces is a `_cambiandoFuente` stuck at `true`: a real
user stop would be masked away from `idle`, the service would never stop, and
the notification would become unkillable. So the flag is cleared by four
independent mechanisms rather than one audited path:
- a `finally` around the whole body of `_cambiarFuente`, which covers normal
completion, BOTH `revision != _revisionFuente` early returns, every
`rethrow` out of a catch clause, and any non-`Exception` `Error` that none
of the three clauses matches;
- eagerly at the top of each of the three catch clauses — needed on top of
the `finally` because `_gestionarErrorReproduccion` calls `_player.stop()`
WITHOUT awaiting it, so that `idle` could otherwise land while the mask
was still up;
- right after `setUrl` resolves, before anything below can await, since the
fresh player's transient `idle` is already behind us at that point;
- at the start of `stop()` — before `_player.stop()` — and at the start of
`_gestionarErrorReproduccion`, which makes the invariant total: the flag
is `false` before every single `_player.stop()` call in this class.
`stop()` matters most: `BaseAudioHandler.stop()` is empty, so the handler
never pushes `idle` itself — teardown is driven entirely by the player's
emission. A stop landing while a station change was still in flight would
otherwise be masked and the notification would survive the stop.
Audited: two `_player.stop()` call sites exist and both are preceded by a
clear; `_recrearPlayer` has exactly one caller and it is guarded; the old
player cannot emit during `_recrearPlayer` because its subscriptions are
cancelled first.
|
||
|
|
b0271fa953 |
feat(audio): log AudioService.asyncError instead of swallowing it
`AudioService.asyncError` had ZERO subscribers app-wide. The plugin funnels
every asynchronous failure of its own observers into that stream and nowhere
else — `_observePlaybackState`, `_observeMediaItem` and `_observeQueue` each
wrap their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
path uses `.catchError(_asyncError.add)` — and a `PublishSubject` with no
listeners simply drops what it is given. The platform-side exception behind
"the media playback notification disappeared" was therefore being discarded
without a single log line, which is why that report arrives with no evidence
attached.
`observarErroresAudio` is a pure, injectable seam in `arranque_audio.dart`
(stream in, logger callback out), matching the seam convention this codebase
already uses for `esperarArranqueAudio`, `decidirAvanceCola` and
`debeReaplicarEcualizador`: the unit tests exercise the wiring with a plain
`StreamController`, never the real plugin. The default logger emits one
`[PluriWave]`-prefixed `developer.log` line at `level: 900`, the same level
and prefix `servicio_audio.dart` already uses, so one logcat filter catches
both.
Wired from `lib/main.dart`, not from `arranque_audio.dart`: main.dart is the
module that genuinely owns handler lifecycle — it is the only caller of
`AudioService.init`, `registrarHandler` and `ServicioAudioSession`, and both
the on-time and the degraded/timeout startup branches converge on its
`conectarHandler` closure. `arranque_audio.dart` owns only the timeout race
and the degraded loading shell; it never creates or registers a handler
(`alListo` is injected into it from main.dart), so it has no lifecycle to
hang a subscription on. Subscribing happens before `AudioService.init` — the
getter only touches a static subject — so nothing reported during the
MediaBrowser handshake is missed, and one subscription covers both paths.
The subscription is cancellable and its `cancel` is registered into the
handler via `registrarLimpiezaArranque`, mirroring the existing
`registrarHandler` / `registrarFuenteNavegacion` / `registrarFuenteMusicaLocal`
registration convention. `onTaskRemoved` — the only handler teardown in this
app — runs it, so the subscription cannot outlive what it instruments. The
dependency points bootstrap -> service, so `servicio_audio.dart` never has to
import the bootstrap module or the plugin's static stream.
Zero behaviour change: nothing but log output is added.
|
||
|
|
b09d644a2c | merge: incorporate main's safearea/auto-order/vacaciones fixes | ||
|
|
f4f9e87970 | docs(alarmas): fix helper name typo in vacation delete comment | ||
|
|
597701f497 |
fix(alarmas): add a delete action to the vacation range edit sheet
The vacation edit sheet could save changes to an existing range but had no way to remove it, forcing users back to the swipe-to-delete gesture on the list. When editing (not creating) a range, the sheet now shows an outlined delete action next to Save; it reuses the existing confirmation dialog and EstadoAlarmas.eliminarRangoVacaciones exactly as the swipe gesture already does, then pops on success. |
||
|
|
4d54908be6 |
fix(ui): add top-inset awareness to PluriRootHeader
PluriRootHeader rendered its 56px title/actions row flush at y=0 on every device, since app.dart's root SafeArea(top: false) deliberately excludes the top inset (so each root's full-bleed background paints edge-to-edge behind the status bar) but the header itself never added MediaQuery.paddingOf(context).top anywhere. The header now wraps its existing 56px content row in an outer top padding equal to that inset, so total rendered height is height + topInset while `height` keeps meaning the content row's own height (verified no call site did total-height math against the old fixed constant). |
||
|
|
a949b4503d |
feat(tutorial): repoint Ajustes "Ayuda y tutorial" to the carousel
Point the existing Info tile at PantallaTutorialAyuda (with primerArranque: false, so its last page reads "Close") instead of PluriOnboardingDialog's "what's new" modal. Trade-off: PluriOnboardingDialog loses its only manual entry point -- it keeps auto-showing on its own existing cadence from app.dart, but is no longer reachable by tapping this tile. This matches the mockup's Info screen, which has no separate "what's new" row. |
||
|
|
e297413145 |
feat(tutorial): wire tutorial carousel into the first-launch flow
Insert PantallaTutorialAyuda.mostrarSiProcede between the welcome screen and the recurring what's-new dialog in _mostrarFlujoPrimerLanzamiento, so the carousel shows once on every install -- fresh AND existing installs upgrading to this version -- via its own independent one-time flag, without racing either surface. |
||
|
|
015a20a823 |
feat(tutorial): add 9-screen help/tutorial carousel
Add PantallaTutorialAyuda, a PageView-based carousel covering saved
stations/groups, per-station equalizer, recording, adaptive alarms,
Android Auto favorites, auto-reconnect, snooze duration, custom
stations, and a closing summary with a "watch it again" reminder.
ServicioTutorialAyuda persists a one-time seen flag so the carousel
shows once via mostrarSiProcede, independent of entry point; the
final page's CTA label depends on the primerArranque constructor
parameter ("Empezar a escuchar" vs "Cerrar").
Translate the new copy into all 13 supported locales and update
helpSubtitle to describe the new entry point.
|
||
|
|
cfd8bc9e6a |
fix(auto): preserve phone-chosen station order in Android Auto folders
Android Auto's Favoritos/Todas/Mis emisoras folders always re-sorted by a hardcoded quality criterion in ConstructorArbolAuto.hijos/hijosGrupo, discarding whatever order the caller passed in. EstadoRadio now pushes already-ordered snapshots (listaFavoritosManual for Favoritos, and the ordenListas-sorted populares/emisorasCustom getters for Todas/Mis emisoras, re-pushed immediately on cambiarOrdenListas), and hijos/ hijosGrupo stop re-sorting so that order survives into the car. |
||
|
|
f2528c930b |
fix(alarmas): decode native failures with the real channel key names
The first pass read 'alarmaId'/'tipo' from the channel payload while the native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389). Every entry would have been dropped silently in production. The tests passed because the fake was seeded with the same guessed keys, so they confirmed the mistake instead of catching it. Decoding now goes through FalloProgramacionNativo.fromMap -- the single place native key names appear -- and the fixtures build through that same constructor. |
||
|
|
a8dca83cd9 |
feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and PluriWaveAlarmService record a pre-notice that could not be armed, a refused foreground-service start, and a per-alarm reschedule that failed after a reboot -- but nothing read them, so all three still ended at logcat. EstadoAlarmas now drains them at startup and turns each into a per-alarm exception, which the card already knows how to mark. An alarm that never reached the OS stops looking identical to one that did. The read is deliberately tolerant: a failure to read is logged and swallowed, never surfaced as an alarm error, so a diagnostics gap cannot masquerade as a scheduling problem. |
||
|
|
7722f204ca |
feat(alarmas): verify native registration after a successful save
android.programar() returning without throwing was treated as proof the OS registered the alarm -- this is exactly the gap the reported case fell through. guardarAlarma now cross-checks a fresh native pending-alarm count against how many alarms Dart believes are active-with-a-next-run right after a successful schedule call, and records a failure for the just-saved alarm when the native count falls short. FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a count derived from programar()/cancelar() calls (mirroring the real native scheduler's own registry) instead of a frozen 0, while any test that explicitly assigns the field keeps getting exactly that value regardless of what programar/cancelar do afterward -- verified against the full suite, no regressions. |
||
|
|
c107c0e18a |
feat(alarmas): surface scheduling failures on the alarm card
Wires EstadoAlarmas.ultimaExcepcionPara into PantallaAlarmas: an alarm with an outstanding scheduling-failure exception now shows a calm warning line (distinguishing a pre-notice-only failure from the alarm itself not being registered) with a tap target into the reliability diagnostics screen. The warning is its own small tap target nested inside the existing card InkWell, so tap-to-edit, swipe-to-delete and the hero "Saltar" chip are untouched. Adds alarmCardSchedulingFailedMessage/alarmCardPreNoticeFailedMessage to all 13 ARB locales with real per-language translations (verified against arb_parity_test and arb_anti_copy_test). |
||
|
|
fd1b91fe9e |
fix(alarmas): wire scheduling failures into per-alarm exceptions
guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record a scheduling failure via ServicioAlarmas.registrarFalloProgramacion on catch and clear it on a successful (re)schedule, in addition to the existing transient EstadoAlarmas.error string. This makes the failure visible per-alarm via ultimaExcepcionPara instead of only a generic app-wide message. Also fixes _sincronizarTodas: a single alarm's android.programar throw used to abort the whole loop, silently skipping every sibling alarm scheduled AFTER it on that pass (including on every app launch, via inicializar). Each alarm's outcome is now independent. |
||
|
|
47d0b8a053 |
feat(alarmas): record and clear per-alarm scheduling failures
Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion, persisting a scheduling-reliability failure through the same ExcepcionAlarma model saltarProxima already uses. Only one failure record is kept per alarm (latest attempt wins) and skipNext entries for any alarm are never touched. EstadoAlarmas wiring follows next. |
||
|
|
88bd251eba |
fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an occurrence, treating it as a user skip. Only the 'skipNext' tipo existed until now, but the next commits reuse the same model to record scheduling-reliability failures per alarm (so the alarms list can surface them via ultimaExcepcionPara) -- without this guard, a recorded failure would be silently treated as if the user asked to skip that occurrence, corrupting scheduling. Adds tipo constants to ExcepcionAlarma for the upcoming failure kinds. |
||
|
|
3f80291e78 |
feat(auto): equalizer folder in the browse tree, one toggle on playback
On-device feedback: two identical icons on the car's now-playing screen, one of which looked dead. It worked -- but head units render custom actions icon-first, so cycling six presets behind one static glyph was invisible. A monochrome icon cannot encode which of six presets is active. Android Auto separates the idioms deliberately: custom actions for stateless toggles, browsable lists for choosing among options. - Playback screen keeps one action: equalizer on/off, state-aware icons - New Ecualizador folder lists Desactivar plus the six presets by name, active one marked - The preset-cycling action and its drawable are removed Supersedes the redesign's no-equalizer-folder rule, which predated knowing custom actions do not surface state in a car. # Conflicts: # lib/l10n/app_ar.arb # lib/l10n/app_bn.arb # lib/l10n/app_de.arb # lib/l10n/app_en.arb # lib/l10n/app_es.arb # lib/l10n/app_fr.arb # lib/l10n/app_hi.arb # lib/l10n/app_id.arb # lib/l10n/app_it.arb # lib/l10n/app_ja.arb # lib/l10n/app_pt.arb # lib/l10n/app_ru.arb # lib/l10n/app_zh.arb |
||
|
|
f19666508d |
fix(auto): remove the preset-cycling custom action, superseded by folder
The equalizer's preset-cycling custom action (eq_preset_siguiente) and its ic_auto_eq_preset drawable are no longer needed now that the "Ecualizador" folder lists all six presets directly: the folder replaces what the cycle action did, and this frees a scarce Android Auto custom action slot. The on/off toggle is now the equalizer's only custom action. |
||
|
|
8423ccdd0c |
feat(auto): add an Ecualizador browsable folder with preset selection
On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.
This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.
Supersedes the earlier "no equalizer folder" rule (commit
|
||
|
|
049ab78acb |
feat(alarmas): replace one-line reliability button with full diagnostics screen
Surface all six DiagnosticoAlarmasAndroid fields instead of three: the battery-optimization exemption and native pending-alarm count were already collected but silently dropped by the old widget. Each failing signal now offers a "Fix this" action that opens the right system settings screen (exact alarms, notifications, full-screen intent, battery optimization), guarded by SDK level and never crashing when a ROM lacks that screen. Manufacturers known for aggressive background killing (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung) get an honest explanation that Autostart must be enabled manually, since there is no API to detect or grant it. Notifications now deep-links straight to ACTION_APP_NOTIFICATION_SETTINGS via a new openNotificationSettings native method, instead of reusing the runtime permission popup meant for first-time alarm creation. New copy is added to all 13 ARB locales with real per-language translations (not Spanish copies), verified by the ARB parity and anti-copy tests plus the corruption scanner. |