Files
pluriwave/lib/estado/estado_entitlement.dart
T
FreeTLab 3449e2cb79
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
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.
2026-08-31 14:34:49 +02:00

194 lines
7.8 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds").
const _keyPremium = 'compra_premium_v1';
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from
/// prefs, with NO `BuildContext`/`Provider` dependency. Mirrors
/// `FuenteMusicaLocalAutoImpl._resolverPrefs()`'s
/// inject-or-`getInstance()` convention (`musica_local_auto.dart:163`) —
/// this is what `PluriWaveAudioHandler` calls, since it registers before
/// `runApp` and no widget tree (therefore no `Provider`) exists yet.
///
/// Absent key = free tier (Rollout "Additive and prefs-backed; absent key =
/// free"). Never throws — a `SharedPreferences.getInstance()` failure would
/// propagate here exactly like the persisted read failing, which the caller
/// (Design ADR-2 "fail-open") must treat as "trust the last known state",
/// not this function's job to catch.
Future<bool> esPremiumPersistido({SharedPreferences? prefs}) async {
final resueltas = prefs ?? await SharedPreferences.getInstance();
return resueltas.getBool(_keyPremium) ?? false;
}
/// User-facing, non-error-text outcomes [EstadoEntitlement] can expose (FIX
/// 3, code review): the UI layer (`hoja_premium.dart`) has no BuildContext
/// here, so this file never carries localized/user-facing STRINGS itself —
/// only this typed signal, mapped to a localized message by the widget.
/// Cleared back to `null` once consumed ([EstadoEntitlement.consumirResultadoUsuario]).
enum ResultadoEntitlementUsuario {
/// A purchase or restore attempt failed (network, billing error, product
/// not yet available in the store, etc). This NEVER carries the raw
/// exception/developer string from [EventoCompra.mensaje] — the UI maps
/// this enum value to ONE generic localized message, never the internal
/// diagnostic text.
error,
/// [EstadoEntitlement.restaurar] completed successfully but found nothing
/// to restore. Distinct from [error]: an expected, non-error outcome
/// (Spec "Restore Purchases" — "finds nothing -> stays free tier with a
/// clear non-error result").
restauracionSinCompras,
}
/// Cross-cutting entitlement notifier (Design ADR-1), idiomatic
/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
: _prefs = prefs,
_compras = compras {
final flujo = _compras;
if (flujo != null) {
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
}
_cargar();
}
/// The single non-consumable product id (Design "Interfaces / Contracts"),
/// re-exported here so UI/paywall code depends on ONE canonical constant
/// rather than reaching into `servicio_compras.dart` for it.
static const idProducto = ServicioComprasPlayBilling.idProducto;
final SharedPreferences? _prefs;
final PuertoCompras? _compras;
StreamSubscription<EventoCompra>? _comprasSub;
bool _esPremium = false;
bool _compraEnCurso = false;
ResultadoEntitlementUsuario? _resultadoUsuario;
bool get esPremium => _esPremium;
bool get compraEnCurso => _compraEnCurso;
/// FIX 3 (code review): the user-facing signal for a failed purchase/
/// restore, or a restore that found nothing. `null` when there is nothing
/// to show — see [consumirResultadoUsuario].
ResultadoEntitlementUsuario? get resultadoUsuario => _resultadoUsuario;
/// Clears [resultadoUsuario] once the UI has consumed/displayed it.
/// A no-op (no extra notification) if there is nothing to clear.
void consumirResultadoUsuario() {
if (_resultadoUsuario == null) return;
_resultadoUsuario = null;
notifyListeners();
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final premium = prefs.getBool(_keyPremium) ?? false;
if (premium != _esPremium) {
_esPremium = premium;
}
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
/// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted.
Future<void> comprar() async {
if (_esPremium) return;
final compras = _compras;
if (compras == null) return;
_compraEnCurso = true;
// FIX 3 (code review): a fresh attempt clears any stale result left over
// from a previous failed attempt, so the UI never shows an outdated
// error/confirmation across two unrelated attempts.
_resultadoUsuario = null;
notifyListeners();
await compras.comprar();
}
/// Re-queries Play Billing for a prior purchase (Spec "Restore Purchases").
Future<void> restaurar() async {
final compras = _compras;
if (compras == null) return;
_compraEnCurso = true;
_resultadoUsuario = null;
notifyListeners();
await compras.restaurar();
}
Future<void> _alRecibirEvento(EventoCompra evento) async {
switch (evento.tipo) {
case TipoEventoCompra.comprada:
case TipoEventoCompra.restaurada:
await _desbloquear();
case TipoEventoCompra.cancelada:
// Spec "Purchase cancelled or failed": a user-INITIATED cancel
// stays free tier with no error surfaced — just stop the in-flight
// spinner. Not a failure, so no [resultadoUsuario] either.
_compraEnCurso = false;
notifyListeners();
case TipoEventoCompra.noEncontrada:
// FIX 3 (code review): "Restore finds nothing" is an expected,
// NON-error outcome (Spec "Restore Purchases") but `hoja_premium.dart`
// had zero feedback for it — the spinner just stopped with no
// confirmation. Distinct signal from [TipoEventoCompra.error].
_compraEnCurso = false;
_resultadoUsuario = ResultadoEntitlementUsuario.restauracionSinCompras;
notifyListeners();
case TipoEventoCompra.error:
// Fail-open (Design ADR-2): an error NEVER writes `false` over an
// already-premium flag, and never invents a `true` for a free user
// either — the persisted flag from `_cargar()` is left untouched.
//
// FIX 3 (code review): [EventoCompra.mensaje] (raw exception/
// developer text, e.g. "Producto no encontrado en Play Console") is
// DELIBERATELY discarded here — only the typed enum crosses into
// [resultadoUsuario], never the raw string. `hoja_premium.dart` maps
// it to ONE generic localized message.
_compraEnCurso = false;
_resultadoUsuario = ResultadoEntitlementUsuario.error;
notifyListeners();
case TipoEventoCompra.pendiente:
_compraEnCurso = true;
notifyListeners();
}
}
Future<void> _desbloquear() async {
final yaEraPremium = _esPremium;
_esPremium = true;
_compraEnCurso = false;
final prefs = await _resolverPrefs();
await prefs.setBool(_keyPremium, true);
notifyListeners();
if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind.
invalidarArbolAuto();
}
}
@override
void dispose() {
_comprasSub?.cancel();
super.dispose();
}
}