fix(audio): survive audio_service init hang on Android Auto cold start
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s

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:
2026-07-25 13:43:40 +02:00
parent 37dee8cb5a
commit d0abe32eef
4 changed files with 408 additions and 18 deletions
+163
View File
@@ -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),
),
),
);
}
}