fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
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.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Play production-readiness guard over the declared Android permissions.
|
||||
///
|
||||
/// There is no Dart seam around `Geolocator` (it is a static facade, not an
|
||||
/// injectable port), so the only honest place to pin the permission surface
|
||||
/// is the manifest file itself. That is deliberate: the risk this guards is
|
||||
/// a *declaration* mismatch with the Data Safety form, which is a property
|
||||
/// of the manifest, not of any Dart call.
|
||||
void main() {
|
||||
late String manifiesto;
|
||||
|
||||
setUpAll(() {
|
||||
manifiesto =
|
||||
File('android/app/src/main/AndroidManifest.xml').readAsStringSync();
|
||||
});
|
||||
|
||||
bool declara(String permiso) =>
|
||||
manifiesto.contains('android.permission.$permiso');
|
||||
|
||||
group('permisos de ubicacion', () {
|
||||
test('NO declara ACCESS_FINE_LOCATION: la app solo resuelve un codigo ISO '
|
||||
'de pais y el Data Safety enviado declara ubicacion aproximada', () {
|
||||
expect(
|
||||
declara('ACCESS_FINE_LOCATION'),
|
||||
isFalse,
|
||||
reason:
|
||||
'The only location consumer is '
|
||||
'EstadoBusqueda.cargarEmisorasCercanas, which asks for '
|
||||
'LocationAccuracy.low and reduces the fix to '
|
||||
'Placemark.isoCountryCode. Declaring FINE contradicts the '
|
||||
'approved "approximate location" Data Safety declaration.',
|
||||
);
|
||||
});
|
||||
|
||||
test('SI declara ACCESS_COARSE_LOCATION: sin ninguno de los dos, el '
|
||||
'plugin lanza PermissionUndefinedException', () {
|
||||
expect(declara('ACCESS_COARSE_LOCATION'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -46,20 +46,9 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
||||
('pt', 'welcomeBullet2Title'),
|
||||
('ru', 'welcomeBullet2Title'),
|
||||
('zh', 'welcomeBullet2Title'),
|
||||
(
|
||||
'ar',
|
||||
'premiumBeneficioAndroidAuto',
|
||||
), // "Android Auto" -- Google product name (fix/import-alarmas-y-paywall)
|
||||
('bn', 'premiumBeneficioAndroidAuto'),
|
||||
('de', 'premiumBeneficioAndroidAuto'),
|
||||
('fr', 'premiumBeneficioAndroidAuto'),
|
||||
('hi', 'premiumBeneficioAndroidAuto'),
|
||||
('id', 'premiumBeneficioAndroidAuto'),
|
||||
('it', 'premiumBeneficioAndroidAuto'),
|
||||
('ja', 'premiumBeneficioAndroidAuto'),
|
||||
('pt', 'premiumBeneficioAndroidAuto'),
|
||||
('ru', 'premiumBeneficioAndroidAuto'),
|
||||
('zh', 'premiumBeneficioAndroidAuto'),
|
||||
// `premiumBeneficioAndroidAuto` used to live here as a bare "Android
|
||||
// Auto" product name. It is now a real sentence describing what PRO adds
|
||||
// in the car, so every locale translates it and no entry belongs here.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Pure symbols / placeholders -- no translatable text at all.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'arb_test_helpers.dart';
|
||||
|
||||
/// Play "Deceptive Behavior" guard over the purchase sheet's copy.
|
||||
///
|
||||
/// The free Android Auto root is NOT a paywall: `ConstructorArbolAuto.raiz`
|
||||
/// hands free users a real, browsable `idDestacadas` folder whose rows are
|
||||
/// playable (`hijosDestacadas`), precisely because Google Play cited the old
|
||||
/// "Premium feature" dead-end rows against the Android for Cars App Quality
|
||||
/// Guidelines (see the comment at `navegacion_auto.dart`). What PRO adds in
|
||||
/// the car is the rest of the tree: the full catalogue, favourites, my
|
||||
/// stations and local music.
|
||||
///
|
||||
/// So a bullet reading just "Android Auto" claims the free tier has no
|
||||
/// Android Auto at all, which is false in every locale.
|
||||
void main() {
|
||||
test('premiumBeneficioAndroidAuto no es el nombre pelado del producto en '
|
||||
'ninguna de las 13 locales', () {
|
||||
final ofensores = <String>[];
|
||||
|
||||
for (final locale in supportedArbLocales) {
|
||||
final valor = readArb(locale)['premiumBeneficioAndroidAuto'] as String?;
|
||||
if (valor == null) continue;
|
||||
if (valor.trim() == 'Android Auto') ofensores.add(locale);
|
||||
}
|
||||
|
||||
expect(
|
||||
ofensores,
|
||||
isEmpty,
|
||||
reason:
|
||||
'Free users already get a playable Android Auto folder, so a '
|
||||
'bullet whose whole text is the product name sells them '
|
||||
'something they have. Describe what PRO actually adds in the '
|
||||
'car instead. Offending locales: $ofensores',
|
||||
);
|
||||
});
|
||||
|
||||
test('premiumBeneficioAndroidAuto nombra lo que PRO anade de verdad en el '
|
||||
'coche (plantilla es)', () {
|
||||
final es = readArb('es')['premiumBeneficioAndroidAuto'] as String;
|
||||
final minusculas = es.toLowerCase();
|
||||
|
||||
expect(minusculas, contains('android auto'));
|
||||
expect(
|
||||
minusculas,
|
||||
contains('catálogo'),
|
||||
reason: 'the full catalogue (idTodas) is the headline PRO folder',
|
||||
);
|
||||
expect(minusculas, contains('favoritos'));
|
||||
expect(minusculas, contains('mis emisoras'));
|
||||
expect(minusculas, contains('música local'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_visualizador.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Play sensitive-permission contract for the waveform visualizer's
|
||||
/// microphone opt-in.
|
||||
///
|
||||
/// Turning the switch ON is what eventually makes the native side ask for
|
||||
/// `RECORD_AUDIO`, so the explanation must be on screen and accepted BEFORE
|
||||
/// the flag flips — never a system dialog the user meets cold.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<EstadoVisualizador> montar(WidgetTester tester) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final estado = EstadoVisualizador(prefs: prefs);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<EstadoVisualizador>.value(
|
||||
value: estado,
|
||||
child: const MaterialApp(
|
||||
locale: Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaAjustesVisualizador(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return estado;
|
||||
}
|
||||
|
||||
AppLocalizations textos(WidgetTester tester) => AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaAjustesVisualizador)),
|
||||
);
|
||||
|
||||
testWidgets('arranca desactivado: la onda real es opt-in, nunca el defecto', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await montar(tester);
|
||||
|
||||
expect(estado.ondaRealHabilitada, isFalse);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'activar muestra PRIMERO la explicacion y NO cambia el ajuste todavia',
|
||||
(tester) async {
|
||||
final estado = await montar(tester);
|
||||
final l10n = textos(tester);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final dialogo = find.byKey(
|
||||
const ValueKey('visualizador-explicacion-permiso'),
|
||||
);
|
||||
expect(dialogo, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: dialogo,
|
||||
matching: find.text(l10n.visualizerRealWavePermissionExplanation),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
estado.ondaRealHabilitada,
|
||||
isFalse,
|
||||
reason:
|
||||
'the flag must not flip while the explanation is still on '
|
||||
'screen — flipping it is what triggers the permission request',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('cancelar la explicacion deja el ajuste desactivado', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await montar(tester);
|
||||
final l10n = textos(tester);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.cancelAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.ondaRealHabilitada, isFalse);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('aceptar la explicacion activa el ajuste y lo persiste', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = await montar(tester);
|
||||
final l10n = textos(tester);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.visualizerRealWaveEnableAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(estado.ondaRealHabilitada, isTrue);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool(EstadoVisualizador.claveOndaReal), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('desactivar NO pide explicacion: retirar un permiso es libre', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
EstadoVisualizador.claveOndaReal: true,
|
||||
});
|
||||
final estado = await montar(tester);
|
||||
|
||||
expect(estado.ondaRealHabilitada, isTrue);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('visualizador-explicacion-permiso')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(estado.ondaRealHabilitada, isFalse);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/archivo_grabacion.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
@@ -108,6 +109,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
|
||||
@@ -54,6 +55,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
@@ -100,7 +104,7 @@ void main() {
|
||||
// completes the remaining 5 sections, so the root is now exactly 4
|
||||
// GrupoAjustes cards, under 400 lines.
|
||||
group('WU3a — AUDIO and EMISORAS groups', () {
|
||||
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
|
||||
testWidgets('AUDIO group renders exactly 4 nav rows, no inline controls', (
|
||||
tester,
|
||||
) async {
|
||||
setLargeSurface(tester);
|
||||
@@ -114,6 +118,10 @@ void main() {
|
||||
expect(find.text('AUDIO'), findsOneWidget);
|
||||
expect(find.text('Equalizer'), findsOneWidget);
|
||||
expect(find.text('Advanced Equalization Options'), findsOneWidget);
|
||||
// The waveform visualizer's microphone opt-in is reachable from the
|
||||
// root: the RECORD_AUDIO request must have a settings home the user
|
||||
// can find, not only the moment they happen to press play.
|
||||
expect(find.text('Real audio waveform'), findsOneWidget);
|
||||
expect(find.text('Sleep timer'), findsOneWidget);
|
||||
|
||||
// Zero inline controls: the old always-visible enable switch and
|
||||
|
||||
@@ -15,16 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// WU15: the recordings library screen — storage usage, browsable rows
|
||||
/// (name/date/duration/size) with inline playback, and a "⋮" menu
|
||||
/// constrained to exactly Rename/Share/Delete.
|
||||
/// constrained to exactly Rename/Open-in-another-app/Delete.
|
||||
///
|
||||
/// [ReproductorGrabaciones] is always injected with a fake here:
|
||||
/// constructing a real `just_audio.AudioPlayer` needs platform
|
||||
/// `MethodChannel`s this suite does not mock — the same documented
|
||||
/// constraint `cola_local_test.dart` records for `PluriWaveAudioHandler`.
|
||||
/// Likewise, `compartir` is always injected with a fake recorder instead of
|
||||
/// the real `share_plus` call, since this suite does not mock that channel
|
||||
/// either (see `pantalla_ajustes_backup_test.dart`'s note on the same
|
||||
/// constraint).
|
||||
/// Likewise, `abrirEnOtraApp` is always injected with a fake recorder
|
||||
/// instead of the real `pluriwave/file_actions` round trip, since this suite
|
||||
/// does not mock that channel either (see `pantalla_ajustes_backup_test.dart`
|
||||
/// for the same constraint).
|
||||
///
|
||||
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
|
||||
/// PluriGlassSurface paints a background over ListTile's ink layer, which
|
||||
@@ -72,7 +72,7 @@ void main() {
|
||||
Widget buildScreen({
|
||||
required EstadoGrabacion estado,
|
||||
required ReproductorGrabaciones reproductor,
|
||||
Future<void> Function(String ruta)? compartir,
|
||||
Future<bool> Function(String ruta)? abrirEnOtraApp,
|
||||
}) {
|
||||
return ListenableProvider<EstadoGrabacion>.value(
|
||||
value: estado,
|
||||
@@ -82,7 +82,7 @@ void main() {
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: PantallaGrabaciones(
|
||||
reproductor: reproductor,
|
||||
compartir: compartir ?? (_) async {},
|
||||
abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -117,6 +117,66 @@ void main() {
|
||||
expect(find.text('My recordings'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('aviso de uso privado', () {
|
||||
/// Recording a broadcast is defensible as a private copy, and stops
|
||||
/// being defensible the moment the product reads as a redistribution
|
||||
/// tool. The library screen had no such statement at all, while the
|
||||
/// manifest already exposes the recordings folder to the system file
|
||||
/// manager, so the notice states the intended use in plain words.
|
||||
testWidgets(
|
||||
'la biblioteca muestra el aviso de uso personal con la lista vacia',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos(
|
||||
const [],
|
||||
maxBytesFijo: 200 * 1024 * 1024,
|
||||
),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('el aviso sigue presente con grabaciones en la lista', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
fijaB,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
expect(find.text(l10n.recordingsPrivateUseNotice), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'WU15b: tapping the settings icon pushes the folder/size settings '
|
||||
'screen (PantallaAjustesGrabaciones stays reachable, now from within '
|
||||
@@ -379,9 +439,12 @@ void main() {
|
||||
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
|
||||
tester,
|
||||
) async {
|
||||
// The middle entry used to be Share, which handed the audio file to the
|
||||
// system share sheet. It is now a LOCAL open: play your own recording in
|
||||
// another app on the same device. The exact-count assertion is the guard
|
||||
// that no off-device action creeps back in beside it.
|
||||
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Open in another '
|
||||
'app, Delete', (tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
@@ -405,7 +468,10 @@ void main() {
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Open in another app'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
|
||||
findsOneWidget,
|
||||
@@ -555,10 +621,15 @@ void main() {
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'15.4-C: Share invokes the injected share callback with the file path',
|
||||
(tester) async {
|
||||
final compartidos = <String>[];
|
||||
/// The row menu used to hand the audio file to the system share sheet,
|
||||
/// which is redistribution of someone else's broadcast. What the owner
|
||||
/// actually wanted is to play your own recording in another app on the
|
||||
/// same device, so the action is a local ACTION_VIEW instead.
|
||||
group('15.4-C: abrir la grabacion en otra app del dispositivo', () {
|
||||
testWidgets('invoca el seam de apertura local con la ruta del archivo', (
|
||||
tester,
|
||||
) async {
|
||||
final abiertos = <String>[];
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
@@ -571,21 +642,68 @@ void main() {
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
compartir: (ruta) async {
|
||||
compartidos.add(ruta);
|
||||
abrirEnOtraApp: (ruta) async {
|
||||
abiertos.add(ruta);
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
|
||||
await tester.tap(
|
||||
find.widgetWithText(PopupMenuItem<String>, l10n.recordingActionOpenIn),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(compartidos, [fijaA.ruta]);
|
||||
},
|
||||
);
|
||||
expect(abiertos, [fijaA.ruta]);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'si ningun reproductor del dispositivo puede abrirla, lo dice en vez '
|
||||
'de fallar en silencio',
|
||||
(tester) async {
|
||||
final estado = EstadoGrabacion(
|
||||
esPremium: () => true,
|
||||
servicio: _FakeServicioGrabacionConArchivos([
|
||||
fijaA,
|
||||
], maxBytesFijo: 200 * 1024 * 1024),
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildScreen(
|
||||
estado: estado,
|
||||
reproductor: _ReproductorGrabacionesFake(const {}),
|
||||
abrirEnOtraApp: (_) async => false,
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaGrabaciones)),
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.more_vert_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithText(
|
||||
PopupMenuItem<String>,
|
||||
l10n.recordingActionOpenIn,
|
||||
),
|
||||
);
|
||||
await pumpStable(tester);
|
||||
|
||||
expect(
|
||||
find.widgetWithText(SnackBar, l10n.recordingOpenNoAppError),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Infrastructure ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_navegacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
|
||||
@@ -804,6 +805,9 @@ Widget _conProviders(
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
ListenableProvider<EstadoBusqueda>.value(value: estado.busqueda),
|
||||
ChangeNotifierProvider<EstadoNavegacionRaiz>.value(
|
||||
value: navegacion ?? EstadoNavegacionRaiz(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_ecualizador.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
@@ -106,6 +107,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
|
||||
ListenableProvider<EstadoEcualizador>.value(value: estado.ecualizador),
|
||||
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
@@ -482,6 +486,32 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
// This tile shares the STATION (its name and its url) — never an audio
|
||||
// file. It used to borrow `recordingActionShare`, the recordings
|
||||
// library's own menu label, so one key stood for two different
|
||||
// actions and the tile could not say which one it performed. Its
|
||||
// label is now its own key, and it names the station.
|
||||
testWidgets('la etiqueta del boton de compartir nombra la emisora, no un '
|
||||
'generico "Compartir" compartido con la biblioteca de grabaciones', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await montarPantalla(tester, estado);
|
||||
|
||||
final l10n = AppLocalizations.of(
|
||||
tester.element(find.byType(PantallaReproductor)),
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('player-tool-share')),
|
||||
matching: find.text(l10n.stationActionShare),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping EQ propio opens a sheet reusing EcualizadorWidget by exact runtime type',
|
||||
(tester) async {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
@@ -80,6 +81,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
Provider<ServicioAnuncios>(
|
||||
create: (_) => ServicioAnuncios(esPremium: () => true),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
@@ -78,6 +79,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
Provider<ServicioAnuncios>(
|
||||
create: (_) => ServicioAnuncios(esPremium: () => true),
|
||||
),
|
||||
|
||||
@@ -220,6 +220,19 @@ void main() {
|
||||
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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:pluriwave/estado/estado_entitlement.dart';
|
||||
import 'package:pluriwave/estado/estado_grabacion.dart';
|
||||
import 'package:pluriwave/estado/estado_idioma.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/estado/estado_visualizador.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_ajustes.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
@@ -222,6 +223,9 @@ void main() {
|
||||
ChangeNotifierProvider<EstadoEntitlement>(
|
||||
create: (_) => EstadoEntitlement(prefs: null),
|
||||
),
|
||||
ChangeNotifierProvider<EstadoVisualizador>(
|
||||
create: (_) => EstadoVisualizador(prefs: null),
|
||||
),
|
||||
],
|
||||
child: testApp(const PantallaAjustes()),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/widgets/visualizador_audio.dart';
|
||||
|
||||
/// Play sensitive-permission guard for the waveform visualizer.
|
||||
///
|
||||
/// `MainActivity.startVisualizerWhenAllowed` asks for `RECORD_AUDIO` the
|
||||
/// instant Dart subscribes to the `pluriwave/audio_visualizer` EventChannel.
|
||||
/// The home screen's "Escuchar" hero mounts this widget, so before this the
|
||||
/// microphone dialog appeared on the user's FIRST play, with no context at
|
||||
/// all, in a radio app — the worst possible moment to ask.
|
||||
///
|
||||
/// The subscription (and therefore the permission request) is now gated on
|
||||
/// an explicit opt-in the user gives in Settings, after reading what the
|
||||
/// permission is for. These tests pin the Dart half of that: no opt-in, no
|
||||
/// channel subscription.
|
||||
void main() {
|
||||
const canal = MethodChannel('pluriwave/audio_visualizer');
|
||||
|
||||
late List<MethodCall> llamadas;
|
||||
|
||||
setUp(() {
|
||||
llamadas = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, (call) async {
|
||||
llamadas.add(call);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(canal, null);
|
||||
});
|
||||
|
||||
Future<void> montar(
|
||||
WidgetTester tester, {
|
||||
required Stream<EstadoReproduccion> estadoStream,
|
||||
required Stream<int?> sessionStream,
|
||||
required bool capturaRealHabilitada,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: VisualizadorAudio(
|
||||
estadoStream: estadoStream,
|
||||
androidAudioSessionIdStream: sessionStream,
|
||||
barras: 12,
|
||||
capturaRealHabilitada: capturaRealHabilitada,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'sin el opt-in, reproducir NO suscribe el EventChannel (y por tanto no '
|
||||
'dispara la peticion de RECORD_AUDIO)',
|
||||
(tester) async {
|
||||
final estado = StreamController<EstadoReproduccion>.broadcast();
|
||||
final sesion = StreamController<int?>.broadcast();
|
||||
addTearDown(estado.close);
|
||||
addTearDown(sesion.close);
|
||||
|
||||
await montar(
|
||||
tester,
|
||||
estadoStream: estado.stream,
|
||||
sessionStream: sesion.stream,
|
||||
capturaRealHabilitada: false,
|
||||
);
|
||||
|
||||
sesion.add(42);
|
||||
estado.add(EstadoReproduccion.reproduciendo);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
llamadas.where((c) => c.method == 'listen'),
|
||||
isEmpty,
|
||||
reason:
|
||||
'subscribing is what makes MainActivity call requestPermissions '
|
||||
'for RECORD_AUDIO; with no opt-in it must never happen',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'con el opt-in, reproducir SI suscribe el EventChannel (la onda real '
|
||||
'sigue disponible para quien la pide)',
|
||||
(tester) async {
|
||||
final estado = StreamController<EstadoReproduccion>.broadcast();
|
||||
final sesion = StreamController<int?>.broadcast();
|
||||
addTearDown(estado.close);
|
||||
addTearDown(sesion.close);
|
||||
|
||||
await montar(
|
||||
tester,
|
||||
estadoStream: estado.stream,
|
||||
sessionStream: sesion.stream,
|
||||
capturaRealHabilitada: true,
|
||||
);
|
||||
|
||||
sesion.add(42);
|
||||
estado.add(EstadoReproduccion.reproduciendo);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(llamadas.where((c) => c.method == 'listen'), hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('el visualizador sigue dibujando sus barras sin el opt-in', (
|
||||
tester,
|
||||
) async {
|
||||
final estado = StreamController<EstadoReproduccion>.broadcast();
|
||||
final sesion = StreamController<int?>.broadcast();
|
||||
addTearDown(estado.close);
|
||||
addTearDown(sesion.close);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: VisualizadorAudio(
|
||||
estadoStream: estado.stream,
|
||||
androidAudioSessionIdStream: sesion.stream,
|
||||
barras: 12,
|
||||
barrasDiscretas: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 0; i < 12; i++) {
|
||||
expect(find.byKey(Key('visualizador-barra-$i')), findsOneWidget);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user