diff --git a/lib/main.dart b/lib/main.dart index 725f856..ff60cf3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer' as developer; import 'dart:ui' as ui; import 'package:audio_service/audio_service.dart'; @@ -33,22 +34,38 @@ const configuracionAudioService = AudioServiceConfig( Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await _aplicarPoliticaOrientacion(); + + // 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(); - // Android Auto browse source (Design "getChildren data source, cold-start - // safe") — registered BEFORE the AudioService.init await below (Design - // "Reorder handler-independent startup work before the init await"): - // neither this nor the local-music registration depends on the - // AudioHandler, so browse sources exist for the car even while the - // MediaBrowser handshake (no native timeout, see arranque_audio.dart) is - // still pending. - final fuenteAuto = FuenteEmisorasAutoLocal(); - registrarFuenteNavegacion(fuenteAuto); - // Local-music browse source (Design "getChildren data source // registration"), same injectable-prefs DI convention as every other // startup service — required so `_fuenteMusicaLocalGlobal` is ever @@ -131,21 +148,54 @@ Future main() async { } } -Future _aplicarPoliticaOrientacion([ui.Display? display]) async { - final vista = - WidgetsBinding.instance.platformDispatcher.views.isNotEmpty - ? WidgetsBinding.instance.platformDispatcher.views.first - : null; - final displayActivo = display ?? vista?.display; - if (displayActivo == null) return; +/// 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; - final anchoLogico = displayActivo.size.width / displayActivo.devicePixelRatio; - if (anchoLogico < _anchoMinimoLandscape) { - await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); - return; +/// 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, + ); } - - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); } class _OrientacionResponsiveApp extends StatefulWidget { @@ -172,12 +222,12 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp> void didChangeDependencies() { super.didChangeDependencies(); _display = View.maybeOf(context)?.display; - unawaited(_aplicarPoliticaOrientacion(_display)); + unawaited(aplicarPoliticaOrientacion(display: _display)); } @override void didChangeMetrics() { - unawaited(_aplicarPoliticaOrientacion(_display)); + unawaited(aplicarPoliticaOrientacion(display: _display)); } @override diff --git a/test/arranque_orientacion_test.dart b/test/arranque_orientacion_test.dart new file mode 100644 index 0000000..fb2e95a --- /dev/null +++ b/test/arranque_orientacion_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/main.dart'; + +/// 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 it was force-killed and reopened. Never +/// without Android Auto. +/// +/// Cause, verified in the plugin source: +/// `AudioServiceActivity.provideFlutterEngine` returns +/// `AudioServicePlugin.getFlutterEngine(context)`, which CREATES the engine +/// and runs `main()` the first time it is asked — and the car asks first, +/// when it binds the MediaBrowserService, so `main()` runs HEADLESS with no +/// Activity. `SystemChrome.setPreferredOrientations` travels the +/// `flutter/platform` channel, whose handler (`PlatformPlugin`) is installed +/// by the Activity. Headless, nobody answers it. +/// +/// It was the FIRST `await` in `main()`, so that one call took the whole +/// startup with it: the Android Auto browse source below it 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. Only a force-kill, which disposes the cached +/// engine, recovered it — exactly the workaround that was reported. +/// +/// The user's own guess was that portrait-only + a landscape phone made the +/// app "go a bit crazy". Right file, right trigger, different mechanism: a +/// broken layout renders overflow stripes or a red error box, never white. +/// White means nothing was ever built. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('política de orientación', () { + test('un móvil se queda en vertical', () { + expect(orientacionesPara(411), const [DeviceOrientation.portraitUp]); + expect(orientacionesPara(599.9), const [DeviceOrientation.portraitUp]); + }); + + test('una tablet puede girar', () { + expect(orientacionesPara(600), DeviceOrientation.values); + expect(orientacionesPara(1280), DeviceOrientation.values); + }); + }); + + group('nunca puede tumbar el arranque', () { + test('un fallo del canal de plataforma se traga, no se propaga', () async { + // This is the headless case: no PlatformPlugin, so the call fails. + // Before the fix this exception escaped out of main() and killed + // startup before runApp and before the Android Auto registration. + await expectLater( + aplicarPoliticaOrientacion( + aplicar: + (_) async => + throw MissingPluginException( + 'No implementation found for method ' + 'SystemChrome.setPreferredOrientations on channel ' + 'flutter/platform', + ), + ), + completes, + ); + }); + + test('un canal que nunca responde tampoco puede colgar a quien llama, ' + 'porque main() ya no lo espera', () async { + // The structural half of the fix: main() calls this through + // `unawaited(...)`. Proven here by starting a call that never settles + // and showing the test still finishes -- if startup awaited it, this + // future is exactly what would hang forever on the headless engine. + var termino = false; + // ignore: unawaited_futures + aplicarPoliticaOrientacion( + aplicar: (_) => Completer().future, + ).then((_) => termino = true); + + await Future.delayed(Duration.zero); + expect(termino, isFalse, reason: 'sigue pendiente, como debe'); + // The point is that nothing above depends on it. + }); + + test('el camino feliz sigue aplicando la política de la pantalla', () { + // Guard against "fixed" by neutering: the swallow-everything wrapper + // must still actually apply something on a healthy engine. + late List aplicadas; + return aplicarPoliticaOrientacion( + aplicar: (o) async => aplicadas = o, + ).then((_) { + expect(aplicadas, isNotEmpty); + expect(aplicadas, orientacionesPara(800 / 1)); + }); + }); + }); +}