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:
@@ -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