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 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(:final handler): // Handshake finished in time — exactly today's startup path. conectarHandler(handler); runApp(construirApp()); case ArranqueAudioPendiente( 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( 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 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 aplicarPoliticaOrientacion({ ui.Display? display, Future Function(List)? 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 createState() => _OrientacionResponsiveAppState(); } class _OrientacionResponsiveAppState extends State 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; }