Files
pluriwave/lib/main.dart
T
FreeTLab 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.
2026-08-06 17:18:53 +02:00

242 lines
10 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:shared_preferences/shared_preferences.dart';
import 'app.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_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',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
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);
// 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());
// S3-R4: single SharedPreferences instance resolved once at startup and
// injected into every state/service below.
final prefs = await SharedPreferences.getInstance();
// Local-music browse source (Design "getChildren data source
// registration"), same injectable-prefs DI convention as every other
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
// non-null; without this registration the local-music root would stay
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
// User-saved EQ presets for the car's Ecualizador folder, same
// injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a
// 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);
// 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);
// 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),
);
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,
);
}
}
class _OrientacionResponsiveApp extends StatefulWidget {
const _OrientacionResponsiveApp({required this.child});
final Widget child;
@override
State<_OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState();
}
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
with WidgetsBindingObserver {
ui.Display? _display;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_display = View.maybeOf(context)?.display;
unawaited(aplicarPoliticaOrientacion(display: _display));
}
@override
void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display));
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}