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.
126 lines
4.8 KiB
Dart
126 lines
4.8 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
|
|
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
|
|
/// app's fixed ±12 dB slider scale to whatever range the device's native
|
|
/// `Equalizer.getBandLevelRange()` reports.
|
|
///
|
|
/// This is the only source-plausible explanation for the reported «suena muy
|
|
/// alto» half of the bug. The original implementation normalised the input
|
|
/// across the WHOLE range and mapped it linearly:
|
|
///
|
|
/// normalizado = (db.clamp(-12, 12) + 12) / 24
|
|
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
|
|
///
|
|
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
|
|
/// the range happens to be symmetric. Android does not guarantee that: the
|
|
/// AudioEffect Equalizer contract only requires a min/max pair, and real
|
|
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
|
|
/// band 0 dB — was silently pushing a positive boost into every band, which
|
|
/// is audibly louder while the on/off button still reads "off".
|
|
///
|
|
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
|
|
/// sides of the scale are stretched INDEPENDENTLY against their own end of
|
|
/// the native range, so the sign of the user's intent is never inverted and
|
|
/// the extremes still reach the device's real limits.
|
|
void main() {
|
|
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
|
|
test('symmetric range (the common case) is unchanged', () {
|
|
expect(
|
|
mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15),
|
|
0,
|
|
);
|
|
});
|
|
|
|
test('asymmetric range no longer boosts a FLAT preset', () {
|
|
// The reported symptom: on a device reporting [-12, +19] the old
|
|
// midpoint mapping turned every 0 dB band into +3.5 dB of real boost.
|
|
expect(
|
|
mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19),
|
|
0,
|
|
reason: 'a FLAT preset must be inaudible, on every device',
|
|
);
|
|
});
|
|
|
|
test('a wholly positive range still cannot boost a FLAT preset', () {
|
|
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
|
|
});
|
|
|
|
test('a wholly negative range still cannot cut a FLAT preset', () {
|
|
expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0);
|
|
});
|
|
});
|
|
|
|
group('mapearGananciaNativa — the extremes reach the native limits', () {
|
|
test('+12 dB maps to the native maximum', () {
|
|
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
|
|
});
|
|
|
|
test('-12 dB maps to the native minimum', () {
|
|
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
|
|
});
|
|
|
|
test('values beyond the slider scale are clamped, not extrapolated', () {
|
|
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
|
|
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
|
|
});
|
|
});
|
|
|
|
group('mapearGananciaNativa — each side scales against its own end', () {
|
|
test('half boost is half of the positive headroom', () {
|
|
expect(
|
|
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
|
|
closeTo(10, 1e-9),
|
|
);
|
|
});
|
|
|
|
test('half cut is half of the negative headroom', () {
|
|
expect(
|
|
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
|
|
closeTo(-6, 1e-9),
|
|
);
|
|
});
|
|
|
|
test('the sign of the user intent is never inverted', () {
|
|
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
|
|
final nativo = mapearGananciaNativa(
|
|
db,
|
|
minDecibels: -12,
|
|
maxDecibels: 19,
|
|
);
|
|
expect(
|
|
nativo.sign,
|
|
db.sign,
|
|
reason: 'a cut must never become a boost ($db dB -> $nativo)',
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
|
|
test('a range with no headroom on one side clamps that side to 0', () {
|
|
// A device that reports max == 0 can only cut. Asking for a boost must
|
|
// resolve to "no change", never to a negative value.
|
|
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
|
|
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
|
|
});
|
|
|
|
test('a zero-width range collapses everything to 0', () {
|
|
expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0);
|
|
expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0);
|
|
});
|
|
|
|
test('the result never escapes the native range', () {
|
|
for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) {
|
|
final nativo = mapearGananciaNativa(
|
|
db,
|
|
minDecibels: -3,
|
|
maxDecibels: 19,
|
|
);
|
|
expect(nativo, greaterThanOrEqualTo(-3));
|
|
expect(nativo, lessThanOrEqualTo(19));
|
|
}
|
|
});
|
|
});
|
|
}
|