diff --git a/lib/app.dart b/lib/app.dart index b9fa4bd..0e02324 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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().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().refrescarLicencia()); } @override diff --git a/lib/estado/estado_entitlement.dart b/lib/estado/estado_entitlement.dart index cbeadf8..a3a235a 100644 --- a/lib/estado/estado_entitlement.dart +++ b/lib/estado/estado_entitlement.dart @@ -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? _comprasSub; + bool _desechado = false; bool _esPremium = false; bool _compraEnCurso = false; @@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier { Future _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 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 _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 _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(); } diff --git a/lib/main.dart b/lib/main.dart index f37ff2e..f300006 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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. diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 298c5c6..06e7d9b 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -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 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 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 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. diff --git a/lib/servicios/servicio_compras.dart b/lib/servicios/servicio_compras.dart index c02405e..8c72362 100644 --- a/lib/servicios/servicio_compras.dart +++ b/lib/servicios/servicio_compras.dart @@ -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 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 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 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 Function()? + _consultarComprasPasadasInyectada; + + /// Upper bound for [consultarPropiedad]: a hung BillingClient connection + /// resolves to [ResultadoVerificacionLicencia.desconocido]. + final Duration _limiteConsultaPropiedad; final _eventos = StreamController.broadcast(); StreamSubscription>? _sub; @@ -125,6 +165,33 @@ class ServicioComprasPlayBilling implements PuertoCompras { } } + @override + Future consultarPropiedad() async { + try { + final disponible = await _iap.isAvailable().timeout( + _limiteConsultaPropiedad, + ); + if (!disponible) return ResultadoVerificacionLicencia.desconocido; + final consultar = + _consultarComprasPasadasInyectada ?? + () => + _iap + .getPlatformAddition() + .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 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 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 on List { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/servicios/verificacion_licencia.dart b/lib/servicios/verificacion_licencia.dart new file mode 100644 index 0000000..8773986 --- /dev/null +++ b/lib/servicios/verificacion_licencia.dart @@ -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? _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; +} diff --git a/pubspec.lock b/pubspec.lock index f7d7860..c4201dc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 diff --git a/pubspec.yaml b/pubspec.yaml index 9a343f1..96eb6ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/estado/estado_entitlement_test.dart b/test/estado/estado_entitlement_test.dart index d343454..7ec5a9f 100644 --- a/test/estado/estado_entitlement_test.dart +++ b/test/estado/estado_entitlement_test.dart @@ -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.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 get eventos => _eventos.stream; @@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras { restaurosIntentados++; } + @override + Future consultarPropiedad() async { + consultasPropiedad++; + return propiedad; + } + void emitir(EventoCompra evento) => _eventos.add(evento); Future 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 _asentar() async { + for (var i = 0; i < 10; i++) { + await Future.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 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}); diff --git a/test/servicios/servicio_compras_test.dart b/test/servicios/servicio_compras_test.dart index 1950b0b..d9ded40 100644 --- a/test/servicios/servicio_compras_test.dart +++ b/test/servicios/servicio_compras_test.dart @@ -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>.broadcast(); int restauracionesPedidas = 0; + bool disponible = true; final completadas = []; + @override + Future isAvailable() async => disponible; + @override Stream> 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: [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([compra]); await Future.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 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().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 = []; + final sub = servicio.eventos.listen(eventos.add); + addTearDown(sub.cancel); + + await servicio.consultarPropiedad(); + await Future.delayed(Duration.zero); + + expect(eventos, isEmpty); + expect(iap.restauracionesPedidas, 0); + }); + }); + test('idProducto es el identificador unico no-consumible', () { expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium'); }); diff --git a/test/servicios/verificacion_licencia_auto_test.dart b/test/servicios/verificacion_licencia_auto_test.dart new file mode 100644 index 0000000..23d252c --- /dev/null +++ b/test/servicios/verificacion_licencia_auto_test.dart @@ -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(); + 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.delayed(Duration.zero); + expect(terminado, isFalse); + + pendiente.complete(CambioLicencia.revocada); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + expect(terminado, isTrue); + expect(invalidaciones, 1); + }, + ); +} diff --git a/test/servicios/verificacion_licencia_test.dart b/test/servicios/verificacion_licencia_test.dart new file mode 100644 index 0000000..564b08b --- /dev/null +++ b/test/servicios/verificacion_licencia_test.dart @@ -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 resultados; + int llamadas = 0; + + Future 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 prefsCon({required bool premium}) async { + SharedPreferences.setMockInitialValues({claveCompraPremium: premium}); + return SharedPreferences.getInstance(); + } + + Future verificar( + SharedPreferences prefs, + Future 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(); + var llamadas = 0; + Future 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); + }); + }); +} diff --git a/test/widgets/banner_anuncio_superior_test.dart b/test/widgets/banner_anuncio_superior_test.dart index 2e4799c..0c5d20d 100644 --- a/test/widgets/banner_anuncio_superior_test.dart +++ b/test/widgets/banner_anuncio_superior_test.dart @@ -24,6 +24,10 @@ class _PuertoComprasFalso implements PuertoCompras { @override Future restaurar() async {} + @override + Future consultarPropiedad() async => + ResultadoVerificacionLicencia.desconocido; + void emitir(EventoCompra evento) => _eventos.add(evento); Future dispose() => _eventos.close(); diff --git a/test/widgets/hoja_premium_test.dart b/test/widgets/hoja_premium_test.dart index 6b0af55..db6358c 100644 --- a/test/widgets/hoja_premium_test.dart +++ b/test/widgets/hoja_premium_test.dart @@ -31,6 +31,10 @@ class _PuertoComprasFalso implements PuertoCompras { @override Future restaurar() async {} + @override + Future consultarPropiedad() async => + ResultadoVerificacionLicencia.desconocido; + void emitir(EventoCompra evento) => _eventos.add(evento); Future dispose() => _eventos.close(); @@ -334,8 +338,7 @@ void main() { MultiProvider( providers: [ ChangeNotifierProvider( - create: - (_) => EstadoEntitlement(prefs: null, compras: servicio), + create: (_) => EstadoEntitlement(prefs: null, compras: servicio), ), ], child: MaterialApp(