fix(audio): survive audio_service init hang on Android Auto cold start
AudioService.init has no internal timeout and an unhandled onConnectionSuspended case in its MediaBrowser self-bind; under bind contention with the car's connection it can hang forever, so runApp never ran (black car screen, white phone UI until process kill). Race init against an 8s timeout without ever re-calling it: on timeout run a bootstrap app that waits on the same future, wires the handler exactly once when it resolves, reports errors via FlutterError, and swaps to the real app. Auto browse sources now register before the init await since they take no handler dependency.
This commit is contained in:
+51
-18
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -37,16 +38,13 @@ Future<void> main() async {
|
||||
// injected into every state/service below.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final handler = await AudioService.init(
|
||||
builder: () => PluriWaveAudioHandler(),
|
||||
config: configuracionAudioService,
|
||||
);
|
||||
registrarHandler(handler);
|
||||
|
||||
// Android Auto browse source (Design "getChildren data source, cold-start
|
||||
// safe") — registered before EstadoRadio builds so a headless Auto bind
|
||||
// (main() runs but the lazily-created Provider tree may never build) can
|
||||
// still serve favourites/custom stations from local reads.
|
||||
// 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);
|
||||
|
||||
@@ -57,16 +55,51 @@ Future<void> main() async {
|
||||
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
||||
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
|
||||
|
||||
// S3-R1: audio focus — phone calls / transient losses pause or duck the
|
||||
// radio; headphones unplugged pauses it.
|
||||
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
||||
unawaited(sesionAudio.configurar());
|
||||
|
||||
runApp(
|
||||
_OrientacionResponsiveApp(
|
||||
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto),
|
||||
),
|
||||
// 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);
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
|
||||
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
|
||||
/// "Timeout without re-init"): the vendored `audio_service` plugin's
|
||||
/// self-bind has no native timeout and an unhandled `onConnectionSuspended`
|
||||
/// case, so under bind contention (Android Auto cold start) the handshake
|
||||
/// can hang forever. Top-level const so tests can reference the production
|
||||
/// value without duplicating it.
|
||||
const timeoutArranqueAudio = Duration(seconds: 8);
|
||||
|
||||
/// Outcome of racing an `AudioService.init` future against
|
||||
/// [timeoutArranqueAudio] (Design "Timeout without re-init"). Sealed so
|
||||
/// callers exhaustively handle both branches.
|
||||
sealed class ResultadoArranqueAudio<T> {
|
||||
const ResultadoArranqueAudio();
|
||||
}
|
||||
|
||||
/// The handler future completed within the timeout — normal startup path.
|
||||
class ArranqueAudioListo<T> extends ResultadoArranqueAudio<T> {
|
||||
const ArranqueAudioListo(this.handler);
|
||||
|
||||
final T handler;
|
||||
}
|
||||
|
||||
/// The handler future did NOT complete within the timeout. [handlerFuturo]
|
||||
/// is the SAME original future passed to [esperarArranqueAudio] — the
|
||||
/// caller must keep awaiting it (e.g. via [ArranqueAudioApp]), never start a
|
||||
/// second `AudioService.init` call (Design "init must never be called
|
||||
/// twice").
|
||||
class ArranqueAudioPendiente<T> extends ResultadoArranqueAudio<T> {
|
||||
const ArranqueAudioPendiente(this.handlerFuturo);
|
||||
|
||||
final Future<T> handlerFuturo;
|
||||
}
|
||||
|
||||
/// Races the already-started [handlerFuturo] — a single `AudioService.init`
|
||||
/// call — against [timeout] (Design "Timeout without re-init"). Returns
|
||||
/// [ArranqueAudioListo] when [handlerFuturo] resolves in time, otherwise
|
||||
/// [ArranqueAudioPendiente] wrapping the SAME [handlerFuturo] so it can keep
|
||||
/// being awaited without ever re-invoking `AudioService.init`.
|
||||
///
|
||||
/// [handlerFuturo] and [timeout] are both injected — this function never
|
||||
/// touches the real `audio_service` plugin, so it is testable with a plain
|
||||
/// [Future] and short durations.
|
||||
Future<ResultadoArranqueAudio<T>> esperarArranqueAudio<T>(
|
||||
Future<T> handlerFuturo, {
|
||||
Duration timeout = timeoutArranqueAudio,
|
||||
}) async {
|
||||
try {
|
||||
final handler = await handlerFuturo.timeout(timeout);
|
||||
return ArranqueAudioListo<T>(handler);
|
||||
} on TimeoutException {
|
||||
return ArranqueAudioPendiente<T>(handlerFuturo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal branded bootstrap widget for the degraded path (Design "still
|
||||
/// call runApp, but with a minimal bootstrap widget that keeps waiting on
|
||||
/// the SAME original future"). Shows [_CargandoArranqueAudio] while
|
||||
/// [handlerFuturo] is pending; once it settles, swaps to [construirApp]'s
|
||||
/// result. On success, [alListo] is called exactly once (handler wiring:
|
||||
/// `registrarHandler` + `ServicioAudioSession`) before the swap. On an
|
||||
/// error, [alListo] is never called — the app shell still renders (an
|
||||
/// infinite spinner is strictly worse) and the error is reported via
|
||||
/// `FlutterError.reportError` instead of becoming an unhandled exception.
|
||||
class ArranqueAudioApp<T> extends StatefulWidget {
|
||||
const ArranqueAudioApp({
|
||||
super.key,
|
||||
required this.handlerFuturo,
|
||||
required this.alListo,
|
||||
required this.construirApp,
|
||||
});
|
||||
|
||||
/// The SAME future returned by the original `AudioService.init` call —
|
||||
/// never a new one.
|
||||
final Future<T> handlerFuturo;
|
||||
|
||||
/// Handler-wiring callback (Design "same handler wiring" as the
|
||||
/// on-time path). Invoked exactly once, when [handlerFuturo] resolves.
|
||||
final void Function(T handler) alListo;
|
||||
|
||||
/// Builds the real app widget once [handlerFuturo] has settled — with the
|
||||
/// resolved handler on success, or `null` if [handlerFuturo] completed
|
||||
/// with an error (Design "error path": an infinite spinner is strictly
|
||||
/// worse than an app shell without the handler wired).
|
||||
final Widget Function(T? handler) construirApp;
|
||||
|
||||
@override
|
||||
State<ArranqueAudioApp<T>> createState() => _ArranqueAudioAppState<T>();
|
||||
}
|
||||
|
||||
class _ArranqueAudioAppState<T> extends State<ArranqueAudioApp<T>> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Attached once in initState (not in build) so alListo runs exactly
|
||||
// once regardless of how many times FutureBuilder rebuilds below.
|
||||
unawaited(
|
||||
widget.handlerFuturo.then(
|
||||
widget.alListo,
|
||||
// Design "error path": a post-timeout handler failure must never
|
||||
// become an unhandled async exception. alListo is deliberately NOT
|
||||
// called here — it only wires a real handler; build() below still
|
||||
// swaps away from the spinner via `connectionState == done`
|
||||
// regardless of hasError, so the app shell renders either way.
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(
|
||||
exception: error,
|
||||
stack: stackTrace,
|
||||
library: 'arranque_audio',
|
||||
context: ErrorDescription(
|
||||
'esperando handlerFuturo tras el timeout de arranque de audio',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<T>(
|
||||
future: widget.handlerFuturo,
|
||||
builder: (context, snapshot) {
|
||||
// `done` covers BOTH hasData and hasError — an errored handshake
|
||||
// still swaps to the app shell instead of spinning forever (Design
|
||||
// "error path").
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
return widget.construirApp(snapshot.data);
|
||||
}
|
||||
return const _CargandoArranqueAudio();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Standalone loading screen shown while [ArranqueAudioApp] waits (Design
|
||||
/// "minimal branded loading view"): centered `CircularProgressIndicator`,
|
||||
/// no text — `AppLocalizations` is not available at this point in startup.
|
||||
/// Self-contained (its own `MaterialApp`) since this can be the direct
|
||||
/// `runApp()` root.
|
||||
class _CargandoArranqueAudio extends StatelessWidget {
|
||||
const _CargandoArranqueAudio();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
backgroundColor: PluriWaveTokens.dark.deepViolet,
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(color: PluriWaveTokens.brand),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/arranque_audio.dart';
|
||||
|
||||
/// S8-android-auto-cold-start: covers [esperarArranqueAudio]'s timeout race
|
||||
/// in isolation, without touching the real `audio_service` plugin (Design
|
||||
/// "Testability" — the init future is injected).
|
||||
void main() {
|
||||
group('esperarArranqueAudio', () {
|
||||
test(
|
||||
'handler resuelto antes del timeout devuelve ArranqueAudioListo',
|
||||
() async {
|
||||
final resultado = await esperarArranqueAudio<String>(
|
||||
Future.value('handler-ok'),
|
||||
timeout: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
expect(resultado, isA<ArranqueAudioListo<String>>());
|
||||
expect((resultado as ArranqueAudioListo<String>).handler, 'handler-ok');
|
||||
},
|
||||
);
|
||||
|
||||
test('handler colgado mas alla del timeout devuelve ArranqueAudioPendiente '
|
||||
'con el MISMO future original', () async {
|
||||
final completer = Completer<String>();
|
||||
|
||||
final resultado = await esperarArranqueAudio<String>(
|
||||
completer.future,
|
||||
timeout: const Duration(milliseconds: 20),
|
||||
);
|
||||
|
||||
expect(resultado, isA<ArranqueAudioPendiente<String>>());
|
||||
expect(
|
||||
identical(
|
||||
(resultado as ArranqueAudioPendiente<String>).handlerFuturo,
|
||||
completer.future,
|
||||
),
|
||||
isTrue,
|
||||
reason:
|
||||
'init nunca debe llamarse dos veces — el future pendiente '
|
||||
'debe ser exactamente el mismo objeto',
|
||||
);
|
||||
|
||||
// El completer nunca se completó — liberarlo evita un future
|
||||
// colgado tras el test.
|
||||
completer.complete('handler-tardio');
|
||||
});
|
||||
|
||||
test('finalizacion tardia tras el timeout resuelve el future original con '
|
||||
'el handler real', () async {
|
||||
final completer = Completer<String>();
|
||||
|
||||
final resultado = await esperarArranqueAudio<String>(
|
||||
completer.future,
|
||||
timeout: const Duration(milliseconds: 20),
|
||||
);
|
||||
final pendiente = resultado as ArranqueAudioPendiente<String>;
|
||||
|
||||
completer.complete('handler-tardio');
|
||||
final handler = await pendiente.handlerFuturo;
|
||||
|
||||
expect(handler, 'handler-tardio');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/arranque_audio.dart';
|
||||
|
||||
/// S8-android-auto-cold-start: covers [ArranqueAudioApp]'s degraded-path
|
||||
/// bootstrap widget — loading state while pending, swap to the real app on
|
||||
/// completion, and the "wire exactly once" guarantee.
|
||||
void main() {
|
||||
Widget construirArranque({
|
||||
required Future<String> handlerFuturo,
|
||||
required void Function(String handler) alListo,
|
||||
}) {
|
||||
return ArranqueAudioApp<String>(
|
||||
handlerFuturo: handlerFuturo,
|
||||
alListo: alListo,
|
||||
// Envuelto en su propio MaterialApp — como el `construirApp` real en
|
||||
// main.dart, cuyo resultado (PluriWaveApp) provee su propio
|
||||
// MaterialApp/Directionality; ArranqueAudioApp nunca lo hace por su
|
||||
// cuenta en la rama "lista".
|
||||
construirApp: (handler) => MaterialApp(home: Text('app-lista:$handler')),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'muestra un indicador de carga mientras el future esta pendiente',
|
||||
(tester) async {
|
||||
final completer = Completer<String>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
construirArranque(handlerFuturo: completer.future, alListo: (_) {}),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.textContaining('app-lista'), findsNothing);
|
||||
|
||||
// Libera el future pendiente para no dejarlo colgado tras el test.
|
||||
completer.complete('handler-x');
|
||||
await tester.pump();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('cambia a la app real cuando el future se completa', (
|
||||
tester,
|
||||
) async {
|
||||
final completer = Completer<String>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
construirArranque(handlerFuturo: completer.future, alListo: (_) {}),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
completer.complete('handler-y');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('app-lista:handler-y'), findsOneWidget);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'alListo se ejecuta exactamente una vez aunque el widget se reconstruya',
|
||||
(tester) async {
|
||||
final completer = Completer<String>();
|
||||
final llamadas = <String>[];
|
||||
|
||||
Widget construir() => construirArranque(
|
||||
handlerFuturo: completer.future,
|
||||
alListo: llamadas.add,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(construir());
|
||||
completer.complete('handler-z');
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Reconstrucciones adicionales del mismo widget (misma posicion, sin
|
||||
// Key) preservan el State — initState no vuelve a ejecutarse — pero
|
||||
// esto prueba que alListo tampoco se re-dispara desde build().
|
||||
await tester.pumpWidget(construir());
|
||||
await tester.pump();
|
||||
await tester.pumpWidget(construir());
|
||||
await tester.pump();
|
||||
|
||||
expect(llamadas, ['handler-z']);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'el future falla tras el timeout: sin excepcion no manejada, la app '
|
||||
'reemplaza al spinner y alListo nunca se llama',
|
||||
(tester) async {
|
||||
final completer = Completer<String>();
|
||||
final llamadas = <String>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
construirArranque(
|
||||
handlerFuturo: completer.future,
|
||||
alListo: llamadas.add,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
completer.completeError(Exception('handshake fallido'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// El error se REPORTA via FlutterError.reportError (por eso
|
||||
// tester.takeException() lo captura) en vez de quedar como una
|
||||
// excepcion async no manejada que tumbe el binding de test/la app.
|
||||
// Consumirlo aqui es la forma de probar "reportado, no crasheado".
|
||||
final excepcionReportada = tester.takeException();
|
||||
expect(excepcionReportada, isException);
|
||||
expect(excepcionReportada.toString(), contains('handshake fallido'));
|
||||
// El spinner infinito es estrictamente peor que un shell sin handler
|
||||
// conectado — se reemplaza igual, sin texto.
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.textContaining('app-lista'), findsOneWidget);
|
||||
// alListo es solo para el camino exitoso; un future fallido nunca lo
|
||||
// dispara.
|
||||
expect(llamadas, isEmpty);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user