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:
@@ -205,6 +205,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
||||
// without a live native sink. Re-subscribe and re-seed the active device
|
||||
// (no-op when multi-device EQ is off).
|
||||
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
|
||||
// Silent, throttled license re-verification (refund revocation) and a
|
||||
// re-sync with any change the Android Auto path persisted meanwhile.
|
||||
// Fire-and-forget: never delays the resume, never shows anything.
|
||||
unawaited(context.read<EstadoEntitlement>().refrescarLicencia());
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+12
-2
@@ -20,6 +20,7 @@ import 'servicios/servicio_compras.dart';
|
||||
import 'servicios/servicio_consentimiento.dart';
|
||||
import 'servicios/servicio_ecualizador.dart';
|
||||
import 'servicios/servicio_presets_personalizados.dart';
|
||||
import 'servicios/verificacion_licencia.dart';
|
||||
import 'tema/pluriwave_tokens.dart';
|
||||
|
||||
const _anchoMinimoLandscape = 600.0;
|
||||
@@ -146,6 +147,16 @@ Future<void> main() async {
|
||||
// injected into every state/service below.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Silent license re-verification (refund revocation) for the Android Auto
|
||||
// path: this engine may be the headless one, with no widget tree and so no
|
||||
// `EstadoEntitlement`. The browse root triggers it fire-and-forget; the
|
||||
// shared in-flight guard in `verificarLicencia` keeps it to one query at a
|
||||
// time even when the phone UI checks too.
|
||||
registrarVerificacionLicenciaAuto(
|
||||
() =>
|
||||
verificarLicencia(consultar: compras.consultarPropiedad, prefs: prefs),
|
||||
);
|
||||
|
||||
// User-saved EQ presets for the car's Ecualizador folder, same
|
||||
// injectable-prefs DI convention and same pre-init placement as the two
|
||||
// registrations above (neither depends on the AudioHandler). Passed as a
|
||||
@@ -354,8 +365,7 @@ bool debeInvalidarArbolAutoAlReanudar({
|
||||
required AppLifecycleState estado,
|
||||
required bool hayCocheSuscrito,
|
||||
required bool yaInvalidado,
|
||||
}) =>
|
||||
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
||||
}) => !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
||||
|
||||
/// Root wrapper that keeps the orientation policy applied and owns the
|
||||
/// Android Auto browse-tree recovery hook.
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'emisoras_destacadas.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
import 'navegacion_auto.dart';
|
||||
import 'servicio_audio_session.dart';
|
||||
import 'verificacion_licencia.dart' show CambioLicencia;
|
||||
|
||||
/// Estado de reproducción expuesto al UI.
|
||||
enum EstadoReproduccion {
|
||||
@@ -383,6 +384,36 @@ void invalidarArbolAuto() {
|
||||
_invalidarArbolAutoGlobal?.call();
|
||||
}
|
||||
|
||||
/// Silent license re-verification for the headless Android Auto engine,
|
||||
/// registered from `main.dart` (it owns the purchase port and the prefs).
|
||||
/// `null` until registered — [dispararVerificacionLicenciaAuto] is then a
|
||||
/// no-op.
|
||||
Future<CambioLicencia> Function()? _verificarLicenciaAutoGlobal;
|
||||
|
||||
/// Registers (or, with `null`, clears) the verification
|
||||
/// [dispararVerificacionLicenciaAuto] runs. Module-level like every other
|
||||
/// `registrar*` seam here, so tests need no real handler.
|
||||
void registrarVerificacionLicenciaAuto(
|
||||
Future<CambioLicencia> Function()? verificar,
|
||||
) {
|
||||
_verificarLicenciaAutoGlobal = verificar;
|
||||
}
|
||||
|
||||
/// Runs the registered license check and, if it changed the persisted flag
|
||||
/// in either direction, invalidates the cached car tree (local music is
|
||||
/// premium-gated). Never throws. The browse path calls it UNAWAITED so a
|
||||
/// head unit's browse answer is never delayed by a Play query.
|
||||
Future<void> dispararVerificacionLicenciaAuto() async {
|
||||
final verificar = _verificarLicenciaAutoGlobal;
|
||||
if (verificar == null) return;
|
||||
try {
|
||||
final cambio = await verificar();
|
||||
if (cambio != CambioLicencia.sinCambios) invalidarArbolAuto();
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][licencia] verificacion Auto fallida $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
|
||||
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
|
||||
///
|
||||
@@ -3472,6 +3503,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
// [resolverLocalizacionesRespaldo], so this works on the engine
|
||||
// Android Auto starts without an Activity -- which is the only engine
|
||||
// a Play reviewer ever gets.
|
||||
// Silent license re-verification (refund revocation), on the root
|
||||
// only — every car connection asks for it — and fire-and-forget: the
|
||||
// browse answer below is served from the persisted flag right away,
|
||||
// and the check is throttled to at most once a day.
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
unawaited(dispararVerificacionLicenciaAuto());
|
||||
}
|
||||
final etiquetas = etiquetasArbolAutoDesde(_textos);
|
||||
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
||||
// The "recent" root, resolved BEFORE the entitlement gate.
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
|
||||
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
|
||||
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
|
||||
@@ -38,6 +39,25 @@ class EventoCompra {
|
||||
final String? mensaje;
|
||||
}
|
||||
|
||||
/// Outcome of the SILENT ownership query ([PuertoCompras.consultarPropiedad])
|
||||
/// used to re-verify the persisted premium flag (refund revocation).
|
||||
///
|
||||
/// Only [poseida] and [noPoseida] are definitive answers from Play; anything
|
||||
/// that is not a clean answer (offline, billing unavailable, query error,
|
||||
/// exception, timeout, pending purchase) is [desconocido], which the
|
||||
/// verification policy treats as "change nothing" (fail-open, ADR-2).
|
||||
enum ResultadoVerificacionLicencia {
|
||||
/// Play reports the premium product as purchased on this account.
|
||||
poseida,
|
||||
|
||||
/// Play answered successfully and the premium product is NOT among the
|
||||
/// account's purchases (e.g. refunded or revoked).
|
||||
noPoseida,
|
||||
|
||||
/// No trustworthy answer — never used to revoke.
|
||||
desconocido,
|
||||
}
|
||||
|
||||
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
|
||||
/// this port, never on `in_app_purchase` directly — matches
|
||||
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
|
||||
@@ -55,13 +75,24 @@ abstract class PuertoCompras {
|
||||
|
||||
/// Re-queries Play Billing for a prior purchase on this account.
|
||||
Future<void> restaurar();
|
||||
|
||||
/// Silently asks the store whether this account currently owns the
|
||||
/// premium product. Unlike [restaurar], it NEVER emits on [eventos] (the
|
||||
/// premium sheet listens there) and never throws: every failure maps to
|
||||
/// [ResultadoVerificacionLicencia.desconocido].
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad();
|
||||
}
|
||||
|
||||
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
|
||||
/// depends on [PuertoCompras] instead.
|
||||
class ServicioComprasPlayBilling implements PuertoCompras {
|
||||
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
|
||||
: _iap = inAppPurchase ?? InAppPurchase.instance {
|
||||
ServicioComprasPlayBilling({
|
||||
InAppPurchase? inAppPurchase,
|
||||
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
|
||||
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
|
||||
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
|
||||
_consultarComprasPasadasInyectada = consultarComprasPasadas,
|
||||
_limiteConsultaPropiedad = limiteConsultaPropiedad {
|
||||
_sub = _iap.purchaseStream.listen(
|
||||
_alRecibirCompras,
|
||||
onError: (Object error) {
|
||||
@@ -77,6 +108,15 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
||||
static const idProducto = 'pluriwave_premium';
|
||||
|
||||
final InAppPurchase _iap;
|
||||
|
||||
/// Test seam for [consultarPropiedad]; `null` in production, where the
|
||||
/// Android platform addition's `queryPastPurchases` is used.
|
||||
final Future<QueryPurchaseDetailsResponse> Function()?
|
||||
_consultarComprasPasadasInyectada;
|
||||
|
||||
/// Upper bound for [consultarPropiedad]: a hung BillingClient connection
|
||||
/// resolves to [ResultadoVerificacionLicencia.desconocido].
|
||||
final Duration _limiteConsultaPropiedad;
|
||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||
StreamSubscription<List<PurchaseDetails>>? _sub;
|
||||
|
||||
@@ -125,6 +165,33 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||
try {
|
||||
final disponible = await _iap.isAvailable().timeout(
|
||||
_limiteConsultaPropiedad,
|
||||
);
|
||||
if (!disponible) return ResultadoVerificacionLicencia.desconocido;
|
||||
final consultar =
|
||||
_consultarComprasPasadasInyectada ??
|
||||
() =>
|
||||
_iap
|
||||
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
|
||||
.queryPastPurchases();
|
||||
// `queryPastPurchases` reads the account's purchases straight from
|
||||
// the BillingClient: unlike `restorePurchases` it does NOT push them
|
||||
// into `purchaseStream`, so the premium sheet never sees this check.
|
||||
final respuesta = await consultar().timeout(_limiteConsultaPropiedad);
|
||||
return resultadoDesdeComprasPasadas(
|
||||
respuesta.pastPurchases,
|
||||
conError: respuesta.error != null,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][compras] consultarPropiedad -> desconocido $e');
|
||||
return ResultadoVerificacionLicencia.desconocido;
|
||||
}
|
||||
}
|
||||
|
||||
void _alRecibirCompras(List<PurchaseDetails> compras) {
|
||||
if (compras.isEmpty) {
|
||||
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
|
||||
@@ -176,6 +243,33 @@ EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) {
|
||||
};
|
||||
}
|
||||
|
||||
/// Pure mapping from a `queryPastPurchases` answer to the typed ownership
|
||||
/// result (same port-boundary rationale as [eventoDesdeEstadoCompra]).
|
||||
///
|
||||
/// The premium product present as purchased/restored is proof of ownership
|
||||
/// even when the answer also carries an error (the query spans in-app AND
|
||||
/// subscriptions, and a failure of the latter is irrelevant here). A pending
|
||||
/// entry is not a clean answer. Absence only counts as [noPoseida] when the
|
||||
/// query succeeded without error.
|
||||
ResultadoVerificacionLicencia resultadoDesdeComprasPasadas(
|
||||
List<PurchaseDetails> compras, {
|
||||
required bool conError,
|
||||
}) {
|
||||
final delProducto = compras.where(
|
||||
(c) => c.productID == ServicioComprasPlayBilling.idProducto,
|
||||
);
|
||||
final comprada = delProducto.any(
|
||||
(c) =>
|
||||
c.status == PurchaseStatus.purchased ||
|
||||
c.status == PurchaseStatus.restored,
|
||||
);
|
||||
if (comprada) return ResultadoVerificacionLicencia.poseida;
|
||||
if (conError || delProducto.isNotEmpty) {
|
||||
return ResultadoVerificacionLicencia.desconocido;
|
||||
}
|
||||
return ResultadoVerificacionLicencia.noPoseida;
|
||||
}
|
||||
|
||||
extension<T> on List<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -366,7 +366,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
in_app_purchase_android:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_purchase_android
|
||||
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
|
||||
|
||||
@@ -55,6 +55,10 @@ dependencies:
|
||||
|
||||
# In-app purchase
|
||||
in_app_purchase: ^3.2.0
|
||||
# Direct dependency only for `InAppPurchaseAndroidPlatformAddition
|
||||
# .queryPastPurchases` (silent license re-verification in
|
||||
# `ServicioComprasPlayBilling.consultarPropiedad`).
|
||||
in_app_purchase_android: ^0.5.0
|
||||
|
||||
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
|
||||
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
|
||||
|
||||
@@ -2,7 +2,10 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart'
|
||||
show registrarInvalidacionArbolAuto;
|
||||
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
|
||||
@@ -11,6 +14,12 @@ class _PuertoComprasFalso implements PuertoCompras {
|
||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||
int comprasIntentadas = 0;
|
||||
int restaurosIntentados = 0;
|
||||
int consultasPropiedad = 0;
|
||||
|
||||
/// What the silent ownership query answers. Defaults to [desconocido] so
|
||||
/// every pre-existing test keeps its old behavior (fail-open: no change).
|
||||
ResultadoVerificacionLicencia propiedad =
|
||||
ResultadoVerificacionLicencia.desconocido;
|
||||
|
||||
@override
|
||||
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||
@@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras {
|
||||
restaurosIntentados++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||
consultasPropiedad++;
|
||||
return propiedad;
|
||||
}
|
||||
|
||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||
|
||||
Future<void> dispose() => _eventos.close();
|
||||
}
|
||||
|
||||
/// Mutable clock for the throttle/spacing rules of the license check.
|
||||
class _Reloj {
|
||||
DateTime ahora = DateTime(2026, 9, 18, 10);
|
||||
|
||||
DateTime call() => ahora;
|
||||
}
|
||||
|
||||
/// Lets every microtask/async continuation of the fire-and-forget license
|
||||
/// check settle (mock prefs + fake port complete immediately).
|
||||
Future<void> _asentar() async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -327,6 +357,135 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('verificacion silenciosa de licencia (reembolsos)', () {
|
||||
late _PuertoComprasFalso compras;
|
||||
late _Reloj reloj;
|
||||
late int invalidaciones;
|
||||
|
||||
setUp(() {
|
||||
compras = _PuertoComprasFalso();
|
||||
reloj = _Reloj();
|
||||
invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
registrarInvalidacionArbolAuto(() {});
|
||||
await compras.dispose();
|
||||
});
|
||||
|
||||
Future<EstadoEntitlement> crear({required bool premium}) async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': premium});
|
||||
final estado = EstadoEntitlement(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
compras: compras,
|
||||
reloj: reloj.call,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await _asentar();
|
||||
return estado;
|
||||
}
|
||||
|
||||
test('se dispara sola al cargar, sin bloquear la carga', () async {
|
||||
final estado = await crear(premium: true);
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(compras.consultasPropiedad, 1);
|
||||
});
|
||||
|
||||
test('desconocido (offline) conserva premium sin notificar nada', () async {
|
||||
final estado = await crear(premium: true);
|
||||
var notificaciones = 0;
|
||||
estado.addListener(() => notificaciones++);
|
||||
|
||||
reloj.ahora = reloj.ahora.add(const Duration(days: 2));
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(notificaciones, 0);
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test('una sola ausencia no revoca', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||
final estado = await crear(premium: true);
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
});
|
||||
|
||||
test('revocacion confirmada: notifica, invalida el arbol de Auto y NUNCA '
|
||||
'toca resultadoUsuario ni compraEnCurso', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||
final estado = await crear(premium: true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// A restore the user started stays in flight, untouched.
|
||||
unawaited(estado.restaurar());
|
||||
await _asentar();
|
||||
expect(estado.compraEnCurso, isTrue);
|
||||
|
||||
var notificaciones = 0;
|
||||
estado.addListener(() => notificaciones++);
|
||||
|
||||
reloj.ahora = reloj.ahora.add(const Duration(days: 1));
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isFalse);
|
||||
expect(prefs.getBool('compra_premium_v1'), isFalse);
|
||||
expect(notificaciones, greaterThan(0));
|
||||
expect(invalidaciones, 1);
|
||||
expect(estado.resultadoUsuario, isNull);
|
||||
expect(estado.compraEnCurso, isTrue);
|
||||
});
|
||||
|
||||
test('poseida con la flag en false desbloquea en silencio', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.poseida;
|
||||
final estado = await crear(premium: false);
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(invalidaciones, 1);
|
||||
expect(estado.resultadoUsuario, isNull);
|
||||
expect(estado.compraEnCurso, isFalse);
|
||||
});
|
||||
|
||||
test('refrescarLicencia recoge un cambio hecho por otra via (Android '
|
||||
'Auto) en prefs', () async {
|
||||
final estado = await crear(premium: true);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setBool('compra_premium_v1', false);
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isFalse);
|
||||
});
|
||||
|
||||
test('una compra real reinicia el contador de ausencias', () async {
|
||||
compras.propiedad = ResultadoVerificacionLicencia.noPoseida;
|
||||
final estado = await crear(premium: false);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(claveAusenciasLicencia, 1);
|
||||
|
||||
compras.emitir(const EventoCompra(TipoEventoCompra.comprada));
|
||||
await _asentar();
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||
});
|
||||
|
||||
test('sin puerto de compras no verifica nada', () async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||
final estado = EstadoEntitlement(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
reloj: reloj.call,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
await _asentar();
|
||||
await estado.refrescarLicencia();
|
||||
|
||||
expect(estado.esPremium, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('esPremiumPersistido (headless, sin BuildContext)', () {
|
||||
test('lee la flag persistida directamente desde prefs', () async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||
|
||||
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
||||
@@ -17,8 +19,12 @@ import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||
class _InAppPurchaseFalso implements InAppPurchase {
|
||||
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
||||
int restauracionesPedidas = 0;
|
||||
bool disponible = true;
|
||||
final completadas = <PurchaseDetails>[];
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async => disponible;
|
||||
|
||||
@override
|
||||
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
||||
|
||||
@@ -52,6 +58,40 @@ PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
|
||||
status: status,
|
||||
);
|
||||
|
||||
/// A Play Billing past purchase as `queryPastPurchases` returns it.
|
||||
GooglePlayPurchaseDetails _compraPasada(
|
||||
PurchaseStatus status, {
|
||||
String productId = ServicioComprasPlayBilling.idProducto,
|
||||
}) => GooglePlayPurchaseDetails(
|
||||
purchaseID: 'GPA.1',
|
||||
productID: productId,
|
||||
verificationData: PurchaseVerificationData(
|
||||
localVerificationData: '{}',
|
||||
serverVerificationData: 'token',
|
||||
source: 'google_play',
|
||||
),
|
||||
transactionDate: '0',
|
||||
status: status,
|
||||
billingClientPurchase: PurchaseWrapper(
|
||||
orderId: 'GPA.1',
|
||||
packageName: 'es.freetimelab.pluriwave',
|
||||
purchaseTime: 0,
|
||||
purchaseToken: 'token',
|
||||
signature: 'firma',
|
||||
products: <String>[productId],
|
||||
isAutoRenewing: false,
|
||||
originalJson: '{}',
|
||||
isAcknowledged: true,
|
||||
purchaseState: PurchaseStateWrapper.purchased,
|
||||
),
|
||||
);
|
||||
|
||||
IAPError _errorBilling() => IAPError(
|
||||
source: 'google_play',
|
||||
code: 'restore_transactions_failed',
|
||||
message: 'BillingResponse.serviceUnavailable',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('eventoDesdeEstadoCompra', () {
|
||||
test('purchased -> comprada', () {
|
||||
@@ -137,8 +177,8 @@ void main() {
|
||||
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
||||
addTearDown(servicio.dispose);
|
||||
|
||||
final compra =
|
||||
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
|
||||
final compra = _compraFalsa(PurchaseStatus.purchased)
|
||||
..pendingCompletePurchase = true;
|
||||
iap.emitir(<PurchaseDetails>[compra]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
@@ -146,6 +186,153 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('ServicioComprasPlayBilling.consultarPropiedad (verificacion '
|
||||
'silenciosa)', () {
|
||||
late _InAppPurchaseFalso iap;
|
||||
|
||||
setUp(() => iap = _InAppPurchaseFalso());
|
||||
tearDown(() => iap.dispose());
|
||||
|
||||
ServicioComprasPlayBilling servicioCon(
|
||||
Future<QueryPurchaseDetailsResponse> Function() consulta, {
|
||||
Duration limite = const Duration(seconds: 10),
|
||||
}) {
|
||||
final servicio = ServicioComprasPlayBilling(
|
||||
inAppPurchase: iap,
|
||||
consultarComprasPasadas: consulta,
|
||||
limiteConsultaPropiedad: limite,
|
||||
);
|
||||
addTearDown(servicio.dispose);
|
||||
return servicio;
|
||||
}
|
||||
|
||||
test('producto comprado -> poseida', () async {
|
||||
final servicio = servicioCon(
|
||||
() async => QueryPurchaseDetailsResponse(
|
||||
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.poseida,
|
||||
);
|
||||
});
|
||||
|
||||
test('respuesta correcta sin el producto -> noPoseida', () async {
|
||||
final servicio = servicioCon(
|
||||
() async => QueryPurchaseDetailsResponse(
|
||||
pastPurchases: [
|
||||
_compraPasada(PurchaseStatus.purchased, productId: 'otro'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
});
|
||||
|
||||
test('respuesta con error y sin producto -> desconocido', () async {
|
||||
final servicio = servicioCon(
|
||||
() async => QueryPurchaseDetailsResponse(
|
||||
pastPurchases: const [],
|
||||
error: _errorBilling(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.desconocido,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'respuesta con error parcial pero con el producto -> poseida',
|
||||
() async {
|
||||
final servicio = servicioCon(
|
||||
() async => QueryPurchaseDetailsResponse(
|
||||
pastPurchases: [_compraPasada(PurchaseStatus.purchased)],
|
||||
error: _errorBilling(),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.poseida,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('compra pendiente -> desconocido (nunca revoca)', () async {
|
||||
final servicio = servicioCon(
|
||||
() async => QueryPurchaseDetailsResponse(
|
||||
pastPurchases: [_compraPasada(PurchaseStatus.pending)],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.desconocido,
|
||||
);
|
||||
});
|
||||
|
||||
test('billing no disponible -> desconocido, sin consultar', () async {
|
||||
var consultas = 0;
|
||||
iap.disponible = false;
|
||||
final servicio = servicioCon(() async {
|
||||
consultas++;
|
||||
return QueryPurchaseDetailsResponse(pastPurchases: const []);
|
||||
});
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.desconocido,
|
||||
);
|
||||
expect(consultas, 0);
|
||||
});
|
||||
|
||||
test('excepcion -> desconocido', () async {
|
||||
final servicio = servicioCon(
|
||||
() async => throw Exception('BillingClient desconectado'),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.desconocido,
|
||||
);
|
||||
});
|
||||
|
||||
test('timeout -> desconocido', () async {
|
||||
final servicio = servicioCon(
|
||||
() => Completer<QueryPurchaseDetailsResponse>().future,
|
||||
limite: const Duration(milliseconds: 10),
|
||||
);
|
||||
|
||||
expect(
|
||||
await servicio.consultarPropiedad(),
|
||||
ResultadoVerificacionLicencia.desconocido,
|
||||
);
|
||||
});
|
||||
|
||||
test('es silenciosa: no emite eventos de compra ni usa '
|
||||
'restorePurchases', () async {
|
||||
final servicio = servicioCon(
|
||||
() async => QueryPurchaseDetailsResponse(pastPurchases: const []),
|
||||
);
|
||||
final eventos = <EventoCompra>[];
|
||||
final sub = servicio.eventos.listen(eventos.add);
|
||||
addTearDown(sub.cancel);
|
||||
|
||||
await servicio.consultarPropiedad();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(eventos, isEmpty);
|
||||
expect(iap.restauracionesPedidas, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('idProducto es el identificador unico no-consumible', () {
|
||||
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||
|
||||
/// Android Auto (headless) trigger of the silent license check: the
|
||||
/// `registrar*` seam `main.dart` wires, exercised without a real
|
||||
/// `PluriWaveAudioHandler`.
|
||||
void main() {
|
||||
late int invalidaciones;
|
||||
|
||||
setUp(() {
|
||||
invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
registrarInvalidacionArbolAuto(() {});
|
||||
registrarVerificacionLicenciaAuto(null);
|
||||
});
|
||||
|
||||
test('sin verificador registrado es un no-op que nunca lanza', () async {
|
||||
registrarVerificacionLicenciaAuto(null);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test('una revocacion invalida el arbol de Auto', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => CambioLicencia.revocada);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 1);
|
||||
});
|
||||
|
||||
test('un desbloqueo silencioso tambien invalida el arbol', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => CambioLicencia.desbloqueada);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 1);
|
||||
});
|
||||
|
||||
test('sin cambios no invalida nada', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => CambioLicencia.sinCambios);
|
||||
|
||||
await dispararVerificacionLicenciaAuto();
|
||||
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test('un fallo del verificador se traga en silencio', () async {
|
||||
registrarVerificacionLicenciaAuto(() async => throw StateError('boom'));
|
||||
|
||||
await expectLater(dispararVerificacionLicenciaAuto(), completes);
|
||||
expect(invalidaciones, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'no retiene al llamador: devuelve antes de que la consulta acabe',
|
||||
() async {
|
||||
final pendiente = Completer<CambioLicencia>();
|
||||
registrarVerificacionLicenciaAuto(() => pendiente.future);
|
||||
|
||||
// The browse path calls this unawaited; the returned future being
|
||||
// pending here proves the verification runs in the background.
|
||||
var terminado = false;
|
||||
unawaited(
|
||||
dispararVerificacionLicenciaAuto().then((_) => terminado = true),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(terminado, isFalse);
|
||||
|
||||
pendiente.complete(CambioLicencia.revocada);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(terminado, isTrue);
|
||||
expect(invalidaciones, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Silent license re-verification policy (refund revocation): pure,
|
||||
/// headless-safe, driven by an injectable clock and a fake ownership query —
|
||||
/// zero plugin channels.
|
||||
|
||||
/// Scripted ownership query: returns [resultados] in order and counts calls.
|
||||
class _ConsultaFalsa {
|
||||
_ConsultaFalsa(this.resultados);
|
||||
|
||||
final List<ResultadoVerificacionLicencia> resultados;
|
||||
int llamadas = 0;
|
||||
|
||||
Future<ResultadoVerificacionLicencia> call() async {
|
||||
final resultado = resultados[llamadas.clamp(0, resultados.length - 1)];
|
||||
llamadas++;
|
||||
return resultado;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable clock so each test can move time forward between checks.
|
||||
class _Reloj {
|
||||
_Reloj(this.ahora);
|
||||
|
||||
DateTime ahora;
|
||||
|
||||
DateTime call() => ahora;
|
||||
|
||||
void avanzar(Duration d) => ahora = ahora.add(d);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _Reloj reloj;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
reloj = _Reloj(DateTime(2026, 9, 18, 10));
|
||||
});
|
||||
|
||||
Future<SharedPreferences> prefsCon({required bool premium}) async {
|
||||
SharedPreferences.setMockInitialValues({claveCompraPremium: premium});
|
||||
return SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
Future<CambioLicencia> verificar(
|
||||
SharedPreferences prefs,
|
||||
Future<ResultadoVerificacionLicencia> Function() consultar,
|
||||
) => verificarLicencia(consultar: consultar, prefs: prefs, reloj: reloj.call);
|
||||
|
||||
group('verificarLicencia', () {
|
||||
test('desconocido (offline / sin billing) conserva premium y no toca el '
|
||||
'contador de ausencias', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
final consulta = _ConsultaFalsa([
|
||||
ResultadoVerificacionLicencia.noPoseida,
|
||||
]);
|
||||
await verificar(prefs, consulta.call);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||
|
||||
reloj.avanzar(const Duration(days: 2));
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.desconocido,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||
});
|
||||
|
||||
test('una excepcion en la consulta no cambia nada (fail-open)', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => throw Exception('BillingClient desconectado'),
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||
});
|
||||
|
||||
test('poseida conserva premium y reinicia el contador', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||
|
||||
reloj.avanzar(const Duration(days: 1));
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.poseida,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||
});
|
||||
|
||||
test('poseida con la flag en false desbloquea en silencio '
|
||||
'(reinstalacion)', () async {
|
||||
final prefs = await prefsCon(premium: false);
|
||||
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.poseida,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.desbloqueada);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
});
|
||||
|
||||
test('una sola ausencia NO revoca', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
});
|
||||
|
||||
test('dos ausencias separadas por el umbral revocan', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
|
||||
reloj.avanzar(intervaloVerificacionLicencia);
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.revocada);
|
||||
expect(prefs.getBool(claveCompraPremium), isFalse);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||
});
|
||||
|
||||
test('dos ausencias demasiado juntas NO revocan', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
// Simulates the throttle having been bypassed (e.g. prefs cleared by
|
||||
// a second engine): only the spacing guard stands between a transient
|
||||
// empty Play cache and a false revocation.
|
||||
await prefs.remove(claveUltimaVerificacionLicencia);
|
||||
await prefs.remove(claveUltimoIntentoLicencia);
|
||||
|
||||
reloj.avanzar(separacionMinimaAusencias - const Duration(minutes: 1));
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
});
|
||||
|
||||
test('ausencia seguida de poseida reinicia: la siguiente ausencia '
|
||||
'vuelve a contar desde cero', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
final consulta = _ConsultaFalsa([
|
||||
ResultadoVerificacionLicencia.noPoseida,
|
||||
ResultadoVerificacionLicencia.poseida,
|
||||
ResultadoVerificacionLicencia.noPoseida,
|
||||
]);
|
||||
|
||||
await verificar(prefs, consulta.call);
|
||||
reloj.avanzar(const Duration(days: 1));
|
||||
await verificar(prefs, consulta.call);
|
||||
reloj.avanzar(const Duration(days: 1));
|
||||
final cambio = await verificar(prefs, consulta.call);
|
||||
|
||||
expect(consulta.llamadas, 3);
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isTrue);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), 1);
|
||||
});
|
||||
|
||||
test('noPoseida con la flag ya en false no hace nada', () async {
|
||||
final prefs = await prefsCon(premium: false);
|
||||
|
||||
final cambio = await verificar(
|
||||
prefs,
|
||||
() async => ResultadoVerificacionLicencia.noPoseida,
|
||||
);
|
||||
|
||||
expect(cambio, CambioLicencia.sinCambios);
|
||||
expect(prefs.getBool(claveCompraPremium), isFalse);
|
||||
expect(prefs.getInt(claveAusenciasLicencia), isNull);
|
||||
});
|
||||
|
||||
test('throttle: no vuelve a consultar dentro de las 24h tras una '
|
||||
'verificacion con respuesta', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
final consulta = _ConsultaFalsa([ResultadoVerificacionLicencia.poseida]);
|
||||
|
||||
await verificar(prefs, consulta.call);
|
||||
reloj.avanzar(intervaloVerificacionLicencia - const Duration(minutes: 1));
|
||||
await verificar(prefs, consulta.call);
|
||||
expect(consulta.llamadas, 1);
|
||||
|
||||
reloj.avanzar(const Duration(minutes: 1));
|
||||
await verificar(prefs, consulta.call);
|
||||
expect(consulta.llamadas, 2);
|
||||
});
|
||||
|
||||
test('throttle: tras un desconocido reintenta pasada la ventana corta, '
|
||||
'no antes', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
final consulta = _ConsultaFalsa([
|
||||
ResultadoVerificacionLicencia.desconocido,
|
||||
]);
|
||||
|
||||
await verificar(prefs, consulta.call);
|
||||
reloj.avanzar(intervaloReintentoLicencia - const Duration(minutes: 1));
|
||||
await verificar(prefs, consulta.call);
|
||||
expect(consulta.llamadas, 1);
|
||||
|
||||
reloj.avanzar(const Duration(minutes: 1));
|
||||
await verificar(prefs, consulta.call);
|
||||
expect(consulta.llamadas, 2);
|
||||
});
|
||||
|
||||
test(
|
||||
'un reloj que retrocede no bloquea la verificacion para siempre',
|
||||
() async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
final consulta = _ConsultaFalsa([
|
||||
ResultadoVerificacionLicencia.poseida,
|
||||
]);
|
||||
|
||||
await verificar(prefs, consulta.call);
|
||||
reloj.avanzar(const Duration(days: -3));
|
||||
await verificar(prefs, consulta.call);
|
||||
|
||||
expect(consulta.llamadas, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test('solo UNA verificacion en vuelo: llamadas concurrentes comparten la '
|
||||
'misma consulta', () async {
|
||||
final prefs = await prefsCon(premium: true);
|
||||
final respuesta = Completer<ResultadoVerificacionLicencia>();
|
||||
var llamadas = 0;
|
||||
Future<ResultadoVerificacionLicencia> consultar() {
|
||||
llamadas++;
|
||||
return respuesta.future;
|
||||
}
|
||||
|
||||
final a = verificar(prefs, consultar);
|
||||
final b = verificar(prefs, consultar);
|
||||
respuesta.complete(ResultadoVerificacionLicencia.poseida);
|
||||
await Future.wait([a, b]);
|
||||
|
||||
expect(llamadas, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,10 @@ class _PuertoComprasFalso implements PuertoCompras {
|
||||
@override
|
||||
Future<void> restaurar() async {}
|
||||
|
||||
@override
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
||||
ResultadoVerificacionLicencia.desconocido;
|
||||
|
||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||
|
||||
Future<void> dispose() => _eventos.close();
|
||||
|
||||
@@ -31,6 +31,10 @@ class _PuertoComprasFalso implements PuertoCompras {
|
||||
@override
|
||||
Future<void> restaurar() async {}
|
||||
|
||||
@override
|
||||
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
||||
ResultadoVerificacionLicencia.desconocido;
|
||||
|
||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||
|
||||
Future<void> dispose() => _eventos.close();
|
||||
@@ -334,8 +338,7 @@ void main() {
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create:
|
||||
(_) => EstadoEntitlement(prefs: null, compras: servicio),
|
||||
create: (_) => EstadoEntitlement(prefs: null, compras: servicio),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
|
||||
Reference in New Issue
Block a user