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
+66
View File
@@ -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');
});
});
}
+128
View File
@@ -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);
},
);
}