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'; import '../servicios/verificacion_licencia.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"). Shared with the silent license re-verification /// (`verificacion_licencia.dart`), which may revoke it after a refund. const _keyPremium = claveCompraPremium; /// 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 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, DateTime Function()? reloj, }) : _prefs = prefs, _compras = compras, _reloj = reloj { final flujo = _compras; if (flujo != null) { _comprasSub = flujo.eventos.listen(_alRecibirEvento); } // The silent license check is chained AFTER the load and never awaited // by anyone: the persisted flag is served immediately, exactly as // before, and the check can only adjust it later, in the background. unawaited(_cargar().then((_) => _verificarLicencia())); } /// 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; /// Injectable clock for the license check's throttle/spacing rules. final DateTime Function()? _reloj; StreamSubscription? _comprasSub; bool _desechado = false; 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 _cargar() async { final prefs = await _resolverPrefs(); final premium = prefs.getBool(_keyPremium) ?? false; if (premium != _esPremium) { _esPremium = premium; } notifyListeners(); } Future _resolverPrefs() async => _prefs ?? SharedPreferences.getInstance(); /// Fire-and-forget hook for app resume: re-syncs with the persisted flag /// (the Android Auto path may have changed it) and runs the throttled /// silent license check. Never throws, never touches [compraEnCurso] or /// [resultadoUsuario]. Future refrescarLicencia() async { try { _sincronizarConPrefs(await _resolverPrefs()); } catch (e) { debugPrint('[PluriWave][licencia] refresco fallido $e'); } await _verificarLicencia(); } /// Runs [verificarLicencia] against the purchase port and mirrors any /// change of the persisted flag. Silent by construction: it only ever /// updates [esPremium] and notifies — no purchase-stream event, no /// [resultadoUsuario], no [compraEnCurso]. Future _verificarLicencia() async { final compras = _compras; if (compras == null || _desechado) return; try { final prefs = await _resolverPrefs(); await verificarLicencia( consultar: compras.consultarPropiedad, prefs: prefs, reloj: _reloj, ); _sincronizarConPrefs(prefs); } catch (e) { debugPrint('[PluriWave][licencia] verificacion fallida $e'); } } /// Aligns [esPremium] with the persisted flag. Safe against a racing /// [_desbloquear]: that one writes the prefs cache in the same synchronous /// block where it flips [_esPremium], so both always agree here. void _sincronizarConPrefs(SharedPreferences prefs) { if (_desechado) return; final premium = prefs.getBool(_keyPremium) ?? false; if (premium == _esPremium) return; _esPremium = premium; notifyListeners(); // Either direction changes what the car may show (local music is // premium-gated), so the cached Android Auto tree is stale both ways. invalidarArbolAuto(); } /// 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 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 restaurar() async { final compras = _compras; if (compras == null) return; _compraEnCurso = true; _resultadoUsuario = null; notifyListeners(); await compras.restaurar(); } Future _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 _desbloquear() async { // Prefs resolved FIRST so the in-memory flip and the prefs-cache write // below happen in one synchronous block (`setBool` updates the cache // before awaiting the platform) — [_sincronizarConPrefs] can never // observe one without the other. final prefs = await _resolverPrefs(); final yaEraPremium = _esPremium; _esPremium = true; _compraEnCurso = false; final escritura = prefs.setBool(_keyPremium, true); // A real purchase/restore is fresh proof of ownership: drop any stale // absence streak of the silent license check. await reiniciarAusenciasLicencia(prefs); await escritura; 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() { _desechado = true; _comprasSub?.cancel(); super.dispose(); } }