fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
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.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user