diff --git a/lib/main.dart b/lib/main.dart index cf76597..bda6676 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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 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(:final handler): + // Handshake finished in time — exactly today's startup path. + conectarHandler(handler); + runApp(construirApp()); + case ArranqueAudioPendiente( + 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( + handlerFuturo: futuroPendiente, + alListo: conectarHandler, + construirApp: (_) => construirApp(), + ), + ); + } } Future _aplicarPoliticaOrientacion([ui.Display? display]) async { diff --git a/lib/servicios/arranque_audio.dart b/lib/servicios/arranque_audio.dart new file mode 100644 index 0000000..6ed7fc3 --- /dev/null +++ b/lib/servicios/arranque_audio.dart @@ -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 { + const ResultadoArranqueAudio(); +} + +/// The handler future completed within the timeout — normal startup path. +class ArranqueAudioListo extends ResultadoArranqueAudio { + 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 extends ResultadoArranqueAudio { + const ArranqueAudioPendiente(this.handlerFuturo); + + final Future 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> esperarArranqueAudio( + Future handlerFuturo, { + Duration timeout = timeoutArranqueAudio, +}) async { + try { + final handler = await handlerFuturo.timeout(timeout); + return ArranqueAudioListo(handler); + } on TimeoutException { + return ArranqueAudioPendiente(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 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 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> createState() => _ArranqueAudioAppState(); +} + +class _ArranqueAudioAppState extends State> { + @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( + 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), + ), + ), + ); + } +} diff --git a/test/servicios/arranque_audio_test.dart b/test/servicios/arranque_audio_test.dart new file mode 100644 index 0000000..288e8a8 --- /dev/null +++ b/test/servicios/arranque_audio_test.dart @@ -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( + Future.value('handler-ok'), + timeout: const Duration(milliseconds: 200), + ); + + expect(resultado, isA>()); + expect((resultado as ArranqueAudioListo).handler, 'handler-ok'); + }, + ); + + test('handler colgado mas alla del timeout devuelve ArranqueAudioPendiente ' + 'con el MISMO future original', () async { + final completer = Completer(); + + final resultado = await esperarArranqueAudio( + completer.future, + timeout: const Duration(milliseconds: 20), + ); + + expect(resultado, isA>()); + expect( + identical( + (resultado as ArranqueAudioPendiente).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(); + + final resultado = await esperarArranqueAudio( + completer.future, + timeout: const Duration(milliseconds: 20), + ); + final pendiente = resultado as ArranqueAudioPendiente; + + completer.complete('handler-tardio'); + final handler = await pendiente.handlerFuturo; + + expect(handler, 'handler-tardio'); + }); + }); +} diff --git a/test/widgets/arranque_audio_app_test.dart b/test/widgets/arranque_audio_app_test.dart new file mode 100644 index 0000000..76e948c --- /dev/null +++ b/test/widgets/arranque_audio_app_test.dart @@ -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 handlerFuturo, + required void Function(String handler) alListo, + }) { + return ArranqueAudioApp( + 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(); + + 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(); + + 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(); + final llamadas = []; + + 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(); + final llamadas = []; + + 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); + }, + ); +}