Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
204 lines
7.3 KiB
Dart
204 lines
7.3 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';
|
|
|
|
/// 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();
|
|
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
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 = PluriWaveAudioHandler();
|
|
registrarHandler(handler);
|
|
|
|
expect(hayCocheSuscritoAlArbol(), isFalse);
|
|
|
|
handler.subscribeToChildren(AudioService.browsableRootId);
|
|
|
|
expect(hayCocheSuscritoAlArbol(), isTrue);
|
|
});
|
|
});
|
|
}
|