Files
pluriwave/lib/main.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

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

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

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

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

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

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

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

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

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

3. El paywall bloqueaba las compras

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

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:34:49 +02:00

410 lines
18 KiB
Dart

import 'dart:async';
import 'dart:developer' as developer;
import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
/// Branded monochrome status-bar icon, replacing the default full-color
/// launcher silhouette fallback.
const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
/// S5-R8: media notification accent uses the brand color, not the M3
/// default purple. Top-level const so tests can assert it.
const configuracionAudioService = AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
// Paired with `androidStopForegroundOnPause: false` below, and required to
// be: the plugin asserts `androidNotificationOngoing` implies
// `androidStopForegroundOnPause`. Nothing is lost by turning it off —
// while the service is in the foreground the OS forces the notification to
// be ongoing anyway, which is now the whole time playback is alive.
androidNotificationOngoing: false,
// The service stays in the FOREGROUND while paused.
//
// With `true`, a pause called `stopForeground(...)`, and a service that is
// not in the foreground is a service Android may kill at will. In the car
// that is exactly what happened: an interruption paused playback, the
// service dropped out of the foreground, Android reclaimed it, and
// PluriWave disappeared from the Android Auto pane — another media app
// took the slot. Ducking (see `ServicioAudioSession.configurar`) removes
// most pauses, but a real pause must not be a death sentence either.
//
// The plugin's own doc for this flag says it outright: «while in this
// lower priority state, the operating system will also be able to kill
// your service at any time to reclaim resources».
//
// Cost of `false`: the notification is not swipe-dismissible while paused,
// only after Stop. That is how every serious media app behaves, and Stop
// still tears everything down.
androidStopForegroundOnPause: false,
notificationColor: PluriWaveTokens.brand,
androidNotificationIcon: androidNotificationIconResource,
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Android Auto browse source: registered FIRST, before any await at all.
// It depends on nothing, and everything below it is a potential place to
// get stuck — so nothing may sit between engine start and this line.
//
// Reported: with Android Auto connected, the car screen sometimes came up
// black and the app then opened WHITE on the phone until it was
// force-killed. `AudioServiceActivity.provideFlutterEngine` returns the
// engine from `AudioServicePlugin.getFlutterEngine`, which CREATES the
// engine and runs `main()` headlessly the first time — with no Activity —
// when the car binds the MediaBrowserService before the app is opened.
// `_aplicarPoliticaOrientacion` used to be the first `await` here, and
// `SystemChrome.setPreferredOrientations` travels the `flutter/platform`
// channel, whose handler (`PlatformPlugin`) is installed by the Activity.
// Headless there is nobody to answer it, so `main()` died or hung on line
// one: the browse source below was never registered (`getChildren` had no
// source -> black car screen) and `runApp` was never reached. Opening the
// app then REUSED that same cached, already-dead engine -> white screen,
// and only a force-kill (which disposes the engine) recovered it.
final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto);
// Local music registers HERE, above every await, alongside the station
// source — not after `SharedPreferences.getInstance()` where it used to
// sit.
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `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
// root, so it stayed missing for the whole session. Before the reorder
// both registrations sat together after the await, so the window did not
// exist.
//
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
// resolves them lazily per call (`_resolverPrefs`, falling back to
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
// was never a reason for it to wait on that await.
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// 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());
// iap-freemium-unlock: neither SDK init call blocks `runApp` — a purchase
// stream subscription and an ad-SDK warm-up are both safe to finish late
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
// rule immediately above).
//
// FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the
// GDPR/UMP consent flow resolves that ads may actually be requested
// (`ConsentInformation.canRequestAds()`) — serving personalized ads to
// EEA/UK users with no CMP violates Google's EU User Consent Policy.
// Premium users never even reach the consent form (`resolverConsentimientoAnuncios`
// short-circuits for them — they get zero ads regardless). This whole
// chain is deliberately `unawaited`: consent/ads are exactly as
// "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above,
// and any failure inside it degrades to "no ads", never a crash or a
// blocked UI.
unawaited(
esPremiumPersistido()
.then(
(premium) => resolverConsentimientoAnuncios(
esPremium: premium,
consentimiento: ServicioConsentimientoUmp(),
),
)
.then((puedeSolicitarAnuncios) async {
if (puedeSolicitarAnuncios) {
await MobileAds.instance.initialize();
}
}),
);
final compras = ServicioComprasPlayBilling();
// S3-R4: single SharedPreferences instance resolved once at startup and
// injected into every state/service below.
final prefs = await SharedPreferences.getInstance();
// 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
// read function, not the service, so the folder re-reads on every browse:
// a preset saved on the phone appears in the car without an app restart.
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
// internally was discarded without a trace, which is exactly why the
// "media notification disappeared" report came with no evidence attached.
// Subscribed BEFORE `AudioService.init` below (the getter only touches a
// static subject, so it needs no initialisation) so nothing reported
// during the MediaBrowser handshake is missed, and placed here rather than
// in `conectarHandler` so ONE subscription covers both the on-time and the
// degraded/timeout startup paths.
final subErroresAudio = observarErroresAudio(
AudioService.asyncError,
registrar: registrarErrorAudioService,
);
// Design "Timeout without re-init": AudioService.init is started exactly
// ONCE here and `handlerFuturo` is the only future ever awaited for it —
// the plugin caches state internally, so a double-configure call is
// unsafe and must never happen, even on the degraded/timeout path below.
final handlerFuturo = AudioService.init(
builder: () => PluriWaveAudioHandler(),
config: configuracionAudioService,
);
// S3-R1: audio focus — phone calls / transient losses pause or duck the
// radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler 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
// as `registrarHandler` itself.
registrarLimpiezaArranque(subErroresAudio.cancel);
final sesionAudio = ServicioAudioSession(objetivo: handler);
unawaited(sesionAudio.configurar());
}
Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
final resultado = await esperarArranqueAudio(handlerFuturo);
switch (resultado) {
case ArranqueAudioListo<PluriWaveAudioHandler>(:final handler):
// Handshake finished in time — exactly today's startup path.
conectarHandler(handler);
runApp(construirApp());
case ArranqueAudioPendiente<PluriWaveAudioHandler>(
handlerFuturo: final futuroPendiente,
):
// Handshake still hung after the timeout: run the app anyway with a
// minimal loading bootstrap that keeps awaiting the SAME
// `futuroPendiente` — identical to the outer `handlerFuturo`, per
// esperarArranqueAudio's contract (never a second AudioService.init
// call) — and wires the handler + swaps to the real app once it
// eventually resolves.
runApp(
ArranqueAudioApp<PluriWaveAudioHandler>(
handlerFuturo: futuroPendiente,
alListo: conectarHandler,
construirApp: (_) => construirApp(),
),
);
}
}
/// Which orientations a display [anchoLogico] dp wide may use: phones stay
/// portrait, tablets get everything. Pure, so the policy is testable without
/// a platform channel.
@visibleForTesting
List<DeviceOrientation> orientacionesPara(double anchoLogico) =>
anchoLogico < _anchoMinimoLandscape
? const [DeviceOrientation.portraitUp]
: DeviceOrientation.values;
/// Applies [orientacionesPara] to the active display.
///
/// NEVER throws and never blocks a caller that matters. This runs on the
/// headless engine Android Auto starts (see `main`), where the
/// `flutter/platform` channel has no handler because there is no Activity to
/// install `PlatformPlugin` — so the call can fail with a
/// `MissingPluginException` or simply never be answered. Before this guard
/// that outcome killed `main()` outright, taking the Android Auto browse
/// registration and `runApp` with it.
///
/// [aplicar] is injectable so the swallow-everything contract is testable
/// without a real platform channel.
@visibleForTesting
Future<void> aplicarPoliticaOrientacion({
ui.Display? display,
Future<void> Function(List<DeviceOrientation>)? aplicar,
}) async {
try {
final vista =
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
? WidgetsBinding.instance.platformDispatcher.views.first
: null;
final displayActivo = display ?? vista?.display;
if (displayActivo == null) return;
final anchoLogico =
displayActivo.size.width / displayActivo.devicePixelRatio;
await (aplicar ?? SystemChrome.setPreferredOrientations)(
orientacionesPara(anchoLogico),
);
} catch (e) {
// Deliberately broad: a cosmetic preference is never worth a failed
// startup, and headless is exactly where this fails.
developer.log(
'[PluriWave] no se pudo aplicar la política de orientación: $e',
name: 'Arranque',
level: 900,
);
}
}
/// 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() =>
_OrientacionResponsiveAppState();
}
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();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_display = View.maybeOf(context)?.display;
unawaited(aplicarPoliticaOrientacion(display: _display));
}
/// `resumed` es lo único que significa de verdad «ya hay Activity
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
/// resolver la música local; Android Auto no vuelve a preguntar por su
/// cuenta, así que se lo decimos aquí. Ver
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!debeInvalidarArbolAutoAlReanudar(
estado: state,
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
yaInvalidado: _arbolAutoInvalidado,
)) {
return;
}
_arbolAutoInvalidado = true;
invalidarArbolAuto();
}
@override
void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display));
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}