Revision previa al envio a produccion. Cada punto se verifico en el codigo antes de tocarlo; lo que ya estaba bien se dejo como estaba. Ubicacion: se declaraba precision fina sin usarla El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo que se penaliza en revision. Verificado que los manifiestos de geolocator_android y geocoding_android no declaran permisos propios, asi que el merge no lo reinyecta y no hace falta `tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio funcional: la deteccion de pais sigue igual. El paywall vendia Android Auto como exclusivo, y ya no lo es La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito recibio una carpeta navegable con emisoras reproducibles cuando hubo que cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier gratuito ya tiene expone a reclamacion y a que se cite en revision. Microfono: se pide al activar el visualizador, no antes Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto. Grabacion: uso privado de verdad, no solo en el aviso La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con `Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en silencio. Se añade ademas el aviso de uso privado en esa pantalla. `recordingActionShare` la usaban DOS botones con significados distintos: el de grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para que al leer el codigo pareciera que solo se compartian enlaces. Separadas en `stationActionShare` y `recordingActionOpenIn`. La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion. Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
391 lines
13 KiB
Dart
391 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:in_app_purchase/in_app_purchase.dart';
|
|
import 'package:pluriwave/estado/estado_entitlement.dart';
|
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
|
import 'package:pluriwave/servicios/servicio_compras.dart';
|
|
import 'package:pluriwave/widgets/hoja_premium.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// fix/import-alarmas-y-paywall: a purchase sheet the user cannot escape is
|
|
/// a dark pattern and a Play policy risk. These cover the dismiss
|
|
/// affordance, the honest/concrete feature list, and the equalizer guard
|
|
/// (the phone equalizer is free for everyone and must never be presented as
|
|
/// a premium feature — this has regressed conceptually before).
|
|
|
|
/// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test
|
|
/// drive [EstadoEntitlement]'s purchase-stream events without touching
|
|
/// `in_app_purchase`.
|
|
class _PuertoComprasFalso implements PuertoCompras {
|
|
final _eventos = StreamController<EventoCompra>.broadcast();
|
|
|
|
@override
|
|
Stream<EventoCompra> get eventos => _eventos.stream;
|
|
|
|
@override
|
|
Future<void> comprar() async {}
|
|
|
|
@override
|
|
Future<void> restaurar() async {}
|
|
|
|
@override
|
|
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
|
|
ResultadoVerificacionLicencia.desconocido;
|
|
|
|
void emitir(EventoCompra evento) => _eventos.add(evento);
|
|
|
|
Future<void> dispose() => _eventos.close();
|
|
}
|
|
|
|
/// Fake [InAppPurchase]: permite montar el paywall sobre el
|
|
/// `ServicioComprasPlayBilling` REAL (no un `PuertoCompras` falso) para cubrir
|
|
/// el bloqueo del paywall de extremo a extremo. `noSuchMethod` cubre el resto
|
|
/// de la API del plugin, que este test no ejercita.
|
|
class _InAppPurchaseFalso implements InAppPurchase {
|
|
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
|
|
|
|
@override
|
|
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
|
|
|
|
@override
|
|
Future<void> restorePurchases({String? applicationUserName}) async {
|
|
// Play Billing publica un lote VACÍO cuando no hay nada que restaurar.
|
|
_compras.add(const <PurchaseDetails>[]);
|
|
}
|
|
|
|
Future<void> dispose() => _compras.close();
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
/// FIX 3 / FIX 9 (code review): the paywall must show localized feedback for
|
|
/// a failed purchase/restore, a distinct non-error confirmation when a
|
|
/// restore finds nothing, and its own dedicated "premium active" string
|
|
/// instead of reusing the equalizer's `equalizerActive` translation.
|
|
void main() {
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
late AppLocalizations l10n;
|
|
|
|
Future<EstadoEntitlement> bombear(
|
|
WidgetTester tester, {
|
|
required _PuertoComprasFalso compras,
|
|
}) async {
|
|
late EstadoEntitlement estado;
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<EstadoEntitlement>(
|
|
create: (_) {
|
|
estado = EstadoEntitlement(prefs: null, compras: compras);
|
|
return estado;
|
|
},
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
locale: const Locale('es'),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: const Scaffold(body: HojaPremium()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
|
|
return estado;
|
|
}
|
|
|
|
/// Presents `HojaPremium` through the REAL `mostrarHojaPremium` modal
|
|
/// route (unlike [bombear], which embeds it directly with no route to
|
|
/// pop) so the dismiss controls can be exercised end-to-end exactly as a
|
|
/// user would encounter them.
|
|
Future<void> bombearComoHoja(
|
|
WidgetTester tester, {
|
|
required _PuertoComprasFalso compras,
|
|
}) async {
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<EstadoEntitlement>(
|
|
create: (_) => EstadoEntitlement(prefs: null, compras: compras),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
locale: const Locale('es'),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: Builder(
|
|
builder:
|
|
(context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => mostrarHojaPremium(context),
|
|
child: const Text('abrir'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.tap(find.text('abrir'));
|
|
await tester.pumpAndSettle();
|
|
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
|
|
}
|
|
|
|
group('fix/import-alarmas-y-paywall — dismissibility', () {
|
|
testWidgets('the close (X) control closes the sheet without purchasing or '
|
|
'restoring', (tester) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombearComoHoja(tester, compras: compras);
|
|
|
|
expect(find.byType(HojaPremium), findsOneWidget);
|
|
|
|
await tester.tap(find.byKey(const ValueKey('hoja-premium-cerrar')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(HojaPremium), findsNothing);
|
|
});
|
|
|
|
testWidgets(
|
|
'the "not now" secondary action closes the sheet without purchasing '
|
|
'or restoring',
|
|
(tester) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombearComoHoja(tester, compras: compras);
|
|
|
|
expect(find.byType(HojaPremium), findsOneWidget);
|
|
|
|
await tester.tap(find.byKey(const ValueKey('hoja-premium-ahora-no')));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(HojaPremium), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets('the system back gesture also closes the sheet (default '
|
|
'isDismissible/enableDrag, no PopScope blocking it)', (tester) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombearComoHoja(tester, compras: compras);
|
|
|
|
expect(find.byType(HojaPremium), findsOneWidget);
|
|
|
|
await tester.binding.handlePopRoute();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(HojaPremium), findsNothing);
|
|
});
|
|
|
|
testWidgets(
|
|
'an already-premium user still gets the close control (no decline '
|
|
'needed) and no "not now" button',
|
|
(tester) async {
|
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombearComoHoja(tester, compras: compras);
|
|
|
|
expect(
|
|
find.byKey(const ValueKey('hoja-premium-cerrar')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
find.byKey(const ValueKey('hoja-premium-ahora-no')),
|
|
findsNothing,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('fix/import-alarmas-y-paywall — honest, concrete copy', () {
|
|
testWidgets('lists the 5 features premium actually unlocks', (
|
|
tester,
|
|
) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
|
|
expect(find.text(l10n.premiumBeneficioSinAnuncios), findsOneWidget);
|
|
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
|
|
expect(find.text(l10n.premiumBeneficioGrabacion), findsOneWidget);
|
|
expect(find.text(l10n.premiumBeneficioVacaciones), findsOneWidget);
|
|
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'the Android Auto bullet names what PRO adds in the car, not the '
|
|
'product itself (free already gets a playable featured folder)',
|
|
(tester) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
|
|
expect(l10n.premiumBeneficioAndroidAuto, isNot('Android Auto'));
|
|
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('states this is a one-time purchase, not a subscription', (
|
|
tester,
|
|
) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
|
|
expect(find.text(l10n.premiumPagoUnico), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'GUARD: the phone equalizer is never presented as a premium feature',
|
|
(tester) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
|
|
expect(find.text(l10n.equalizerTitle), findsNothing);
|
|
expect(find.text(l10n.equalizerActive), findsNothing);
|
|
expect(find.textContaining('cualizador'), findsNothing);
|
|
expect(find.textContaining('qualizer'), findsNothing);
|
|
},
|
|
);
|
|
});
|
|
|
|
group(
|
|
'FIX 9 — la etiqueta de premium activo es propia, no la del ecualizador',
|
|
() {
|
|
testWidgets(
|
|
'usuario premium: muestra l10n.premiumActivo, nunca l10n.equalizerActive',
|
|
(tester) async {
|
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.premiumActivo), findsOneWidget);
|
|
expect(find.text(l10n.equalizerActive), findsNothing);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
group('FIX 3 — feedback de error/restauración en el paywall', () {
|
|
testWidgets('un error de compra muestra el mensaje localizado genérico '
|
|
'(l10n.compraError), nunca el texto interno de EventoCompra.mensaje', (
|
|
tester,
|
|
) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
|
|
compras.emitir(
|
|
const EventoCompra(
|
|
TipoEventoCompra.error,
|
|
mensaje: 'Producto no encontrado en Play Console',
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.compraError), findsOneWidget);
|
|
expect(find.text('Producto no encontrado en Play Console'), findsNothing);
|
|
});
|
|
|
|
testWidgets('una restauración sin compras muestra su propia confirmación '
|
|
'(l10n.restauracionSinCompras), distinta del mensaje de error', (
|
|
tester,
|
|
) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
await bombear(tester, compras: compras);
|
|
|
|
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.restauracionSinCompras), findsOneWidget);
|
|
expect(find.text(l10n.compraError), findsNothing);
|
|
});
|
|
|
|
testWidgets(
|
|
'descartar el mensaje de error llama a consumirResultadoUsuario() y '
|
|
'lo oculta de la UI',
|
|
(tester) async {
|
|
final compras = _PuertoComprasFalso();
|
|
addTearDown(compras.dispose);
|
|
final estado = await bombear(tester, compras: compras);
|
|
|
|
compras.emitir(const EventoCompra(TipoEventoCompra.error));
|
|
await tester.pump();
|
|
await tester.pump();
|
|
expect(find.text(l10n.compraError), findsOneWidget);
|
|
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('hoja-premium-resultado-descartar')),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text(l10n.compraError), findsNothing);
|
|
expect(estado.resultadoUsuario, isNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('bloqueo del paywall — "Restaurar compras" sin compras previas', () {
|
|
testWidgets('extremo a extremo (ServicioComprasPlayBilling real): un lote '
|
|
'vacío reactiva AMBOS botones, comprar y restaurar', (tester) async {
|
|
final iap = _InAppPurchaseFalso();
|
|
addTearDown(iap.dispose);
|
|
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
|
|
addTearDown(servicio.dispose);
|
|
|
|
await tester.pumpWidget(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider<EstadoEntitlement>(
|
|
create: (_) => EstadoEntitlement(prefs: null, compras: servicio),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
locale: const Locale('es'),
|
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
home: const Scaffold(body: HojaPremium()),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
|
|
|
|
final restaurar = find.byKey(const ValueKey('hoja-premium-restaurar'));
|
|
final comprar = find.byKey(const ValueKey('hoja-premium-comprar'));
|
|
|
|
await tester.tap(restaurar);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
|
|
// El botón sigue vivo: si `noEncontrada` nunca llega, `compraEnCurso`
|
|
// se queda en `true` y el usuario no puede pagar nunca más.
|
|
expect(
|
|
tester.widget<OutlinedButton>(restaurar).onPressed,
|
|
isNotNull,
|
|
reason: 'restaurar debe volver a estar habilitado',
|
|
);
|
|
expect(
|
|
tester.widget<FilledButton>(comprar).onPressed,
|
|
isNotNull,
|
|
reason: 'comprar debe volver a estar habilitado',
|
|
);
|
|
expect(find.text(l10n.restauracionSinCompras), findsOneWidget);
|
|
});
|
|
});
|
|
}
|