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:
@@ -0,0 +1,218 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user