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.
This commit is contained in:
2026-08-31 14:34:49 +02:00
parent 10bb017f4c
commit 3449e2cb79
34 changed files with 2948 additions and 473 deletions
+106 -8
View File
@@ -16,6 +16,7 @@ import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
@@ -88,7 +89,7 @@ Future<void> main() async {
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
@@ -103,7 +104,7 @@ Future<void> main() async {
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway.
unawaited(aplicarPoliticaOrientacion());
@@ -151,6 +152,18 @@ Future<void> main() async {
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
// eq-estado-unico items A/B: the handler's own link to the equalizer's
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
// `prefs` instance resolved just above — no widget tree, no Provider — so
// it is available on EVERY engine, including the headless one Android Auto
// starts. Before this, the persisted value only reached the handler
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
// runs: the handler played with the equalizer forced on while disk and the
// phone UI both said off, and a toggle made in the car was lost on
// restart. Passed as two narrow function ports, mirroring the
// read-function convention used for the preset folder right above.
final ecualizador = ServicioEcualizador(prefs: prefs);
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches
@@ -179,7 +192,11 @@ Future<void> main() async {
// radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) {
registrarHandler(handler);
registrarHandler(
handler,
leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention
@@ -189,7 +206,7 @@ Future<void> main() async {
unawaited(sesionAudio.configurar());
}
Widget construirApp() => _OrientacionResponsiveApp(
Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
@@ -268,20 +285,80 @@ Future<void> aplicarPoliticaOrientacion({
}
}
class _OrientacionResponsiveApp extends StatefulWidget {
const _OrientacionResponsiveApp({required this.child});
/// Whether the Android Auto browse tree must be invalidated right now
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
///
/// The trigger used to be `View.maybeOf(context) != null` inside
/// `didChangeDependencies`, latched once, on the premise that «a View means
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
/// wraps the tree in a `View` built from
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
/// on the headless `audio_service` engine — which demonstrably reaches
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
/// the first `didChangeDependencies`. The one-shot latch was spent at the
/// exact moment it could accomplish nothing (`_childrenSubjects` still
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
/// fire again, because `didChangeDependencies` does not re-run when an
/// Activity later attaches to that same cached engine.
///
/// Two conditions replace it, both required:
///
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
/// means «an Activity is attached and in the foreground». It reaches Dart
/// exclusively through `SystemChannels.lifecycle` (or
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
/// it, driven by the Activity's own `onResume`.
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
/// APPLICATION context and runs the Dart entrypoint immediately, with no
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
/// it on the headless engine.
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
/// worth spending, and it is also the belt to `resumed`'s braces: even if
/// a lifecycle event did somehow arrive during a headless cold start,
/// nothing has subscribed yet, so the latch survives for the moment an
/// Activity really does attach.
///
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
/// not send twenty `notifyChildrenChanged` storms to the car.
///
/// Pure, so the whole policy is testable without an engine.
@visibleForTesting
bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
///
/// Public only so a test can mount it and drive real lifecycle events
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
/// trigger shipped broken precisely because nothing could reach it.
@visibleForTesting
class OrientacionResponsiveApp extends StatefulWidget {
const OrientacionResponsiveApp({super.key, required this.child});
final Widget child;
@override
State<_OrientacionResponsiveApp> createState() =>
State<OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState();
}
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
with WidgetsBindingObserver {
ui.Display? _display;
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
/// coche se dispara UNA sola vez. Ver
/// [debeInvalidarArbolAutoAlReanudar].
bool _arbolAutoInvalidado = false;
@override
void initState() {
super.initState();
@@ -295,6 +372,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
unawaited(aplicarPoliticaOrientacion(display: _display));
}
/// `resumed` es lo único que significa de verdad «ya hay Activity
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
/// resolver la música local; Android Auto no vuelve a preguntar por su
/// cuenta, así que se lo decimos aquí. Ver
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!debeInvalidarArbolAutoAlReanudar(
estado: state,
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
yaInvalidado: _arbolAutoInvalidado,
)) {
return;
}
_arbolAutoInvalidado = true;
invalidarArbolAuto();
}
@override
void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display));