Compare commits
19
Commits
cbc54e915b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69eea0f2a1 | ||
|
|
0b7919e72e | ||
|
|
d7366bbf99 | ||
|
|
b1bf289e0d | ||
|
|
8fc3d99fbd | ||
|
|
a0fae57219 | ||
|
|
405dc18430 | ||
|
|
bcdf3d55c4 | ||
|
|
02609ec82c | ||
|
|
5f35ab7d6a | ||
|
|
241f81e535 | ||
|
|
a82dcc9c1b | ||
|
|
3449e2cb79 | ||
|
|
10bb017f4c | ||
|
|
a99df5d055 | ||
|
|
ab66f4985c | ||
|
|
d61c62540a | ||
|
|
25d5841d57 | ||
|
|
663fed5f41 |
+57
-13
@@ -68,7 +68,18 @@ jobs:
|
|||||||
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
||||||
echo "✅ Keystore configurado"
|
echo "✅ Keystore configurado"
|
||||||
|
|
||||||
- name: Bump versión patch + commit
|
# PRO owns the version NAME; every branch advances the build NUMBER.
|
||||||
|
#
|
||||||
|
# Previously main also bumped its patch on every push, so main's semver
|
||||||
|
# raced permanently ahead of PRO's (main hit 1.3.3 while the branch that
|
||||||
|
# actually ships sat at 1.3.0). That buried the release artifacts under a
|
||||||
|
# dev branch on builds.freetimelab.es, which sorts by version, and made
|
||||||
|
# every main<->PRO merge conflict on pubspec.yaml.
|
||||||
|
#
|
||||||
|
# The build number still advances everywhere: Google Play requires it to
|
||||||
|
# be monotonic across the whole app, so two branches must never mint the
|
||||||
|
# same code.
|
||||||
|
- name: Bump versión + commit
|
||||||
run: |
|
run: |
|
||||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
BRANCH="${CURRENT_REF#refs/heads/}"
|
||||||
git config user.name "ShanaiaBot"
|
git config user.name "ShanaiaBot"
|
||||||
@@ -77,12 +88,20 @@ jobs:
|
|||||||
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
|
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
|
||||||
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
|
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
|
||||||
NEW_BUILD=$((BUILD + 1))
|
NEW_BUILD=$((BUILD + 1))
|
||||||
# If the triggering commit explicitly pins the version name via the
|
|
||||||
# [version set] marker, ship that semver as-is (a milestone like 1.0.0
|
# Look for [version set] across EVERY commit this push introduced,
|
||||||
# or a major/minor jump the automatic patch bump cannot reach) and only
|
# not just the tip. `git pull` inserts an auto-generated merge commit
|
||||||
# advance the build number, which Google Play requires to stay
|
# whose message carries no marker, which silently discarded a pinned
|
||||||
# monotonic. Otherwise keep the default automatic patch+build bump.
|
# version name and bumped 1.3.0 to 1.3.1 behind our backs.
|
||||||
if git log -1 --pretty=%B | grep -q '\[version set\]'; then
|
RANGO="${{ gitea.event.before }}..${{ gitea.sha }}"
|
||||||
|
if git log "$RANGO" --pretty=%B 2>/dev/null | grep -q '\[version set\]'; then
|
||||||
|
MARCADOR="si"
|
||||||
|
else
|
||||||
|
MARCADOR="no"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BRANCH" != "PRO" ] || [ "$MARCADOR" = "si" ]; then
|
||||||
|
# Non-release branches never touch the name; PRO respects a pin.
|
||||||
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
|
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
|
||||||
else
|
else
|
||||||
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
|
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
|
||||||
@@ -91,6 +110,8 @@ jobs:
|
|||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
|
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "rama=${BRANCH} marcador=${MARCADOR} ${CURRENT} -> ${NEW_VERSION}"
|
||||||
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
|
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
|
||||||
git add pubspec.yaml
|
git add pubspec.yaml
|
||||||
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
|
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
|
||||||
@@ -255,7 +276,29 @@ jobs:
|
|||||||
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
|
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
|
||||||
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
|
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
|
||||||
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
|
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
|
||||||
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
|
# La rama va en el NOMBRE DE LA APP, no en una subcarpeta.
|
||||||
|
#
|
||||||
|
# El objetivo sigue siendo el de siempre: que main y PRO no se mezclen
|
||||||
|
# en el portal, que ordena por número de versión y mostraba el build
|
||||||
|
# de desarrollo como "última versión" por delante del de release.
|
||||||
|
#
|
||||||
|
# Pero la primera solución metía la rama como TERCER nivel
|
||||||
|
# (pluriwave/main/v1.3.3/) y el portal indexa solo DOS —
|
||||||
|
# <app>/<versión>/<ficheros> —, así que desde el 29-08 ningún build de
|
||||||
|
# main volvió a aparecer en builds.freetimelab.es aunque el job saliera
|
||||||
|
# verde: el scp subía bien, a una ruta que el indexador no lee. Nada
|
||||||
|
# avisaba, y el echo de abajo se comía la rama y mandaba a la carpeta
|
||||||
|
# antigua, que llevaba congelada desde el +157.
|
||||||
|
#
|
||||||
|
# Con la rama en el nombre, PRO conserva la entrada limpia "pluriwave"
|
||||||
|
# y main tiene la suya, igual que ya conviven radar-foral y
|
||||||
|
# radar-foral-android.
|
||||||
|
if [ "$BRANCH" = "PRO" ]; then
|
||||||
|
APP="pluriwave"
|
||||||
|
else
|
||||||
|
APP="pluriwave-$(echo "$BRANCH" | tr '/' '-')"
|
||||||
|
fi
|
||||||
|
DESTINO="/opt/ftl-builds/builds/${APP}/v${VERSION}"
|
||||||
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
|
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
|
||||||
|
|
||||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
|
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
|
||||||
@@ -265,8 +308,11 @@ jobs:
|
|||||||
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
|
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
|
||||||
build/app/outputs/bundle/release/app-release.aab \
|
build/app/outputs/bundle/release/app-release.aab \
|
||||||
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
|
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
|
||||||
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
|
# La ruta se imprime desde ${APP}, no a mano: la version anterior tenia
|
||||||
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
|
# "pluriwave" escrito a fuego y mandaba a la carpeta equivocada cada
|
||||||
|
# vez que se compilaba algo que no fuera PRO.
|
||||||
|
echo "✅ APK: builds.freetimelab.es → ${APP} → v${VERSION} → ${APK_NOMBRE}"
|
||||||
|
echo "✅ AAB: builds.freetimelab.es → ${APP} → v${VERSION} → ${AAB_NOMBRE}"
|
||||||
|
|
||||||
# La publicacion automatica en Google Play es OPCIONAL.
|
# La publicacion automatica en Google Play es OPCIONAL.
|
||||||
#
|
#
|
||||||
@@ -319,9 +365,7 @@ jobs:
|
|||||||
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
|
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
|
||||||
if [ "${{ job.status }}" = "success" ]; then
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
|
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
|
||||||
# Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
|
# Solo se anuncia la subida a Play cuando de verdad ocurrio.
|
||||||
# se omite si falta el secreto, y un aviso que dice "publicado"
|
|
||||||
# cuando no se publico es peor que no avisar.
|
|
||||||
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
|
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
|
||||||
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
|
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
|
||||||
elif [ "$BRANCH" = "PRO" ]; then
|
elif [ "$BRANCH" = "PRO" ]; then
|
||||||
|
|||||||
@@ -205,6 +205,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
|
|||||||
// without a live native sink. Re-subscribe and re-seed the active device
|
// without a live native sink. Re-subscribe and re-seed the active device
|
||||||
// (no-op when multi-device EQ is off).
|
// (no-op when multi-device EQ is off).
|
||||||
unawaited(context.read<EstadoEcualizador>().refrescarDispositivoActual());
|
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
|
@override
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
|
||||||
import '../servicios/servicio_compras.dart';
|
import '../servicios/servicio_compras.dart';
|
||||||
|
import '../servicios/verificacion_licencia.dart';
|
||||||
|
|
||||||
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
|
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
|
||||||
/// premium unlock. Older builds that predate this key simply never read it —
|
/// premium unlock. Older builds that predate this key simply never read it —
|
||||||
/// no migration needed (Rollout "Versioned key ... is ignored by older
|
/// no migration needed (Rollout "Versioned key ... is ignored by older
|
||||||
/// builds").
|
/// builds"). Shared with the silent license re-verification
|
||||||
const _keyPremium = 'compra_premium_v1';
|
/// (`verificacion_licencia.dart`), which may revoke it after a refund.
|
||||||
|
const _keyPremium = claveCompraPremium;
|
||||||
|
|
||||||
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
|
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
|
||||||
/// Entitlement Read"): resolves the persisted premium flag directly from
|
/// Entitlement Read"): resolves the persisted premium flag directly from
|
||||||
@@ -55,14 +57,21 @@ enum ResultadoEntitlementUsuario {
|
|||||||
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
|
||||||
/// since no `Provider` exists on that path.
|
/// since no `Provider` exists on that path.
|
||||||
class EstadoEntitlement extends ChangeNotifier {
|
class EstadoEntitlement extends ChangeNotifier {
|
||||||
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
|
EstadoEntitlement({
|
||||||
: _prefs = prefs,
|
SharedPreferences? prefs,
|
||||||
_compras = compras {
|
PuertoCompras? compras,
|
||||||
|
DateTime Function()? reloj,
|
||||||
|
}) : _prefs = prefs,
|
||||||
|
_compras = compras,
|
||||||
|
_reloj = reloj {
|
||||||
final flujo = _compras;
|
final flujo = _compras;
|
||||||
if (flujo != null) {
|
if (flujo != null) {
|
||||||
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
|
_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"),
|
/// The single non-consumable product id (Design "Interfaces / Contracts"),
|
||||||
@@ -72,7 +81,11 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
|
|
||||||
final SharedPreferences? _prefs;
|
final SharedPreferences? _prefs;
|
||||||
final PuertoCompras? _compras;
|
final PuertoCompras? _compras;
|
||||||
|
|
||||||
|
/// Injectable clock for the license check's throttle/spacing rules.
|
||||||
|
final DateTime Function()? _reloj;
|
||||||
StreamSubscription<EventoCompra>? _comprasSub;
|
StreamSubscription<EventoCompra>? _comprasSub;
|
||||||
|
bool _desechado = false;
|
||||||
|
|
||||||
bool _esPremium = false;
|
bool _esPremium = false;
|
||||||
bool _compraEnCurso = false;
|
bool _compraEnCurso = false;
|
||||||
@@ -106,6 +119,53 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
Future<SharedPreferences> _resolverPrefs() async =>
|
Future<SharedPreferences> _resolverPrefs() async =>
|
||||||
_prefs ?? SharedPreferences.getInstance();
|
_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
|
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
|
||||||
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
/// already premium (Spec "Already-purchased attempt is idempotent") — no
|
||||||
/// duplicate charge is even attempted.
|
/// duplicate charge is even attempted.
|
||||||
@@ -171,11 +231,19 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _desbloquear() async {
|
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;
|
final yaEraPremium = _esPremium;
|
||||||
_esPremium = true;
|
_esPremium = true;
|
||||||
_compraEnCurso = false;
|
_compraEnCurso = false;
|
||||||
final prefs = await _resolverPrefs();
|
final escritura = prefs.setBool(_keyPremium, true);
|
||||||
await 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();
|
notifyListeners();
|
||||||
if (!yaEraPremium) {
|
if (!yaEraPremium) {
|
||||||
// Orchestrator-resolved open question (design.md): actively
|
// Orchestrator-resolved open question (design.md): actively
|
||||||
@@ -187,6 +255,7 @@ class EstadoEntitlement extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_desechado = true;
|
||||||
_comprasSub?.cancel();
|
_comprasSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -20,6 +20,7 @@ import 'servicios/servicio_compras.dart';
|
|||||||
import 'servicios/servicio_consentimiento.dart';
|
import 'servicios/servicio_consentimiento.dart';
|
||||||
import 'servicios/servicio_ecualizador.dart';
|
import 'servicios/servicio_ecualizador.dart';
|
||||||
import 'servicios/servicio_presets_personalizados.dart';
|
import 'servicios/servicio_presets_personalizados.dart';
|
||||||
|
import 'servicios/verificacion_licencia.dart';
|
||||||
import 'tema/pluriwave_tokens.dart';
|
import 'tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
const _anchoMinimoLandscape = 600.0;
|
const _anchoMinimoLandscape = 600.0;
|
||||||
@@ -146,6 +147,16 @@ Future<void> main() async {
|
|||||||
// injected into every state/service below.
|
// injected into every state/service below.
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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
|
// User-saved EQ presets for the car's Ecualizador folder, same
|
||||||
// injectable-prefs DI convention and same pre-init placement as the two
|
// injectable-prefs DI convention and same pre-init placement as the two
|
||||||
// registrations above (neither depends on the AudioHandler). Passed as a
|
// registrations above (neither depends on the AudioHandler). Passed as a
|
||||||
@@ -354,8 +365,7 @@ bool debeInvalidarArbolAutoAlReanudar({
|
|||||||
required AppLifecycleState estado,
|
required AppLifecycleState estado,
|
||||||
required bool hayCocheSuscrito,
|
required bool hayCocheSuscrito,
|
||||||
required bool yaInvalidado,
|
required bool yaInvalidado,
|
||||||
}) =>
|
}) => !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
||||||
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
|
||||||
|
|
||||||
/// Root wrapper that keeps the orientation policy applied and owns the
|
/// Root wrapper that keeps the orientation policy applied and owns the
|
||||||
/// Android Auto browse-tree recovery hook.
|
/// Android Auto browse-tree recovery hook.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'emisoras_destacadas.dart';
|
|||||||
import 'musica_local_auto.dart';
|
import 'musica_local_auto.dart';
|
||||||
import 'navegacion_auto.dart';
|
import 'navegacion_auto.dart';
|
||||||
import 'servicio_audio_session.dart';
|
import 'servicio_audio_session.dart';
|
||||||
|
import 'verificacion_licencia.dart' show CambioLicencia;
|
||||||
|
|
||||||
/// Estado de reproducción expuesto al UI.
|
/// Estado de reproducción expuesto al UI.
|
||||||
enum EstadoReproduccion {
|
enum EstadoReproduccion {
|
||||||
@@ -383,6 +384,36 @@ void invalidarArbolAuto() {
|
|||||||
_invalidarArbolAutoGlobal?.call();
|
_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
|
/// 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).
|
/// 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
|
// [resolverLocalizacionesRespaldo], so this works on the engine
|
||||||
// Android Auto starts without an Activity -- which is the only engine
|
// Android Auto starts without an Activity -- which is the only engine
|
||||||
// a Play reviewer ever gets.
|
// 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 etiquetas = etiquetasArbolAutoDesde(_textos);
|
||||||
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
||||||
// The "recent" root, resolved BEFORE the entitlement gate.
|
// The "recent" root, resolved BEFORE the entitlement gate.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/foundation.dart' show debugPrint;
|
import 'package:flutter/foundation.dart' show debugPrint;
|
||||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
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
|
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
|
||||||
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
|
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
|
||||||
@@ -38,6 +39,25 @@ class EventoCompra {
|
|||||||
final String? mensaje;
|
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
|
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
|
||||||
/// this port, never on `in_app_purchase` directly — matches
|
/// this port, never on `in_app_purchase` directly — matches
|
||||||
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
|
/// `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.
|
/// Re-queries Play Billing for a prior purchase on this account.
|
||||||
Future<void> restaurar();
|
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
|
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
|
||||||
/// depends on [PuertoCompras] instead.
|
/// depends on [PuertoCompras] instead.
|
||||||
class ServicioComprasPlayBilling implements PuertoCompras {
|
class ServicioComprasPlayBilling implements PuertoCompras {
|
||||||
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
|
ServicioComprasPlayBilling({
|
||||||
: _iap = inAppPurchase ?? InAppPurchase.instance {
|
InAppPurchase? inAppPurchase,
|
||||||
|
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
|
||||||
|
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
|
||||||
|
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
|
||||||
|
_consultarComprasPasadasInyectada = consultarComprasPasadas,
|
||||||
|
_limiteConsultaPropiedad = limiteConsultaPropiedad {
|
||||||
_sub = _iap.purchaseStream.listen(
|
_sub = _iap.purchaseStream.listen(
|
||||||
_alRecibirCompras,
|
_alRecibirCompras,
|
||||||
onError: (Object error) {
|
onError: (Object error) {
|
||||||
@@ -77,6 +108,15 @@ class ServicioComprasPlayBilling implements PuertoCompras {
|
|||||||
static const idProducto = 'pluriwave_premium';
|
static const idProducto = 'pluriwave_premium';
|
||||||
|
|
||||||
final InAppPurchase _iap;
|
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();
|
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||||
StreamSubscription<List<PurchaseDetails>>? _sub;
|
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) {
|
void _alRecibirCompras(List<PurchaseDetails> compras) {
|
||||||
if (compras.isEmpty) {
|
if (compras.isEmpty) {
|
||||||
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
|
// `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> {
|
extension<T> on List<T> {
|
||||||
T? get firstOrNull => isEmpty ? null : first;
|
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
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
in_app_purchase_android:
|
in_app_purchase_android:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: in_app_purchase_android
|
name: in_app_purchase_android
|
||||||
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
|
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,7 @@
|
|||||||
name: pluriwave
|
name: pluriwave
|
||||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.3.3+161
|
version: 1.3.3+163
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.0
|
sdk: ^3.7.0
|
||||||
@@ -55,6 +55,10 @@ dependencies:
|
|||||||
|
|
||||||
# In-app purchase
|
# In-app purchase
|
||||||
in_app_purchase: ^3.2.0
|
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
|
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
|
||||||
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
|
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:pluriwave/estado/estado_entitlement.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/servicio_compras.dart';
|
||||||
|
import 'package:pluriwave/servicios/verificacion_licencia.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
|
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
|
||||||
@@ -11,6 +14,12 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
final _eventos = StreamController<EventoCompra>.broadcast();
|
final _eventos = StreamController<EventoCompra>.broadcast();
|
||||||
int comprasIntentadas = 0;
|
int comprasIntentadas = 0;
|
||||||
int restaurosIntentados = 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
|
@override
|
||||||
Stream<EventoCompra> get eventos => _eventos.stream;
|
Stream<EventoCompra> get eventos => _eventos.stream;
|
||||||
@@ -25,11 +34,32 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
restaurosIntentados++;
|
restaurosIntentados++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
|
||||||
|
consultasPropiedad++;
|
||||||
|
return propiedad;
|
||||||
|
}
|
||||||
|
|
||||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
Future<void> dispose() => _eventos.close();
|
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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
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)', () {
|
group('esPremiumPersistido (headless, sin BuildContext)', () {
|
||||||
test('lee la flag persistida directamente desde prefs', () async {
|
test('lee la flag persistida directamente desde prefs', () async {
|
||||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:in_app_purchase/in_app_purchase.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';
|
import 'package:pluriwave/servicios/servicio_compras.dart';
|
||||||
|
|
||||||
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
|
/// 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 {
|
class _InAppPurchaseFalso implements InAppPurchase {
|
||||||
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
||||||
int restauracionesPedidas = 0;
|
int restauracionesPedidas = 0;
|
||||||
|
bool disponible = true;
|
||||||
final completadas = <PurchaseDetails>[];
|
final completadas = <PurchaseDetails>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isAvailable() async => disponible;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
||||||
|
|
||||||
@@ -52,6 +58,40 @@ PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
|
|||||||
status: status,
|
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() {
|
void main() {
|
||||||
group('eventoDesdeEstadoCompra', () {
|
group('eventoDesdeEstadoCompra', () {
|
||||||
test('purchased -> comprada', () {
|
test('purchased -> comprada', () {
|
||||||
@@ -137,8 +177,8 @@ void main() {
|
|||||||
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
||||||
addTearDown(servicio.dispose);
|
addTearDown(servicio.dispose);
|
||||||
|
|
||||||
final compra =
|
final compra = _compraFalsa(PurchaseStatus.purchased)
|
||||||
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
|
..pendingCompletePurchase = true;
|
||||||
iap.emitir(<PurchaseDetails>[compra]);
|
iap.emitir(<PurchaseDetails>[compra]);
|
||||||
await Future<void>.delayed(Duration.zero);
|
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', () {
|
test('idProducto es el identificador unico no-consumible', () {
|
||||||
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
|
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
|
@override
|
||||||
Future<void> restaurar() async {}
|
Future<void> restaurar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
||||||
|
ResultadoVerificacionLicencia.desconocido;
|
||||||
|
|
||||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
Future<void> dispose() => _eventos.close();
|
Future<void> dispose() => _eventos.close();
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class _PuertoComprasFalso implements PuertoCompras {
|
|||||||
@override
|
@override
|
||||||
Future<void> restaurar() async {}
|
Future<void> restaurar() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
||||||
|
ResultadoVerificacionLicencia.desconocido;
|
||||||
|
|
||||||
void emitir(EventoCompra evento) => _eventos.add(evento);
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
||||||
|
|
||||||
Future<void> dispose() => _eventos.close();
|
Future<void> dispose() => _eventos.close();
|
||||||
@@ -334,8 +338,7 @@ void main() {
|
|||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider<EstadoEntitlement>(
|
ChangeNotifierProvider<EstadoEntitlement>(
|
||||||
create:
|
create: (_) => EstadoEntitlement(prefs: null, compras: servicio),
|
||||||
(_) => EstadoEntitlement(prefs: null, compras: servicio),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
|||||||
Reference in New Issue
Block a user