Files
pluriwave/lib/servicios/verificacion_licencia.dart
T
Javier Bautista Fernández 0b7919e72e
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 4m8s
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.
2026-09-18 11:45:43 +02:00

219 lines
7.9 KiB
Dart

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<CambioLicencia>? _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<CambioLicencia> verificarLicencia({
required Future<ResultadoVerificacionLicencia> 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<void> reiniciarAusenciasLicencia(SharedPreferences prefs) async {
await prefs.remove(claveAusenciasLicencia);
await prefs.remove(clavePrimeraAusenciaLicencia);
}
Future<CambioLicencia> _verificar({
required Future<ResultadoVerificacionLicencia> 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<CambioLicencia> _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;
}