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.
219 lines
7.9 KiB
Dart
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;
|
|
}
|