Compare commits
2
Commits
809b4c6eb4
...
1d8c9c57bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8c9c57bc | ||
|
|
c1afe72aec |
+76
-26
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
@@ -33,22 +34,38 @@ const configuracionAudioService = AudioServiceConfig(
|
|||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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
|
// S3-R4: single SharedPreferences instance resolved once at startup and
|
||||||
// injected into every state/service below.
|
// injected into every state/service below.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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
|
// Local-music browse source (Design "getChildren data source
|
||||||
// registration"), same injectable-prefs DI convention as every other
|
// registration"), same injectable-prefs DI convention as every other
|
||||||
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
|
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
|
||||||
@@ -131,21 +148,54 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
|
/// Which orientations a display [anchoLogico] dp wide may use: phones stay
|
||||||
final vista =
|
/// portrait, tablets get everything. Pure, so the policy is testable without
|
||||||
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
|
/// a platform channel.
|
||||||
? WidgetsBinding.instance.platformDispatcher.views.first
|
@visibleForTesting
|
||||||
: null;
|
List<DeviceOrientation> orientacionesPara(double anchoLogico) =>
|
||||||
final displayActivo = display ?? vista?.display;
|
anchoLogico < _anchoMinimoLandscape
|
||||||
if (displayActivo == null) return;
|
? const [DeviceOrientation.portraitUp]
|
||||||
|
: DeviceOrientation.values;
|
||||||
|
|
||||||
final anchoLogico = displayActivo.size.width / displayActivo.devicePixelRatio;
|
/// Applies [orientacionesPara] to the active display.
|
||||||
if (anchoLogico < _anchoMinimoLandscape) {
|
///
|
||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
/// NEVER throws and never blocks a caller that matters. This runs on the
|
||||||
return;
|
/// 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 {
|
class _OrientacionResponsiveApp extends StatefulWidget {
|
||||||
@@ -172,12 +222,12 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
|
|||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
_display = View.maybeOf(context)?.display;
|
_display = View.maybeOf(context)?.display;
|
||||||
unawaited(_aplicarPoliticaOrientacion(_display));
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeMetrics() {
|
void didChangeMetrics() {
|
||||||
unawaited(_aplicarPoliticaOrientacion(_display));
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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