fix(compras): revocar el PRO tras un reembolso sin penalizar a quien pago
Verificacion silenciosa en segundo plano con queryPastPurchases, al abrir o volver a la app y al cargar Android Auto. Solo revoca tras dos respuestas validas de Play sin la compra separadas 12h; sin red o con error no toca nada. Reactiva el PRO automaticamente si Play confirma la compra.
This commit is contained in:
@@ -5,12 +5,14 @@ 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").
|
||||
const _keyPremium = 'compra_premium_v1';
|
||||
/// 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
|
||||
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
|
||||
/// 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 {
|
||||
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);
|
||||
}
|
||||
_cargar();
|
||||
// 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"),
|
||||
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
final PuertoCompras? _compras;
|
||||
|
||||
/// Injectable clock for the license check's throttle/spacing rules.
|
||||
final DateTime Function()? _reloj;
|
||||
StreamSubscription<EventoCompra>? _comprasSub;
|
||||
bool _desechado = false;
|
||||
|
||||
bool _esPremium = false;
|
||||
bool _compraEnCurso = false;
|
||||
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
Future<SharedPreferences> _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<void> 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<void> _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.
|
||||
@@ -171,11 +231,19 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _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 prefs = await _resolverPrefs();
|
||||
await prefs.setBool(_keyPremium, true);
|
||||
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
|
||||
@@ -187,6 +255,7 @@ class EstadoEntitlement extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_desechado = true;
|
||||
_comprasSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user