import 'dart:async'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:shared_preferences/shared_preferences.dart'; import 'servicio_compras.dart'; export 'servicio_compras.dart' show ResultadoVerificacionLicencia; /// Versioned persistence key (Design ADR-1) of the permanent, non-consumable /// premium unlock. Owned here so the headless verification below and /// `EstadoEntitlement` read/write exactly ONE key. const claveCompraPremium = 'compra_premium_v1'; /// Epoch millis of the last verification that got a DEFINITIVE answer /// ([ResultadoVerificacionLicencia.poseida] / [noPoseida]). const claveUltimaVerificacionLicencia = 'licencia_ultima_verificacion_ms'; /// Epoch millis of the last ATTEMPT, whatever its outcome. const claveUltimoIntentoLicencia = 'licencia_ultimo_intento_ms'; /// Consecutive definitive "not owned" answers while the flag was premium. const claveAusenciasLicencia = 'licencia_ausencias_consecutivas'; /// Epoch millis of the FIRST absence of the current streak. const clavePrimeraAusenciaLicencia = 'licencia_primera_ausencia_ms'; /// After a definitive answer, Play is not asked again for this long. const intervaloVerificacionLicencia = Duration(hours: 24); /// After an attempt without a definitive answer (offline, billing /// unavailable, error), the retry waits at least this long, so resuming the /// app or reconnecting the car while offline never hammers Play. const intervaloReintentoLicencia = Duration(hours: 1); /// Minimum time between the first absence and the one that confirms it — /// a transient empty Play Store cache must never revoke a paying user. const separacionMinimaAusencias = Duration(hours: 12); /// Definitive absences required (spaced by [separacionMinimaAusencias]) /// before the premium flag is revoked. const ausenciasParaRevocar = 2; /// What a [verificarLicencia] run changed in the persisted flag. enum CambioLicencia { /// Nothing changed (throttled, unknown answer, or state already right). sinCambios, /// The flag went false -> true (e.g. a reinstall of a paying user). desbloqueada, /// The flag went true -> false (refund confirmed twice, spaced apart). revocada, } /// The run currently in flight, shared by every caller in this isolate /// (phone UI and Android Auto), so only ONE ownership query runs at a time. Future? _verificacionEnCurso; /// Silent, headless-safe license re-verification (refund revocation). /// /// No `BuildContext`, no purchase-stream events, never throws, and every /// non-definitive outcome leaves the persisted state untouched (fail-open, /// ADR-2) — PRO is never removed for lack of connectivity. Callers fire and /// forget it; it must never sit on a startup or browse path. /// /// Rules: /// * throttled by [intervaloVerificacionLicencia] after a definitive answer /// and by [intervaloReintentoLicencia] after any attempt; a clock that /// went backwards never blocks it; /// * [ResultadoVerificacionLicencia.poseida]: flag forced to `true` (silent /// unlock if it was `false`), absence streak cleared; /// * [ResultadoVerificacionLicencia.noPoseida] with the flag `true`: the /// streak grows, and the flag is revoked only once it reaches /// [ausenciasParaRevocar] AND at least [separacionMinimaAusencias] passed /// since its first absence; with the flag `false` there is nothing to do; /// * [ResultadoVerificacionLicencia.desconocido] (or an exception): nothing /// changes, not even the streak. Future verificarLicencia({ required Future Function() consultar, SharedPreferences? prefs, DateTime Function()? reloj, }) { final enCurso = _verificacionEnCurso; if (enCurso != null) return enCurso; final ejecucion = _verificar( consultar: consultar, prefs: prefs, reloj: reloj ?? DateTime.now, ); _verificacionEnCurso = ejecucion; unawaited( ejecucion.whenComplete(() { if (identical(_verificacionEnCurso, ejecucion)) { _verificacionEnCurso = null; } }), ); return ejecucion; } /// Clears the absence streak — a real purchase/restore is fresh proof of /// ownership, so a stale streak must not survive it. Future reiniciarAusenciasLicencia(SharedPreferences prefs) async { await prefs.remove(claveAusenciasLicencia); await prefs.remove(clavePrimeraAusenciaLicencia); } Future _verificar({ required Future Function() consultar, required SharedPreferences? prefs, required DateTime Function() reloj, }) async { try { final p = prefs ?? await SharedPreferences.getInstance(); final ahora = reloj(); if (_dentroDeVentana( p, claveUltimaVerificacionLicencia, ahora, intervaloVerificacionLicencia, ) || _dentroDeVentana( p, claveUltimoIntentoLicencia, ahora, intervaloReintentoLicencia, )) { return CambioLicencia.sinCambios; } await p.setInt(claveUltimoIntentoLicencia, ahora.millisecondsSinceEpoch); ResultadoVerificacionLicencia resultado; try { resultado = await consultar(); } catch (e) { debugPrint('[PluriWave][licencia] consulta fallida -> sin cambios $e'); resultado = ResultadoVerificacionLicencia.desconocido; } switch (resultado) { case ResultadoVerificacionLicencia.desconocido: return CambioLicencia.sinCambios; case ResultadoVerificacionLicencia.poseida: await p.setInt( claveUltimaVerificacionLicencia, ahora.millisecondsSinceEpoch, ); await reiniciarAusenciasLicencia(p); if (p.getBool(claveCompraPremium) ?? false) { return CambioLicencia.sinCambios; } await p.setBool(claveCompraPremium, true); return CambioLicencia.desbloqueada; case ResultadoVerificacionLicencia.noPoseida: await p.setInt( claveUltimaVerificacionLicencia, ahora.millisecondsSinceEpoch, ); if (!(p.getBool(claveCompraPremium) ?? false)) { await reiniciarAusenciasLicencia(p); return CambioLicencia.sinCambios; } return _registrarAusencia(p, ahora); } } catch (e) { // Fail-open (ADR-2): a prefs failure never touches the entitlement. debugPrint('[PluriWave][licencia] verificacion fallida -> sin cambios $e'); return CambioLicencia.sinCambios; } } Future _registrarAusencia( SharedPreferences p, DateTime ahora, ) async { final ausencias = (p.getInt(claveAusenciasLicencia) ?? 0) + 1; final primeraMs = p.getInt(clavePrimeraAusenciaLicencia); if (primeraMs == null || ausencias == 1) { await p.setInt(claveAusenciasLicencia, 1); await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch); return CambioLicencia.sinCambios; } final separacion = ahora.difference( DateTime.fromMillisecondsSinceEpoch(primeraMs), ); if (separacion.isNegative) { // The clock went backwards: restart the spacing from now (delays the // revocation, never hastens it). await p.setInt(clavePrimeraAusenciaLicencia, ahora.millisecondsSinceEpoch); await p.setInt(claveAusenciasLicencia, ausencias); return CambioLicencia.sinCambios; } if (ausencias >= ausenciasParaRevocar && separacion >= separacionMinimaAusencias) { await p.setBool(claveCompraPremium, false); await reiniciarAusenciasLicencia(p); return CambioLicencia.revocada; } await p.setInt(claveAusenciasLicencia, ausencias); return CambioLicencia.sinCambios; } /// Whether [clave]'s timestamp is less than [ventana] before [ahora]. A /// timestamp in the future (clock moved backwards) does NOT throttle. bool _dentroDeVentana( SharedPreferences p, String clave, DateTime ahora, Duration ventana, ) { final ms = p.getInt(clave); if (ms == null) return false; final transcurrido = ahora.difference( DateTime.fromMillisecondsSinceEpoch(ms), ); return !transcurrido.isNegative && transcurrido < ventana; }