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.
229 lines
8.6 KiB
Dart
229 lines
8.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui' show Locale;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/archivo_grabacion.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../servicios/servicio_grabacion_radio.dart';
|
|
|
|
/// Recording state extracted from `EstadoRadio` (S4-R2).
|
|
///
|
|
/// Owns [ServicioGrabacionRadio] and the recording-state subscription, and
|
|
/// notifies ONLY its own listeners — recording progress must not rebuild
|
|
/// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on
|
|
/// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference
|
|
/// to this notifier.
|
|
/// Whether [emisora] is something the recorder can actually capture: a live
|
|
/// network stream.
|
|
///
|
|
/// The recorder opens the URL as an HTTP stream and writes the bytes to disk,
|
|
/// so anything else fails inside the HTTP client with a message no user can
|
|
/// act on ("Unsupported scheme 'content' in URI content://...").
|
|
///
|
|
/// This is not hypothetical tidiness. `PluriWaveAudioHandler._cambiarFuente`
|
|
/// sets `emisoraActual` for EVERY source it plays, so a local MP3 shows up
|
|
/// here as an `Emisora` whose `url` is the `content://` document URI it was
|
|
/// opened from. Recording a local file makes no sense anyway — it is already
|
|
/// on the device.
|
|
bool esEmisoraGrabable(Emisora emisora) {
|
|
final esquema = Uri.tryParse(emisora.url)?.scheme.toLowerCase();
|
|
return esquema == 'http' || esquema == 'https';
|
|
}
|
|
|
|
/// Distinct outcome signal for [EstadoGrabacion.iniciar] (freemium-gating
|
|
/// spec "Recording Start Gated"): [requierePremium] is NOT surfaced through
|
|
/// [EstadoGrabacion.iniciar]'s existing `alError` sink — the caller must
|
|
/// react by opening the paywall, a different UI than a plain error snackbar.
|
|
enum ResultadoIniciarGrabacion { iniciada, requierePremium, error }
|
|
|
|
class EstadoGrabacion extends ChangeNotifier {
|
|
EstadoGrabacion({
|
|
ServicioGrabacionRadio? servicio,
|
|
Emisora? Function()? emisoraActual,
|
|
void Function(String mensaje)? alError,
|
|
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
|
|
// [_emisoraActual]'s callback-injection shape. REQUIRED on purpose: an
|
|
// optional parameter with any default lets a forgotten wiring compile
|
|
// and silently pick a tier, and no test can catch that. Callers must
|
|
// state the entitlement source explicitly.
|
|
required bool Function() esPremium,
|
|
}) : servicio = servicio ?? ServicioGrabacionRadio(),
|
|
_emisoraActual = emisoraActual ?? (() => null),
|
|
_alError = alError,
|
|
_esPremium = esPremium {
|
|
_suscripcion = this.servicio.estadoStream.listen((estado) {
|
|
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
|
|
estado.error != null) {
|
|
_alError?.call(_textos.radioRecordingError(estado.error!));
|
|
}
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
static const MethodChannel _fileActionsChannel = MethodChannel(
|
|
'pluriwave/file_actions',
|
|
);
|
|
|
|
final ServicioGrabacionRadio servicio;
|
|
|
|
/// Callback into the owner (EstadoRadio) for the currently playing station;
|
|
/// keeps this notifier free of any station-list coupling.
|
|
final Emisora? Function() _emisoraActual;
|
|
|
|
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
|
|
final void Function(String mensaje)? _alError;
|
|
|
|
final bool Function() _esPremium;
|
|
|
|
StreamSubscription<EstadoGrabacionRadio>? _suscripcion;
|
|
AppLocalizations? _l10n;
|
|
|
|
AppLocalizations get _textos {
|
|
final actual = _l10n;
|
|
if (actual != null) return actual;
|
|
return lookupAppLocalizations(const Locale('es'));
|
|
}
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_l10n = l10n;
|
|
servicio.configurarLocalizaciones(l10n);
|
|
}
|
|
|
|
Future<void> inicializar() => servicio.inicializar();
|
|
|
|
EstadoGrabacionRadio get estado => servicio.estado;
|
|
bool get activa => servicio.estado.activa;
|
|
String? get directorioConfigurado => servicio.directorioConfigurado;
|
|
int get maxBytes => servicio.maxBytes;
|
|
File? get ultimoArchivo => servicio.ultimoArchivo;
|
|
|
|
Future<ResultadoIniciarGrabacion> iniciar({Duration? duracion}) async {
|
|
// Freemium gate (freemium-gating spec "Free user starts a new
|
|
// recording"): the AUTHORITATIVE check, before touching the service at
|
|
// all. Management of already-existing recordings is untouched — this
|
|
// method only governs STARTING a new one.
|
|
if (!_esPremium()) {
|
|
return ResultadoIniciarGrabacion.requierePremium;
|
|
}
|
|
final actual = _emisoraActual();
|
|
// `emisoraActual` is set by `_cambiarFuente` for EVERY source, local
|
|
// tracks included -- a local file becomes an `Emisora` whose `url` is the
|
|
// SAF `content://` URI it was opened from. Handing that to the recorder
|
|
// produced "Unsupported scheme 'content' in URI content://..." on screen,
|
|
// and it started happening only once local music playback existed: before
|
|
// that, whatever was playing was always a real station.
|
|
if (actual == null || !esEmisoraGrabable(actual)) {
|
|
_alError?.call(_textos.recordingSelectStationFirst);
|
|
return ResultadoIniciarGrabacion.error;
|
|
}
|
|
try {
|
|
await servicio.iniciar(actual, duracion: duracion);
|
|
return ResultadoIniciarGrabacion.iniciada;
|
|
} catch (e) {
|
|
_alError?.call(_textos.recordingStartError(e.toString()));
|
|
return ResultadoIniciarGrabacion.error;
|
|
}
|
|
}
|
|
|
|
Future<void> detener() => servicio.detener();
|
|
|
|
Future<void> cambiarMaxBytes(int bytes) async {
|
|
await servicio.guardarMaxBytes(bytes);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> cambiarDirectorio(String path) async {
|
|
await servicio.guardarDirectorio(path);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> restaurarDirectorio() async {
|
|
await servicio.limpiarDirectorioConfigurado();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
|
|
|
|
/// WU15, recordings-library: browsable listing of recording files
|
|
/// already on disk. Thin delegate — no additional logic.
|
|
Future<List<ArchivoGrabacion>> listarGrabaciones() =>
|
|
servicio.listarGrabaciones();
|
|
|
|
/// WU15: deletes [ruta] and notifies listeners so the library screen's
|
|
/// row disappears.
|
|
Future<void> eliminarGrabacion(String ruta) async {
|
|
await servicio.eliminarGrabacion(ruta);
|
|
notifyListeners();
|
|
}
|
|
|
|
/// WU15: renames the recording at [ruta] to [nuevoNombre] and notifies
|
|
/// listeners so the library screen reflects the new name.
|
|
Future<void> renombrarGrabacion(String ruta, String nuevoNombre) async {
|
|
await servicio.renombrarGrabacion(ruta, nuevoNombre);
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> abrirDirectorio() async {
|
|
final ruta = await directorioEfectivo();
|
|
await Directory(ruta).create(recursive: true);
|
|
if (!kIsWeb && Platform.isAndroid) {
|
|
final abierto = await _fileActionsChannel.invokeMethod<bool>(
|
|
'viewDirectory',
|
|
{'path': ruta},
|
|
);
|
|
return abierto ?? false;
|
|
}
|
|
final uri = Uri.directory(ruta);
|
|
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
}
|
|
|
|
/// Hands the recording at [ruta] to whatever player the user already has
|
|
/// on THIS device (`ACTION_VIEW` through the native `openFile` method,
|
|
/// over the app's own `FileProvider`). Nothing leaves the device: this is
|
|
/// the "play my own copy elsewhere" action, not a share sheet.
|
|
///
|
|
/// Returns `false` when the file is gone or no installed app accepted the
|
|
/// intent, so the caller can say so instead of failing in silence.
|
|
///
|
|
/// Static-review-only, like [abrirDirectorio] and every other method here
|
|
/// that crosses `pluriwave/file_actions`: the channel has no handler under
|
|
/// `flutter test`. The screens that call it inject a seam instead.
|
|
Future<bool> abrirGrabacion(String ruta) async {
|
|
final archivo = File(ruta);
|
|
if (!await archivo.exists()) {
|
|
debugPrint('[PluriWave][recordings] file missing: $ruta');
|
|
return false;
|
|
}
|
|
debugPrint('[PluriWave][recordings] opening file: $ruta');
|
|
if (!kIsWeb && Platform.isAndroid) {
|
|
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
|
'path': ruta,
|
|
'mimeType': 'audio/*',
|
|
});
|
|
return abierto ?? false;
|
|
}
|
|
return launchUrl(Uri.file(ruta), mode: LaunchMode.externalApplication);
|
|
}
|
|
|
|
Future<bool> abrirUltimaGrabacion() async {
|
|
final archivo = ultimoArchivo;
|
|
if (archivo == null) {
|
|
debugPrint('[PluriWave][recordings] last recording missing');
|
|
return false;
|
|
}
|
|
return abrirGrabacion(archivo.path);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_suscripcion?.cancel();
|
|
unawaited(servicio.dispose());
|
|
super.dispose();
|
|
}
|
|
}
|