Compare commits
2
Commits
809b4c6eb4
...
1d8c9c57bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8c9c57bc | ||
|
|
c1afe72aec |
+76
-26
@@ -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<void> 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<void> main() async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<DeviceOrientation> 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<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,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<void>().future,
|
||||
).then((_) => termino = true);
|
||||
|
||||
await Future<void>.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<DeviceOrientation> aplicadas;
|
||||
return aplicarPoliticaOrientacion(
|
||||
aplicar: (o) async => aplicadas = o,
|
||||
).then((_) {
|
||||
expect(aplicadas, isNotEmpty);
|
||||
expect(aplicadas, orientacionesPara(800 / 1));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user