Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on stop button makes the entire app useless", citado contra las Android for Cars App Quality Guidelines. La causa no era el boton de parar. Maquina de estados del transporte _cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de fuente se encolan incrementando la revision al encolar, no al ejecutar, asi que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que las entradas obsoletas reescribieran loading sobre el idle que stop() acababa de publicar. Estado final: loading para siempre sobre una sesion que audio_service ya habia desactivado. Ahora la guarda de revision es la primera sentencia del metodo. pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual despues de pulsar pausa; se revalida la intencion antes de llamar a play(). Se anade un suelo de estado que cierra cualquier loading o buffering sin carga viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando, para no convertir un tunel en un error. El presupuesto hasta el primer mensaje baja a menos de diez segundos y los reintentos ya no borran el mensaje visible. Tier gratuito en el coche El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando no habia premium, y un revisor con instalacion limpia siempre es tier gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con emisoras reales y reproducibles, compiladas en el binario para que existan en frio, y la puerta de entitlement acota contenido en vez de bloquear acciones. Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la ultima emisora, que fallaba tambien a los clientes de pago. Localizacion El locale del handler solo lo fijaba un widget que el motor headless nunca construye, asi que todo error del coche salia en castellano. Se resuelve desde el locale de plataforma. Se traducen las once etiquetas del arbol que estaban a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si vuelve a aparecer texto visible fuera del sistema de traduccion. Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los 5 avisos preexistentes.
208 lines
7.4 KiB
Dart
208 lines
7.4 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/main.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
|
|
import 'helpers/handlers_audio.dart';
|
|
|
|
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
|
|
///
|
|
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
|
|
/// `didChangeDependencies`, con un latch de un solo uso y este comentario:
|
|
/// «Que exista una View significa que hay Activity». La premisa es FALSA.
|
|
///
|
|
/// `runApp` envuelve SIEMPRE el árbol en una `View` construida a partir de
|
|
/// `platformDispatcher.implicitView`, y lanza `StateError` si no la hay
|
|
/// (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). Así que
|
|
/// en el motor headless que `audio_service` levanta sin Activity —el mismo
|
|
/// que demostrablemente llega a `runApp`, ver la doc de
|
|
/// `aplicarPoliticaOrientacion`— `View.maybeOf(context)` ya es no-nulo en el
|
|
/// PRIMER `didChangeDependencies`.
|
|
///
|
|
/// Consecuencia: el latch se gastaba durante el arranque headless, justo en
|
|
/// el instante en que no podía conseguir nada (`_childrenSubjects` sigue
|
|
/// vacío, y `notificarHijosCambiaron` es `_childrenSubjects[id]?.add(...)`,
|
|
/// un no-op silencioso). Y no podía volver a dispararse nunca, porque
|
|
/// `didChangeDependencies` no se re-ejecuta cuando más tarde se adjunta una
|
|
/// Activity al MISMO motor cacheado. La vía de recuperación estaba muerta en
|
|
/// los dos motores.
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
final crearHandler = registrarHandlersLiberables();
|
|
|
|
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
|
|
test('resumed + coche ya suscrito + latch libre invalida', () {
|
|
expect(
|
|
debeInvalidarArbolAutoAlReanudar(
|
|
estado: AppLifecycleState.resumed,
|
|
hayCocheSuscrito: true,
|
|
yaInvalidado: false,
|
|
),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('sin suscripción del coche NO invalida — y por tanto no gasta el '
|
|
'latch en el arranque headless', () {
|
|
expect(
|
|
debeInvalidarArbolAutoAlReanudar(
|
|
estado: AppLifecycleState.resumed,
|
|
hayCocheSuscrito: false,
|
|
yaInvalidado: false,
|
|
),
|
|
isFalse,
|
|
reason:
|
|
'notificarHijosCambiaron solo empuja a un sujeto que ya existe, '
|
|
'así que invalidar antes de que el coche se suscriba a NADA es '
|
|
'demostrablemente un no-op',
|
|
);
|
|
});
|
|
|
|
test('ningún estado del ciclo de vida distinto de resumed invalida', () {
|
|
for (final estado in [
|
|
AppLifecycleState.detached,
|
|
AppLifecycleState.inactive,
|
|
AppLifecycleState.hidden,
|
|
AppLifecycleState.paused,
|
|
]) {
|
|
expect(
|
|
debeInvalidarArbolAutoAlReanudar(
|
|
estado: estado,
|
|
hayCocheSuscrito: true,
|
|
yaInvalidado: false,
|
|
),
|
|
isFalse,
|
|
reason:
|
|
'$estado no significa «hay una Activity adjunta en primer '
|
|
'plano»; solo resumed lo significa',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('con el latch ya gastado no vuelve a invalidar (nada de tormenta '
|
|
'de notificaciones)', () {
|
|
expect(
|
|
debeInvalidarArbolAutoAlReanudar(
|
|
estado: AppLifecycleState.resumed,
|
|
hayCocheSuscrito: true,
|
|
yaInvalidado: true,
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('OrientacionResponsiveApp — cableado real del disparador', () {
|
|
testWidgets('bajo pumpWidget/runApp SIEMPRE existe una View, que es '
|
|
'exactamente por qué el disparador anterior no valía', (tester) async {
|
|
await tester.pumpWidget(
|
|
const OrientacionResponsiveApp(child: SizedBox.shrink()),
|
|
);
|
|
|
|
expect(
|
|
View.maybeOf(tester.element(find.byType(SizedBox))),
|
|
isNotNull,
|
|
reason:
|
|
'wrapWithDefaultView envuelve el árbol en una View o lanza '
|
|
'StateError: no hay ningún motor bajo runApp sin View',
|
|
);
|
|
});
|
|
|
|
testWidgets('arranque headless: hay View desde el primer frame, pero sin '
|
|
'Activity ni coche suscrito el latch NO se gasta y sigue disponible '
|
|
'para cuando el coche por fin navegue', (tester) async {
|
|
final handler = crearHandler();
|
|
registrarHandler(handler);
|
|
var invalidaciones = 0;
|
|
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
|
|
|
await tester.pumpWidget(
|
|
const OrientacionResponsiveApp(child: SizedBox.shrink()),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(
|
|
invalidaciones,
|
|
0,
|
|
reason: 'el primer frame no prueba que haya Activity',
|
|
);
|
|
|
|
// Incluso si un evento de ciclo de vida llegara en frío: el coche no
|
|
// ha navegado nada todavía, así que no hay ningún sujeto al que
|
|
// empujar y el latch debe sobrevivir.
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
expect(invalidaciones, 0);
|
|
|
|
// Ahora el coche navega la raíz (esto es lo que crea el sujeto), y la
|
|
// siguiente vuelta a primer plano sí encuentra algo que invalidar.
|
|
handler.subscribeToChildren(AudioService.browsableRootId);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
|
|
expect(invalidaciones, 1);
|
|
});
|
|
|
|
testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) '
|
|
'empuja de verdad por el stream de hijos de la raíz', (tester) async {
|
|
final handler = crearHandler();
|
|
registrarHandler(handler);
|
|
|
|
// El coche navegó la raíz durante el arranque headless: el sujeto
|
|
// existe y el head unit tiene el listado cacheado.
|
|
final eventos = <Map<String, dynamic>>[];
|
|
final sub = handler
|
|
.subscribeToChildren(AudioService.browsableRootId)
|
|
.listen(eventos.add);
|
|
addTearDown(sub.cancel);
|
|
|
|
await tester.pumpWidget(
|
|
const OrientacionResponsiveApp(child: SizedBox.shrink()),
|
|
);
|
|
await tester.pump();
|
|
expect(
|
|
eventos,
|
|
isEmpty,
|
|
reason: 'todavía no hay Activity, solo una View',
|
|
);
|
|
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
|
|
expect(
|
|
eventos,
|
|
hasLength(1),
|
|
reason:
|
|
'esta es la ÚNICA vía de recuperación cuando el registro del '
|
|
'canal pluriwave/file_actions falló en el motor headless',
|
|
);
|
|
|
|
// Y no una por cada rebote de ciclo de vida.
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
|
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
|
await tester.pump();
|
|
|
|
expect(eventos, hasLength(1));
|
|
});
|
|
});
|
|
|
|
group('hayCocheSuscritoAlArbol', () {
|
|
test('es false sin handler suscrito y true en cuanto el coche navega un '
|
|
'id', () async {
|
|
final handler = crearHandler();
|
|
registrarHandler(handler);
|
|
|
|
expect(hayCocheSuscritoAlArbol(), isFalse);
|
|
|
|
handler.subscribeToChildren(AudioService.browsableRootId);
|
|
|
|
expect(hayCocheSuscritoAlArbol(), isTrue);
|
|
});
|
|
});
|
|
}
|