Author SHA1 Message Date
FreeTLab 3ed33c7dbb fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:32:26 +02:00
ShanaiaBot a5572d2cbd chore: bump version to 1.3.1+157 [ci skip] 2026-08-28 23:53:03 +02:00
FreeTLab 98b24d84cd Merge branch 'PRO' of https://git.freetimelab.es/FreeTLab/pluriwave into PRO
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 19s
2026-08-28 23:52:27 +02:00
FreeTLab 72c5777508 ci: name build artifacts by branch and version code [version set]
Every build of a given semver was published as `pluriwave-v1.3.0.aab` into
the same folder, so main and PRO overwrote each other and three different
builds became indistinguishable once downloaded — the browser saves them as
"(1)", "(2)" and the version code is only visible by unzipping the bundle.

That cost two rejected uploads to Play Console for reusing a version code.
Artifacts are now `pluriwave-<branch>-v<semver>+<build>.<ext>", which
identifies itself weeks later and outside this repo.
2026-08-28 23:52:16 +02:00
ShanaiaBot b69041f32a chore: bump version to 1.3.0+156 [ci skip] 2026-08-28 23:40:45 +02:00
FreeTLab 9681a47e83 merge: alarm-import recovery, dismissible paywall and complete config export [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m1s
Same three fixes already merged to main (e57f7bb, a2bed18, 4ca2813):
alarms actually come back after a backup import and get re-scheduled
natively, the premium sheet can be dismissed, and the equalizer on/off
toggle finally travels with the backup.

[version set] keeps the 1.3.0 release name; CI advances the build number.

# Conflicts:
#	pubspec.yaml
2026-08-28 23:38:15 +02:00
ShanaiaBot 524b8f0035 chore: bump version to 1.3.0+154 [ci skip] 2026-08-28 19:59:13 +02:00
95 changed files with 767 additions and 10992 deletions
+15 -79
View File
@@ -68,18 +68,7 @@ jobs:
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "✅ Keystore configurado"
# 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
- name: Bump versión patch + commit
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git config user.name "ShanaiaBot"
@@ -88,20 +77,12 @@ jobs:
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
NEW_BUILD=$((BUILD + 1))
# Look for [version set] across EVERY commit this push introduced,
# not just the tip. `git pull` inserts an auto-generated merge commit
# whose message carries no marker, which silently discarded a pinned
# version name and bumped 1.3.0 to 1.3.1 behind our backs.
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.
# 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
# or a major/minor jump the automatic patch bump cannot reach) and only
# advance the build number, which Google Play requires to stay
# monotonic. Otherwise keep the default automatic patch+build bump.
if git log -1 --pretty=%B | grep -q '\[version set\]'; then
NEW_VERSION="${SEMVER}+${NEW_BUILD}"
else
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
@@ -110,8 +91,6 @@ jobs:
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
fi
echo "rama=${BRANCH} marcador=${MARCADOR} ${CURRENT} -> ${NEW_VERSION}"
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
@@ -276,29 +255,7 @@ jobs:
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
# 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}"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
@@ -308,46 +265,28 @@ jobs:
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
# La ruta se imprime desde ${APP}, no a mano: la version anterior tenia
# "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}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
# La publicacion automatica en Google Play es OPCIONAL.
#
# Este paso hacia `exit 1` cuando faltaba el secreto, asi que TODA
# compilacion de PRO terminaba en rojo por una funcion que nunca llego a
# activarse: el secreto no se configuro nunca y las subidas a Play se han
# hecho siempre a mano. Un rojo permanente entrena a ignorar los rojos, y
# entonces el dia que falle algo de verdad tampoco se mira.
#
# Ahora se omite con un aviso. El AAB ya esta compilado, firmado y subido
# a ftl-builds por el paso anterior, asi que no se pierde nada. El dia que
# se configure el secreto, los tres pasos se activan solos.
- name: Preparar credenciales de Google Play
id: credenciales_play
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "disponible=no" >> "$GITHUB_OUTPUT"
echo "AVISO: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON."
echo "Se omite la publicacion en Google Play; sube el AAB a mano."
exit 0
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
exit 1
fi
mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
echo "disponible=si" >> "$GITHUB_OUTPUT"
- name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
if: ${{ gitea.ref == 'refs/heads/PRO' }}
run: |
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
- name: Publicar AAB en Google Play Internal Testing
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
@@ -365,11 +304,8 @@ jobs:
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
# Solo se anuncia la subida a Play cuando de verdad ocurrio.
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
if [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
elif [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0AEn builds.freetimelab.es · sube el AAB a Play a mano"
else
MSG="${MSG}%0APublicado en builds.freetimelab.es"
fi
+1 -8
View File
@@ -12,15 +12,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- Approximate location ONLY. The single consumer
(EstadoBusqueda.cargarEmisorasCercanas) asks for LocationAccuracy.low
and throws the fix away except for Placemark.isoCountryCode, so a
country-level fix is all this app can use. Declaring
ACCESS_FINE_LOCATION would also contradict the approved Data Safety
declaration. geolocator builds its runtime request from whichever of
the two permissions the merged manifest declares, so with COARSE
alone the system dialog offers approximate precision only. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!--
Reading the paired-device list is gated by BLUETOOTH_CONNECT from API 31
-11
View File
@@ -10,7 +10,6 @@ import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
import 'estado/estado_navegacion.dart';
import 'estado/estado_visualizador.dart';
import 'servicios/servicio_anuncios.dart';
import 'servicios/servicio_compras.dart';
import 'widgets/banner_anuncio_superior.dart';
@@ -123,12 +122,6 @@ class PluriWaveApp extends StatelessWidget {
ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs),
),
// Sensitive-permission opt-in for the waveform visualizer's real
// audio capture. Lives at the root because BOTH visualizer call
// sites (the Escuchar hero and the full player) have to read it —
// whichever of them mounts first is the one that would otherwise
// trigger the RECORD_AUDIO request.
ChangeNotifierProvider(create: (_) => EstadoVisualizador(prefs: prefs)),
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
// watches this instead of owning `_indice` locally.
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
@@ -212,10 +205,6 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
// without a live native sink. Re-subscribe and re-seed the active device
// (no-op when multi-device EQ is off).
unawaited(context.read<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
-17
View File
@@ -663,29 +663,12 @@ class EstadoEcualizador extends ChangeNotifier {
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
/// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about.
///
/// The handler can also REFUSE the change: when the native `setEnabled`
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
/// flag back and skips its persistence write, so the value we optimistically
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
/// learn that: on divergence we adopt the handler's real value and return
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
/// to disk that would resurrect it on the next start. The supersede check
/// runs FIRST so a newer tap still owns the outcome; the read-back only
/// speaks for a call nobody overtook.
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
notifyListeners();
await audio.setEcualizadorActivo(activo);
if (_activo != activo) return;
final aceptado = audio.ecualizadorActivo;
if (aceptado != activo) {
_activo = aceptado;
notifyListeners();
return;
}
if (activo) {
await audio.aplicarPreset(_presetActual);
if (_activo != activo) return;
+8 -77
View File
@@ -5,14 +5,12 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
import '../servicios/verificacion_licencia.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds"). Shared with the silent license re-verification
/// (`verificacion_licencia.dart`), which may revoke it after a refund.
const _keyPremium = claveCompraPremium;
/// builds").
const _keyPremium = 'compra_premium_v1';
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from
@@ -57,21 +55,14 @@ enum ResultadoEntitlementUsuario {
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({
SharedPreferences? prefs,
PuertoCompras? compras,
DateTime Function()? reloj,
}) : _prefs = prefs,
_compras = compras,
_reloj = reloj {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
: _prefs = prefs,
_compras = compras {
final flujo = _compras;
if (flujo != null) {
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
}
// 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()));
_cargar();
}
/// The single non-consumable product id (Design "Interfaces / Contracts"),
@@ -81,11 +72,7 @@ class EstadoEntitlement extends ChangeNotifier {
final SharedPreferences? _prefs;
final PuertoCompras? _compras;
/// Injectable clock for the license check's throttle/spacing rules.
final DateTime Function()? _reloj;
StreamSubscription<EventoCompra>? _comprasSub;
bool _desechado = false;
bool _esPremium = false;
bool _compraEnCurso = false;
@@ -119,53 +106,6 @@ class EstadoEntitlement extends ChangeNotifier {
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
/// Fire-and-forget hook for app resume: re-syncs with the persisted flag
/// (the Android Auto path may have changed it) and runs the throttled
/// silent license check. Never throws, never touches [compraEnCurso] or
/// [resultadoUsuario].
Future<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
/// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted.
@@ -231,19 +171,11 @@ class EstadoEntitlement extends ChangeNotifier {
}
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;
_esPremium = true;
_compraEnCurso = false;
final escritura = prefs.setBool(_keyPremium, true);
// A real purchase/restore is fresh proof of ownership: drop any stale
// absence streak of the silent license check.
await reiniciarAusenciasLicencia(prefs);
await escritura;
final prefs = await _resolverPrefs();
await prefs.setBool(_keyPremium, true);
notifyListeners();
if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively
@@ -255,7 +187,6 @@ class EstadoEntitlement extends ChangeNotifier {
@override
void dispose() {
_desechado = true;
_comprasSub?.cancel();
super.dispose();
}
+10 -27
View File
@@ -182,41 +182,24 @@ class EstadoGrabacion extends ChangeNotifier {
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');
Future<bool> abrirUltimaGrabacion() async {
final archivo = ultimoArchivo;
if (archivo == null || !await archivo.exists()) {
debugPrint('[PluriWave][recordings] last recording missing');
return false;
}
debugPrint('[PluriWave][recordings] opening file: $ruta');
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': ruta,
'path': archivo.path,
'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);
return launchUrl(
Uri.file(archivo.path),
mode: LaunchMode.externalApplication,
);
}
@override
+26 -19
View File
@@ -338,6 +338,24 @@ class EstadoRadio extends ChangeNotifier {
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
@@ -357,12 +375,9 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
// Issue 4's write used to live here as well. It is gone: the handler
// persists every station itself from `_cambiarFuente`, which is the
// same source change that moved `audio.emisoraActual` and is the
// reason this branch runs at all. Writing again here would make the
// key's final value depend on how two independent fire-and-forget
// chains interleave on a fast station switch.
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
}
notifyListeners();
});
@@ -573,13 +588,10 @@ class EstadoRadio extends ChangeNotifier {
}
_emisoraSeleccionada = emisora;
notifyListeners();
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
// below reaches for this very station — see
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
// left the key with TWO fire-and-forget writers whose relative order
// decides the value after a fast A -> B switch, and this one cannot see
// the revision guard that already cancels a superseded change.
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
@@ -865,12 +877,7 @@ class EstadoRadio extends ChangeNotifier {
final favRaw = data['favoritos'] as List? ?? [];
for (final raw in favRaw) {
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
// los grupos restaurados arriba volvían como cascarones vacíos y todas
// las emisoras aterrizaban en «Sin asignar».
await favoritos.restaurarFavorito(emisora);
await favoritos.agregar(emisora);
}
// ── Emisoras custom ───────────────────────────────────────────────────
-53
View File
@@ -1,53 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Opt-in for reading the REAL audio level behind the waveform visualizer.
///
/// This is a sensitive-permission gate, not a cosmetic preference.
/// `VisualizadorAudio` subscribes to the native `pluriwave/audio_visualizer`
/// EventChannel only while [ondaRealHabilitada] is true, and that
/// subscription is precisely what makes `MainActivity` request
/// `RECORD_AUDIO`. So the flag must:
///
/// * default to `false`, so a fresh install never asks;
/// * flip to `true` only from a place where the user has just been told what
/// the permission is for (see `PantallaAjustesVisualizador`);
/// * be revocable at any time with no friction at all.
///
/// Without it the visualizer animates its synthetic wave, which is exactly
/// what it already did whenever the permission was denied — nothing about
/// the app breaks.
class EstadoVisualizador extends ChangeNotifier {
EstadoVisualizador({SharedPreferences? prefs}) : _prefs = prefs {
_cargar();
}
/// Persisted key. Public so tests can assert persistence without
/// duplicating the literal.
static const String claveOndaReal = 'visualizador_onda_real_v1';
final SharedPreferences? _prefs;
bool _ondaRealHabilitada = false;
bool get ondaRealHabilitada => _ondaRealHabilitada;
Future<void> cambiarOndaReal(bool habilitada) async {
if (habilitada == _ondaRealHabilitada) return;
_ondaRealHabilitada = habilitada;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.setBool(claveOndaReal, habilitada);
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final guardado = prefs.getBool(claveOndaReal) ?? false;
if (guardado == _ondaRealHabilitada) return;
_ondaRealHabilitada = guardado;
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "تعذّر تحميل الدول.",
"recordingActionDelete": "حذف",
"recordingActionRename": "إعادة تسمية",
"stationActionShare": "مشاركة المحطة",
"recordingActionOpenIn": "فتح في تطبيق آخر",
"recordingOpenNoAppError": "لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.",
"recordingActionShare": "مشاركة",
"recordingDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"recordingDeleteConfirmTitle": "هل تريد حذف التسجيل؟",
"recordingRenameDialogTitle": "إعادة تسمية التسجيل",
@@ -909,28 +907,10 @@
"premiumActivo": "النسخة المميزة مفعّلة",
"premiumHojaTitulo": "افتح PluriWave Premium",
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
"premiumBeneficioAndroidAuto": "الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "تسجيل المحطات",
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
"premiumAhoraNo": "ليس الآن",
"autoErrorEmisoraPremium": "هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.",
"autoErrorBusquedaSinResultados": "لم نعثر على تلك المحطة. جرّب اسمًا آخر.",
"autoCarpetaEscuchar": "الاستماع",
"autoCarpetaFavoritos": "المفضلة",
"autoCarpetaTodas": "كل المحطات",
"autoCarpetaMisEmisoras": "محطاتي",
"autoCarpetaMusicaLocal": "الموسيقى المحلية",
"autoMusicaLocalNoDisponible": "افتح PluriWave على هاتفك لقراءة موسيقاك",
"autoCargarMas": "المزيد…",
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
"autoReproducirCarpeta": "تشغيل المجلد",
"autoReproducirAleatorio": "تشغيل عشوائي",
"autoPistaSinNombre": "مقطع بلا اسم",
"recordingsPrivateUseNotice": "التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.",
"visualizerRealWaveTitle": "الموجة الحقيقية للصوت",
"visualizerRealWaveSubtitle": "تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.",
"visualizerRealWavePermissionExplanation": "لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.",
"visualizerRealWaveEnableAction": "تفعيل"
"premiumAhoraNo": "ليس الآن"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "দেশগুলো লোড করা যায়নি।",
"recordingActionDelete": "মুছে ফেলুন",
"recordingActionRename": "নাম পরিবর্তন করুন",
"stationActionShare": "স্টেশন শেয়ার করুন",
"recordingActionOpenIn": "অন্য অ্যাপে খুলুন",
"recordingOpenNoAppError": "এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।",
"recordingActionShare": "শেয়ার করুন",
"recordingDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"recordingDeleteConfirmTitle": "রেকর্ডিং মুছবেন?",
"recordingRenameDialogTitle": "রেকর্ডিং-এর নাম পরিবর্তন করুন",
@@ -909,28 +907,10 @@
"premiumActivo": "প্রিমিয়াম সক্রিয়",
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
"premiumBeneficioAndroidAuto": "Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
"premiumAhoraNo": "এখন নয়",
"autoErrorEmisoraPremium": "এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।",
"autoErrorBusquedaSinResultados": "সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।",
"autoCarpetaEscuchar": "শুনুন",
"autoCarpetaFavoritos": "প্রিয়",
"autoCarpetaTodas": "সব স্টেশন",
"autoCarpetaMisEmisoras": "আমার স্টেশন",
"autoCarpetaMusicaLocal": "স্থানীয় সঙ্গীত",
"autoMusicaLocalNoDisponible": "আপনার গান পড়তে ফোনে PluriWave খুলুন",
"autoCargarMas": "আরও…",
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
"autoReproducirCarpeta": "ফোল্ডার চালান",
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
"autoPistaSinNombre": "নামহীন ট্র্যাক",
"recordingsPrivateUseNotice": "রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।",
"visualizerRealWaveTitle": "আসল অডিও তরঙ্গ",
"visualizerRealWaveSubtitle": "বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।",
"visualizerRealWavePermissionExplanation": "মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।",
"visualizerRealWaveEnableAction": "চালু করুন"
"premiumAhoraNo": "এখন নয়"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "Die Länder konnten nicht geladen werden.",
"recordingActionDelete": "Löschen",
"recordingActionRename": "Umbenennen",
"stationActionShare": "Sender teilen",
"recordingActionOpenIn": "In anderer App öffnen",
"recordingOpenNoAppError": "Keine App auf diesem Gerät kann diese Aufnahme abspielen.",
"recordingActionShare": "Teilen",
"recordingDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
"recordingDeleteConfirmTitle": "Aufnahme löschen?",
"recordingRenameDialogTitle": "Aufnahme umbenennen",
@@ -909,28 +907,10 @@
"premiumActivo": "Premium aktiv",
"premiumHojaTitulo": "PluriWave Premium freischalten",
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
"premiumBeneficioAndroidAuto": "Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Sender aufnehmen",
"premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker",
"premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)",
"premiumPagoUnico": "Einmalzahlung, für immer. Kein Abonnement.",
"premiumAhoraNo": "Nicht jetzt",
"autoErrorEmisoraPremium": "Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.",
"autoErrorBusquedaSinResultados": "Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.",
"autoCarpetaEscuchar": "Hören",
"autoCarpetaFavoritos": "Favoriten",
"autoCarpetaTodas": "Alle Sender",
"autoCarpetaMisEmisoras": "Meine Sender",
"autoCarpetaMusicaLocal": "Lokale Musik",
"autoMusicaLocalNoDisponible": "Öffne PluriWave auf dem Handy, um deine Musik zu lesen",
"autoCargarMas": "Mehr…",
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
"autoReproducirCarpeta": "Ordner abspielen",
"autoReproducirAleatorio": "Zufallswiedergabe",
"autoPistaSinNombre": "Unbenannter Titel",
"recordingsPrivateUseNotice": "Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.",
"visualizerRealWaveTitle": "Echte Audio-Wellenform",
"visualizerRealWaveSubtitle": "Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.",
"visualizerRealWavePermissionExplanation": "Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.",
"visualizerRealWaveEnableAction": "Aktivieren"
"premiumAhoraNo": "Nicht jetzt"
}
+3 -23
View File
@@ -237,9 +237,7 @@
"recordingsLibraryEmptyTitle": "No recordings yet",
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
"recordingActionRename": "Rename",
"stationActionShare": "Share station",
"recordingActionOpenIn": "Open in another app",
"recordingOpenNoAppError": "No app on this device can play this recording.",
"recordingActionShare": "Share",
"recordingActionDelete": "Delete",
"recordingRenameDialogTitle": "Rename recording",
"recordingRenameLabel": "Name",
@@ -909,28 +907,10 @@
"premiumActivo": "Premium active",
"premiumHojaTitulo": "Unlock PluriWave Premium",
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
"premiumBeneficioAndroidAuto": "Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Station recording",
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
"premiumPagoUnico": "One-time purchase, forever. Not a subscription.",
"premiumAhoraNo": "Not now",
"autoErrorEmisoraPremium": "This station is Premium. Open PluriWave on your phone to unlock it.",
"autoErrorBusquedaSinResultados": "We couldn't find that station. Try another name.",
"autoCarpetaEscuchar": "Listen",
"autoCarpetaFavoritos": "Favorites",
"autoCarpetaTodas": "All stations",
"autoCarpetaMisEmisoras": "My stations",
"autoCarpetaMusicaLocal": "Local music",
"autoMusicaLocalNoDisponible": "Open PluriWave on your phone to read your music",
"autoCargarMas": "More…",
"autoOrdenarPorCalidad": "Sort by quality",
"autoReproducirCarpeta": "Play folder",
"autoReproducirAleatorio": "Shuffle play",
"autoPistaSinNombre": "Untitled track",
"recordingsPrivateUseNotice": "Recordings are for your own personal use. Please respect each station's rights and those of its content.",
"visualizerRealWaveTitle": "Real audio waveform",
"visualizerRealWaveSubtitle": "The bars follow the sound that is playing. When off, they animate on their own.",
"visualizerRealWavePermissionExplanation": "Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.",
"visualizerRealWaveEnableAction": "Turn on"
"premiumAhoraNo": "Not now"
}
+3 -23
View File
@@ -237,9 +237,7 @@
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
"recordingActionRename": "Renombrar",
"stationActionShare": "Compartir emisora",
"recordingActionOpenIn": "Abrir en otra app",
"recordingOpenNoAppError": "No hay ninguna app en este dispositivo que pueda reproducir la grabación.",
"recordingActionShare": "Compartir",
"recordingActionDelete": "Eliminar",
"recordingRenameDialogTitle": "Renombrar grabación",
"recordingRenameLabel": "Nombre",
@@ -868,28 +866,10 @@
"premiumActivo": "Premium activo",
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
"premiumBeneficioAndroidAuto": "Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Grabación de emisoras",
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
"premiumPagoUnico": "Pago único, para siempre. No es una suscripción.",
"premiumAhoraNo": "Ahora no",
"autoErrorEmisoraPremium": "Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.",
"autoErrorBusquedaSinResultados": "No hemos encontrado esa emisora. Prueba con otro nombre.",
"autoCarpetaEscuchar": "Escuchar",
"autoCarpetaFavoritos": "Favoritos",
"autoCarpetaTodas": "Todas las emisoras",
"autoCarpetaMisEmisoras": "Mis emisoras",
"autoCarpetaMusicaLocal": "Música Local",
"autoMusicaLocalNoDisponible": "Abre PluriWave en el móvil para leer tu música",
"autoCargarMas": "Más…",
"autoOrdenarPorCalidad": "Ordenar por calidad",
"autoReproducirCarpeta": "Reproducir carpeta",
"autoReproducirAleatorio": "Reproducir aleatorio",
"autoPistaSinNombre": "Pista sin nombre",
"recordingsPrivateUseNotice": "Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.",
"visualizerRealWaveTitle": "Onda real del audio",
"visualizerRealWaveSubtitle": "Las barras siguen el sonido que está sonando. Desactivado, se animan solas.",
"visualizerRealWavePermissionExplanation": "Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.",
"visualizerRealWaveEnableAction": "Activar"
"premiumAhoraNo": "Ahora no"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "Impossible de charger les pays.",
"recordingActionDelete": "Supprimer",
"recordingActionRename": "Renommer",
"stationActionShare": "Partager la station",
"recordingActionOpenIn": "Ouvrir dans une autre appli",
"recordingOpenNoAppError": "Aucune application de cet appareil ne peut lire cet enregistrement.",
"recordingActionShare": "Partager",
"recordingDeleteConfirmMessage": "Cette action est irréversible.",
"recordingDeleteConfirmTitle": "Supprimer l'enregistrement ?",
"recordingRenameDialogTitle": "Renommer l'enregistrement",
@@ -909,28 +907,10 @@
"premiumActivo": "Premium actif",
"premiumHojaTitulo": "Débloquer PluriWave Premium",
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
"premiumBeneficioAndroidAuto": "Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Enregistrement des stations",
"premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes",
"premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)",
"premiumPagoUnico": "Achat unique, pour toujours. Ce n'est pas un abonnement.",
"premiumAhoraNo": "Plus tard",
"autoErrorEmisoraPremium": "Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.",
"autoErrorBusquedaSinResultados": "Nous n'avons pas trouvé cette station. Essaie un autre nom.",
"autoCarpetaEscuchar": "Écouter",
"autoCarpetaFavoritos": "Favoris",
"autoCarpetaTodas": "Toutes les stations",
"autoCarpetaMisEmisoras": "Mes stations",
"autoCarpetaMusicaLocal": "Musique locale",
"autoMusicaLocalNoDisponible": "Ouvrez PluriWave sur votre téléphone pour lire votre musique",
"autoCargarMas": "Plus…",
"autoOrdenarPorCalidad": "Trier par qualité",
"autoReproducirCarpeta": "Lire le dossier",
"autoReproducirAleatorio": "Lecture aléatoire",
"autoPistaSinNombre": "Piste sans nom",
"recordingsPrivateUseNotice": "Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.",
"visualizerRealWaveTitle": "Onde audio réelle",
"visualizerRealWaveSubtitle": "Les barres suivent le son en cours. Désactivées, elles s'animent d'elles-mêmes.",
"visualizerRealWavePermissionExplanation": "Android n'autorise la lecture du niveau audio qu'avec l'autorisation du microphone. PluriWave n'écoute ni n'enregistre le microphone : il mesure seulement le son qu'il diffuse déjà. Vous pouvez le désactiver à tout moment.",
"visualizerRealWaveEnableAction": "Activer"
"premiumAhoraNo": "Plus tard"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "देश लोड नहीं हो सके।",
"recordingActionDelete": "हटाएं",
"recordingActionRename": "नाम बदलें",
"stationActionShare": "स्टेशन शेयर करें",
"recordingActionOpenIn": "दूसरे ऐप में खोलें",
"recordingOpenNoAppError": "इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।",
"recordingActionShare": "शेयर करें",
"recordingDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"recordingDeleteConfirmTitle": "रिकॉर्डिंग हटाएं?",
"recordingRenameDialogTitle": "रिकॉर्डिंग का नाम बदलें",
@@ -909,28 +907,10 @@
"premiumActivo": "प्रीमियम सक्रिय",
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
"premiumBeneficioAndroidAuto": "Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
"premiumAhoraNo": "अभी नहीं",
"autoErrorEmisoraPremium": "यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।",
"autoErrorBusquedaSinResultados": "वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।",
"autoCarpetaEscuchar": "सुनें",
"autoCarpetaFavoritos": "पसंदीदा",
"autoCarpetaTodas": "सभी स्टेशन",
"autoCarpetaMisEmisoras": "मेरे स्टेशन",
"autoCarpetaMusicaLocal": "लोकल संगीत",
"autoMusicaLocalNoDisponible": "अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें",
"autoCargarMas": "और…",
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
"autoReproducirAleatorio": "शफ़ल चलाएँ",
"autoPistaSinNombre": "बिना नाम का ट्रैक",
"recordingsPrivateUseNotice": "रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।",
"visualizerRealWaveTitle": "असली ऑडियो तरंग",
"visualizerRealWaveSubtitle": "बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।",
"visualizerRealWavePermissionExplanation": "Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।",
"visualizerRealWaveEnableAction": "चालू करें"
"premiumAhoraNo": "अभी नहीं"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "Negara tidak dapat dimuat.",
"recordingActionDelete": "Hapus",
"recordingActionRename": "Ganti nama",
"stationActionShare": "Bagikan stasiun",
"recordingActionOpenIn": "Buka di aplikasi lain",
"recordingOpenNoAppError": "Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.",
"recordingActionShare": "Bagikan",
"recordingDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
"recordingDeleteConfirmTitle": "Hapus rekaman?",
"recordingRenameDialogTitle": "Ganti nama rekaman",
@@ -909,28 +907,10 @@
"premiumActivo": "Premium aktif",
"premiumHojaTitulo": "Buka PluriWave Premium",
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
"premiumBeneficioAndroidAuto": "Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Perekaman stasiun",
"premiumBeneficioVacaciones": "Rentang liburan untuk alarm",
"premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)",
"premiumPagoUnico": "Pembelian sekali bayar, untuk selamanya. Bukan langganan.",
"premiumAhoraNo": "Nanti saja",
"autoErrorEmisoraPremium": "Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.",
"autoErrorBusquedaSinResultados": "Kami tidak menemukan stasiun itu. Coba nama lain.",
"autoCarpetaEscuchar": "Dengarkan",
"autoCarpetaFavoritos": "Favorit",
"autoCarpetaTodas": "Semua stasiun",
"autoCarpetaMisEmisoras": "Stasiun saya",
"autoCarpetaMusicaLocal": "Musik lokal",
"autoMusicaLocalNoDisponible": "Buka PluriWave di ponsel untuk membaca musik Anda",
"autoCargarMas": "Lainnya…",
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
"autoReproducirCarpeta": "Putar folder",
"autoReproducirAleatorio": "Putar acak",
"autoPistaSinNombre": "Trek tanpa nama",
"recordingsPrivateUseNotice": "Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.",
"visualizerRealWaveTitle": "Gelombang audio asli",
"visualizerRealWaveSubtitle": "Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.",
"visualizerRealWavePermissionExplanation": "Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.",
"visualizerRealWaveEnableAction": "Aktifkan"
"premiumAhoraNo": "Nanti saja"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "Non è stato possibile caricare i paesi.",
"recordingActionDelete": "Elimina",
"recordingActionRename": "Rinomina",
"stationActionShare": "Condividi stazione",
"recordingActionOpenIn": "Apri in un'altra app",
"recordingOpenNoAppError": "Nessuna app su questo dispositivo può riprodurre questa registrazione.",
"recordingActionShare": "Condividi",
"recordingDeleteConfirmMessage": "Questa azione non può essere annullata.",
"recordingDeleteConfirmTitle": "Eliminare la registrazione?",
"recordingRenameDialogTitle": "Rinomina registrazione",
@@ -909,28 +907,10 @@
"premiumActivo": "Premium attivo",
"premiumHojaTitulo": "Sblocca PluriWave Premium",
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
"premiumBeneficioAndroidAuto": "Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Registrazione delle stazioni",
"premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie",
"premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)",
"premiumPagoUnico": "Acquisto unico, per sempre. Non è un abbonamento.",
"premiumAhoraNo": "Non ora",
"autoErrorEmisoraPremium": "Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.",
"autoErrorBusquedaSinResultados": "Non abbiamo trovato quella stazione. Prova con un altro nome.",
"autoCarpetaEscuchar": "Ascolta",
"autoCarpetaFavoritos": "Preferiti",
"autoCarpetaTodas": "Tutte le emittenti",
"autoCarpetaMisEmisoras": "Le mie emittenti",
"autoCarpetaMusicaLocal": "Musica locale",
"autoMusicaLocalNoDisponible": "Apri PluriWave sul telefono per leggere la tua musica",
"autoCargarMas": "Altro…",
"autoOrdenarPorCalidad": "Ordina per qualità",
"autoReproducirCarpeta": "Riproduci cartella",
"autoReproducirAleatorio": "Riproduzione casuale",
"autoPistaSinNombre": "Traccia senza nome",
"recordingsPrivateUseNotice": "Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.",
"visualizerRealWaveTitle": "Onda audio reale",
"visualizerRealWaveSubtitle": "Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.",
"visualizerRealWavePermissionExplanation": "Android consente di leggere il livello audio solo con l'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.",
"visualizerRealWaveEnableAction": "Attiva"
"premiumAhoraNo": "Non ora"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "国の一覧を読み込めませんでした。",
"recordingActionDelete": "削除",
"recordingActionRename": "名前を変更",
"stationActionShare": "放送局を共有",
"recordingActionOpenIn": "別のアプリで開く",
"recordingOpenNoAppError": "この端末には、この録音を再生できるアプリがありません。",
"recordingActionShare": "共有",
"recordingDeleteConfirmMessage": "この操作は元に戻せません。",
"recordingDeleteConfirmTitle": "録音を削除しますか?",
"recordingRenameDialogTitle": "録音の名前を変更",
@@ -909,28 +907,10 @@
"premiumActivo": "プレミアム有効",
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
"premiumBeneficioAndroidAuto": "Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "放送局の録音",
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
"premiumAhoraNo": "後で",
"autoErrorEmisoraPremium": "この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。",
"autoErrorBusquedaSinResultados": "その放送局は見つかりませんでした。別の名前をお試しください。",
"autoCarpetaEscuchar": "聴く",
"autoCarpetaFavoritos": "お気に入り",
"autoCarpetaTodas": "すべての局",
"autoCarpetaMisEmisoras": "マイ局",
"autoCarpetaMusicaLocal": "ローカルの音楽",
"autoMusicaLocalNoDisponible": "音楽を読み込むにはスマートフォンで PluriWave を開いてください",
"autoCargarMas": "もっと見る…",
"autoOrdenarPorCalidad": "音質順に並べ替え",
"autoReproducirCarpeta": "フォルダを再生",
"autoReproducirAleatorio": "シャッフル再生",
"autoPistaSinNombre": "名称未設定のトラック",
"recordingsPrivateUseNotice": "録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。",
"visualizerRealWaveTitle": "実際の音声波形",
"visualizerRealWaveSubtitle": "バーが再生中の音に合わせて動きます。オフのときは独自に動きます。",
"visualizerRealWavePermissionExplanation": "Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。",
"visualizerRealWaveEnableAction": "オンにする"
"premiumAhoraNo": "後で"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "Não foi possível carregar os países.",
"recordingActionDelete": "Excluir",
"recordingActionRename": "Renomear",
"stationActionShare": "Compartilhar estação",
"recordingActionOpenIn": "Abrir em outro app",
"recordingOpenNoAppError": "Nenhum app deste dispositivo consegue reproduzir esta gravação.",
"recordingActionShare": "Compartilhar",
"recordingDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
"recordingDeleteConfirmTitle": "Excluir gravação?",
"recordingRenameDialogTitle": "Renomear gravação",
@@ -909,28 +907,10 @@
"premiumActivo": "Premium ativo",
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
"premiumBeneficioAndroidAuto": "Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Gravação de emissoras",
"premiumBeneficioVacaciones": "Períodos de férias para os alarmes",
"premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)",
"premiumPagoUnico": "Pagamento único, para sempre. Não é uma assinatura.",
"premiumAhoraNo": "Agora não",
"autoErrorEmisoraPremium": "Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.",
"autoErrorBusquedaSinResultados": "Não encontrámos essa estação. Tente outro nome.",
"autoCarpetaEscuchar": "Ouvir",
"autoCarpetaFavoritos": "Favoritos",
"autoCarpetaTodas": "Todas as estações",
"autoCarpetaMisEmisoras": "As minhas estações",
"autoCarpetaMusicaLocal": "Música local",
"autoMusicaLocalNoDisponible": "Abra o PluriWave no telemóvel para ler a sua música",
"autoCargarMas": "Mais…",
"autoOrdenarPorCalidad": "Ordenar por qualidade",
"autoReproducirCarpeta": "Reproduzir pasta",
"autoReproducirAleatorio": "Reprodução aleatória",
"autoPistaSinNombre": "Faixa sem nome",
"recordingsPrivateUseNotice": "As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.",
"visualizerRealWaveTitle": "Onda de áudio real",
"visualizerRealWaveSubtitle": "As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.",
"visualizerRealWavePermissionExplanation": "O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.",
"visualizerRealWaveEnableAction": "Ativar"
"premiumAhoraNo": "Agora não"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "Не удалось загрузить страны.",
"recordingActionDelete": "Удалить",
"recordingActionRename": "Переименовать",
"stationActionShare": "Поделиться станцией",
"recordingActionOpenIn": "Открыть в другом приложении",
"recordingOpenNoAppError": "На этом устройстве нет приложения, способного воспроизвести эту запись.",
"recordingActionShare": "Поделиться",
"recordingDeleteConfirmMessage": "Это действие нельзя отменить.",
"recordingDeleteConfirmTitle": "Удалить запись?",
"recordingRenameDialogTitle": "Переименовать запись",
@@ -909,28 +907,10 @@
"premiumActivo": "Премиум активен",
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
"premiumBeneficioAndroidAuto": "Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Запись радиостанций",
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
"premiumAhoraNo": "Не сейчас",
"autoErrorEmisoraPremium": "Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.",
"autoErrorBusquedaSinResultados": "Мы не нашли такую станцию. Попробуйте другое название.",
"autoCarpetaEscuchar": "Слушать",
"autoCarpetaFavoritos": "Избранное",
"autoCarpetaTodas": "Все станции",
"autoCarpetaMisEmisoras": "Мои станции",
"autoCarpetaMusicaLocal": "Локальная музыка",
"autoMusicaLocalNoDisponible": "Откройте PluriWave на телефоне, чтобы прочитать вашу музыку",
"autoCargarMas": "Ещё…",
"autoOrdenarPorCalidad": "Сортировать по качеству",
"autoReproducirCarpeta": "Воспроизвести папку",
"autoReproducirAleatorio": "Случайное воспроизведение",
"autoPistaSinNombre": "Трек без названия",
"recordingsPrivateUseNotice": "Записи предназначены только для личного использования. Уважайте права станций и их контента.",
"visualizerRealWaveTitle": "Реальная звуковая волна",
"visualizerRealWaveSubtitle": "Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.",
"visualizerRealWavePermissionExplanation": "Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.",
"visualizerRealWaveEnableAction": "Включить"
"premiumAhoraNo": "Не сейчас"
}
+3 -23
View File
@@ -769,9 +769,7 @@
"radioCountriesError": "无法加载国家列表。",
"recordingActionDelete": "删除",
"recordingActionRename": "重命名",
"stationActionShare": "分享电台",
"recordingActionOpenIn": "用其他应用打开",
"recordingOpenNoAppError": "此设备上没有可播放该录音的应用。",
"recordingActionShare": "分享",
"recordingDeleteConfirmMessage": "此操作无法撤销。",
"recordingDeleteConfirmTitle": "删除录音?",
"recordingRenameDialogTitle": "重命名录音",
@@ -909,28 +907,10 @@
"premiumActivo": "高级版已解锁",
"premiumHojaTitulo": "解锁 PluriWave Premium",
"premiumBeneficioSinAnuncios": "全应用无广告",
"premiumBeneficioAndroidAuto": "Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "电台录音",
"premiumBeneficioVacaciones": "闹钟的假期时间段",
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
"premiumAhoraNo": "以后再说",
"autoErrorEmisoraPremium": "该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。",
"autoErrorBusquedaSinResultados": "没有找到该电台。请换个名称再试。",
"autoCarpetaEscuchar": "收听",
"autoCarpetaFavoritos": "收藏",
"autoCarpetaTodas": "全部电台",
"autoCarpetaMisEmisoras": "我的电台",
"autoCarpetaMusicaLocal": "本地音乐",
"autoMusicaLocalNoDisponible": "请在手机上打开 PluriWave 以读取您的音乐",
"autoCargarMas": "更多…",
"autoOrdenarPorCalidad": "按音质排序",
"autoReproducirCarpeta": "播放文件夹",
"autoReproducirAleatorio": "随机播放",
"autoPistaSinNombre": "未命名曲目",
"recordingsPrivateUseNotice": "录音仅供你个人使用。请尊重各电台及其内容的权利。",
"visualizerRealWaveTitle": "真实音频波形",
"visualizerRealWaveSubtitle": "音条会跟随正在播放的声音。关闭时,音条会自行跳动。",
"visualizerRealWavePermissionExplanation": "Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。",
"visualizerRealWaveEnableAction": "开启"
"premiumAhoraNo": "以后再说"
}
+4 -124
View File
@@ -904,23 +904,11 @@ abstract class AppLocalizations {
/// **'Renombrar'**
String get recordingActionRename;
/// No description provided for @stationActionShare.
/// No description provided for @recordingActionShare.
///
/// In es, this message translates to:
/// **'Compartir emisora'**
String get stationActionShare;
/// No description provided for @recordingActionOpenIn.
///
/// In es, this message translates to:
/// **'Abrir en otra app'**
String get recordingActionOpenIn;
/// No description provided for @recordingOpenNoAppError.
///
/// In es, this message translates to:
/// **'No hay ninguna app en este dispositivo que pueda reproducir la grabación.'**
String get recordingOpenNoAppError;
/// **'Compartir'**
String get recordingActionShare;
/// No description provided for @recordingActionDelete.
///
@@ -3395,7 +3383,7 @@ abstract class AppLocalizations {
/// No description provided for @premiumBeneficioAndroidAuto.
///
/// In es, this message translates to:
/// **'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)'**
/// **'Android Auto'**
String get premiumBeneficioAndroidAuto;
/// No description provided for @premiumBeneficioGrabacion.
@@ -3427,114 +3415,6 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Ahora no'**
String get premiumAhoraNo;
/// No description provided for @autoErrorEmisoraPremium.
///
/// In es, this message translates to:
/// **'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.'**
String get autoErrorEmisoraPremium;
/// No description provided for @autoErrorBusquedaSinResultados.
///
/// In es, this message translates to:
/// **'No hemos encontrado esa emisora. Prueba con otro nombre.'**
String get autoErrorBusquedaSinResultados;
/// No description provided for @autoCarpetaEscuchar.
///
/// In es, this message translates to:
/// **'Escuchar'**
String get autoCarpetaEscuchar;
/// No description provided for @autoCarpetaFavoritos.
///
/// In es, this message translates to:
/// **'Favoritos'**
String get autoCarpetaFavoritos;
/// No description provided for @autoCarpetaTodas.
///
/// In es, this message translates to:
/// **'Todas las emisoras'**
String get autoCarpetaTodas;
/// No description provided for @autoCarpetaMisEmisoras.
///
/// In es, this message translates to:
/// **'Mis emisoras'**
String get autoCarpetaMisEmisoras;
/// No description provided for @autoCarpetaMusicaLocal.
///
/// In es, this message translates to:
/// **'Música Local'**
String get autoCarpetaMusicaLocal;
/// No description provided for @autoMusicaLocalNoDisponible.
///
/// In es, this message translates to:
/// **'Abre PluriWave en el móvil para leer tu música'**
String get autoMusicaLocalNoDisponible;
/// No description provided for @autoCargarMas.
///
/// In es, this message translates to:
/// **'Más…'**
String get autoCargarMas;
/// No description provided for @autoOrdenarPorCalidad.
///
/// In es, this message translates to:
/// **'Ordenar por calidad'**
String get autoOrdenarPorCalidad;
/// No description provided for @autoReproducirCarpeta.
///
/// In es, this message translates to:
/// **'Reproducir carpeta'**
String get autoReproducirCarpeta;
/// No description provided for @autoReproducirAleatorio.
///
/// In es, this message translates to:
/// **'Reproducir aleatorio'**
String get autoReproducirAleatorio;
/// No description provided for @autoPistaSinNombre.
///
/// In es, this message translates to:
/// **'Pista sin nombre'**
String get autoPistaSinNombre;
/// No description provided for @recordingsPrivateUseNotice.
///
/// In es, this message translates to:
/// **'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.'**
String get recordingsPrivateUseNotice;
/// No description provided for @visualizerRealWaveTitle.
///
/// In es, this message translates to:
/// **'Onda real del audio'**
String get visualizerRealWaveTitle;
/// No description provided for @visualizerRealWaveSubtitle.
///
/// In es, this message translates to:
/// **'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.'**
String get visualizerRealWaveSubtitle;
/// No description provided for @visualizerRealWavePermissionExplanation.
///
/// In es, this message translates to:
/// **'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.'**
String get visualizerRealWavePermissionExplanation;
/// No description provided for @visualizerRealWaveEnableAction.
///
/// In es, this message translates to:
/// **'Activar'**
String get visualizerRealWaveEnableAction;
}
class _AppLocalizationsDelegate
+2 -70
View File
@@ -456,14 +456,7 @@ class AppLocalizationsAr extends AppLocalizations {
String get recordingActionRename => 'إعادة تسمية';
@override
String get stationActionShare => 'مشاركة المحطة';
@override
String get recordingActionOpenIn => 'فتح في تطبيق آخر';
@override
String get recordingOpenNoAppError =>
'لا يوجد تطبيق على هذا الجهاز يمكنه تشغيل هذا التسجيل.';
String get recordingActionShare => 'مشاركة';
@override
String get recordingActionDelete => 'حذف';
@@ -1878,8 +1871,7 @@ class AppLocalizationsAr extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
@override
String get premiumBeneficioAndroidAuto =>
'الكتالوج الكامل في Android Auto: المفضلة ومحطاتي والموسيقى المحلية (مجانًا: المحطات المميزة فقط)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
@@ -1896,64 +1888,4 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get premiumAhoraNo => 'ليس الآن';
@override
String get autoErrorEmisoraPremium =>
'هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.';
@override
String get autoErrorBusquedaSinResultados =>
'لم نعثر على تلك المحطة. جرّب اسمًا آخر.';
@override
String get autoCarpetaEscuchar => 'الاستماع';
@override
String get autoCarpetaFavoritos => 'المفضلة';
@override
String get autoCarpetaTodas => 'كل المحطات';
@override
String get autoCarpetaMisEmisoras => 'محطاتي';
@override
String get autoCarpetaMusicaLocal => 'الموسيقى المحلية';
@override
String get autoMusicaLocalNoDisponible =>
'افتح PluriWave على هاتفك لقراءة موسيقاك';
@override
String get autoCargarMas => 'المزيد…';
@override
String get autoOrdenarPorCalidad => 'الترتيب حسب الجودة';
@override
String get autoReproducirCarpeta => 'تشغيل المجلد';
@override
String get autoReproducirAleatorio => 'تشغيل عشوائي';
@override
String get autoPistaSinNombre => 'مقطع بلا اسم';
@override
String get recordingsPrivateUseNotice =>
'التسجيلات مخصصة لاستخدامك الشخصي. يُرجى احترام حقوق كل محطة وحقوق محتواها.';
@override
String get visualizerRealWaveTitle => 'الموجة الحقيقية للصوت';
@override
String get visualizerRealWaveSubtitle =>
'تتبع الأعمدة الصوت الجاري تشغيله. وعند الإيقاف تتحرك من تلقاء نفسها.';
@override
String get visualizerRealWavePermissionExplanation =>
'لا يسمح أندرويد بقراءة مستوى الصوت إلا بإذن الميكروفون. لا يستمع PluriWave إلى الميكروفون ولا يسجّله: فهو يقيس الصوت الذي يشغّله بالفعل فقط. ويمكنك إيقاف ذلك متى شئت.';
@override
String get visualizerRealWaveEnableAction => 'تفعيل';
}
+2 -70
View File
@@ -461,14 +461,7 @@ class AppLocalizationsBn extends AppLocalizations {
String get recordingActionRename => 'নাম পরিবর্তন করুন';
@override
String get stationActionShare => 'স্টেশন শেয়ার করুন';
@override
String get recordingActionOpenIn => 'অন্য অ্যাপে খুলুন';
@override
String get recordingOpenNoAppError =>
'এই ডিভাইসে এই রেকর্ডিং চালাতে পারে এমন কোনো অ্যাপ নেই।';
String get recordingActionShare => 'শেয়ার করুন';
@override
String get recordingActionDelete => 'মুছে ফেলুন';
@@ -1889,8 +1882,7 @@ class AppLocalizationsBn extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
@override
String get premiumBeneficioAndroidAuto =>
'Android Auto-তে সম্পূর্ণ তালিকা: প্রিয়, আমার স্টেশন ও স্থানীয় সংগীত (ফ্রি: শুধু বাছাই করা স্টেশন)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
@@ -1908,64 +1900,4 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get premiumAhoraNo => 'এখন নয়';
@override
String get autoErrorEmisoraPremium =>
'এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।';
@override
String get autoErrorBusquedaSinResultados =>
'সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।';
@override
String get autoCarpetaEscuchar => 'শুনুন';
@override
String get autoCarpetaFavoritos => 'প্রিয়';
@override
String get autoCarpetaTodas => 'সব স্টেশন';
@override
String get autoCarpetaMisEmisoras => 'আমার স্টেশন';
@override
String get autoCarpetaMusicaLocal => 'স্থানীয় সঙ্গীত';
@override
String get autoMusicaLocalNoDisponible =>
'আপনার গান পড়তে ফোনে PluriWave খুলুন';
@override
String get autoCargarMas => 'আরও…';
@override
String get autoOrdenarPorCalidad => 'মান অনুসারে সাজান';
@override
String get autoReproducirCarpeta => 'ফোল্ডার চালান';
@override
String get autoReproducirAleatorio => 'এলোমেলোভাবে চালান';
@override
String get autoPistaSinNombre => 'নামহীন ট্র্যাক';
@override
String get recordingsPrivateUseNotice =>
'রেকর্ডিংগুলি আপনার ব্যক্তিগত ব্যবহারের জন্য। প্রতিটি স্টেশন ও তার কনটেন্টের অধিকারকে সম্মান করুন।';
@override
String get visualizerRealWaveTitle => 'আসল অডিও তরঙ্গ';
@override
String get visualizerRealWaveSubtitle =>
'বারগুলি চলমান শব্দ অনুসরণ করে। বন্ধ থাকলে সেগুলি নিজে থেকেই চলে।';
@override
String get visualizerRealWavePermissionExplanation =>
'মাইক্রোফোন অনুমতি ছাড়া Android অডিও লেভেল পড়তে দেয় না। PluriWave মাইক্রোফোন শোনে না বা রেকর্ড করে না: এটি শুধু চলমান শব্দের মাত্রা মাপে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।';
@override
String get visualizerRealWaveEnableAction => 'চালু করুন';
}
+2 -70
View File
@@ -464,14 +464,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get recordingActionRename => 'Umbenennen';
@override
String get stationActionShare => 'Sender teilen';
@override
String get recordingActionOpenIn => 'In anderer App öffnen';
@override
String get recordingOpenNoAppError =>
'Keine App auf diesem Gerät kann diese Aufnahme abspielen.';
String get recordingActionShare => 'Teilen';
@override
String get recordingActionDelete => 'Löschen';
@@ -1903,8 +1896,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
@override
String get premiumBeneficioAndroidAuto =>
'Der komplette Katalog in Android Auto: Favoriten, meine Sender und lokale Musik (gratis: nur empfohlene Sender)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Sender aufnehmen';
@@ -1921,64 +1913,4 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get premiumAhoraNo => 'Nicht jetzt';
@override
String get autoErrorEmisoraPremium =>
'Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.';
@override
String get autoErrorBusquedaSinResultados =>
'Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.';
@override
String get autoCarpetaEscuchar => 'Hören';
@override
String get autoCarpetaFavoritos => 'Favoriten';
@override
String get autoCarpetaTodas => 'Alle Sender';
@override
String get autoCarpetaMisEmisoras => 'Meine Sender';
@override
String get autoCarpetaMusicaLocal => 'Lokale Musik';
@override
String get autoMusicaLocalNoDisponible =>
'Öffne PluriWave auf dem Handy, um deine Musik zu lesen';
@override
String get autoCargarMas => 'Mehr…';
@override
String get autoOrdenarPorCalidad => 'Nach Qualität sortieren';
@override
String get autoReproducirCarpeta => 'Ordner abspielen';
@override
String get autoReproducirAleatorio => 'Zufallswiedergabe';
@override
String get autoPistaSinNombre => 'Unbenannter Titel';
@override
String get recordingsPrivateUseNotice =>
'Aufnahmen sind für deinen persönlichen Gebrauch bestimmt. Bitte respektiere die Rechte des Senders und seiner Inhalte.';
@override
String get visualizerRealWaveTitle => 'Echte Audio-Wellenform';
@override
String get visualizerRealWaveSubtitle =>
'Die Balken folgen dem laufenden Ton. Ausgeschaltet bewegen sie sich von selbst.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android erlaubt das Auslesen des Audiopegels nur mit der Mikrofonberechtigung. PluriWave hört das Mikrofon weder ab noch nimmt es auf: Es misst nur den Ton, den es ohnehin abspielt. Du kannst das jederzeit wieder ausschalten.';
@override
String get visualizerRealWaveEnableAction => 'Aktivieren';
}
+2 -70
View File
@@ -458,14 +458,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get recordingActionRename => 'Rename';
@override
String get stationActionShare => 'Share station';
@override
String get recordingActionOpenIn => 'Open in another app';
@override
String get recordingOpenNoAppError =>
'No app on this device can play this recording.';
String get recordingActionShare => 'Share';
@override
String get recordingActionDelete => 'Delete';
@@ -1882,8 +1875,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
@override
String get premiumBeneficioAndroidAuto =>
'Your full catalogue in Android Auto: favourites, my stations and local music (free: featured stations only)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Station recording';
@@ -1901,64 +1893,4 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get premiumAhoraNo => 'Not now';
@override
String get autoErrorEmisoraPremium =>
'This station is Premium. Open PluriWave on your phone to unlock it.';
@override
String get autoErrorBusquedaSinResultados =>
'We couldn\'t find that station. Try another name.';
@override
String get autoCarpetaEscuchar => 'Listen';
@override
String get autoCarpetaFavoritos => 'Favorites';
@override
String get autoCarpetaTodas => 'All stations';
@override
String get autoCarpetaMisEmisoras => 'My stations';
@override
String get autoCarpetaMusicaLocal => 'Local music';
@override
String get autoMusicaLocalNoDisponible =>
'Open PluriWave on your phone to read your music';
@override
String get autoCargarMas => 'More…';
@override
String get autoOrdenarPorCalidad => 'Sort by quality';
@override
String get autoReproducirCarpeta => 'Play folder';
@override
String get autoReproducirAleatorio => 'Shuffle play';
@override
String get autoPistaSinNombre => 'Untitled track';
@override
String get recordingsPrivateUseNotice =>
'Recordings are for your own personal use. Please respect each station\'s rights and those of its content.';
@override
String get visualizerRealWaveTitle => 'Real audio waveform';
@override
String get visualizerRealWaveSubtitle =>
'The bars follow the sound that is playing. When off, they animate on their own.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android only allows reading the audio level with the microphone permission. PluriWave never listens to or records the microphone: it only measures the sound it is already playing. You can turn this off at any time.';
@override
String get visualizerRealWaveEnableAction => 'Turn on';
}
+2 -70
View File
@@ -462,14 +462,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get recordingActionRename => 'Renombrar';
@override
String get stationActionShare => 'Compartir emisora';
@override
String get recordingActionOpenIn => 'Abrir en otra app';
@override
String get recordingOpenNoAppError =>
'No hay ninguna app en este dispositivo que pueda reproducir la grabación.';
String get recordingActionShare => 'Compartir';
@override
String get recordingActionDelete => 'Eliminar';
@@ -1896,8 +1889,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
@override
String get premiumBeneficioAndroidAuto =>
'Todo el catálogo en Android Auto: favoritos, mis emisoras y música local (gratis: solo destacadas)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Grabación de emisoras';
@@ -1916,64 +1908,4 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get premiumAhoraNo => 'Ahora no';
@override
String get autoErrorEmisoraPremium =>
'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.';
@override
String get autoErrorBusquedaSinResultados =>
'No hemos encontrado esa emisora. Prueba con otro nombre.';
@override
String get autoCarpetaEscuchar => 'Escuchar';
@override
String get autoCarpetaFavoritos => 'Favoritos';
@override
String get autoCarpetaTodas => 'Todas las emisoras';
@override
String get autoCarpetaMisEmisoras => 'Mis emisoras';
@override
String get autoCarpetaMusicaLocal => 'Música Local';
@override
String get autoMusicaLocalNoDisponible =>
'Abre PluriWave en el móvil para leer tu música';
@override
String get autoCargarMas => 'Más…';
@override
String get autoOrdenarPorCalidad => 'Ordenar por calidad';
@override
String get autoReproducirCarpeta => 'Reproducir carpeta';
@override
String get autoReproducirAleatorio => 'Reproducir aleatorio';
@override
String get autoPistaSinNombre => 'Pista sin nombre';
@override
String get recordingsPrivateUseNotice =>
'Las grabaciones son para tu uso personal. Respeta los derechos de cada emisora y de sus contenidos.';
@override
String get visualizerRealWaveTitle => 'Onda real del audio';
@override
String get visualizerRealWaveSubtitle =>
'Las barras siguen el sonido que está sonando. Desactivado, se animan solas.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android solo deja leer el nivel del audio con el permiso de micrófono. PluriWave no escucha ni graba el micrófono: solo mide el sonido que ya está reproduciendo. Puedes desactivarlo cuando quieras.';
@override
String get visualizerRealWaveEnableAction => 'Activar';
}
+2 -70
View File
@@ -467,14 +467,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get recordingActionRename => 'Renommer';
@override
String get stationActionShare => 'Partager la station';
@override
String get recordingActionOpenIn => 'Ouvrir dans une autre appli';
@override
String get recordingOpenNoAppError =>
'Aucune application de cet appareil ne peut lire cet enregistrement.';
String get recordingActionShare => 'Partager';
@override
String get recordingActionDelete => 'Supprimer';
@@ -1910,8 +1903,7 @@ class AppLocalizationsFr extends AppLocalizations {
'Aucune publicité dans toute l\'application';
@override
String get premiumBeneficioAndroidAuto =>
'Tout le catalogue dans Android Auto : favoris, mes stations et musique locale (gratuit : stations à la une uniquement)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Enregistrement des stations';
@@ -1930,64 +1922,4 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get premiumAhoraNo => 'Plus tard';
@override
String get autoErrorEmisoraPremium =>
'Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.';
@override
String get autoErrorBusquedaSinResultados =>
'Nous n\'avons pas trouvé cette station. Essaie un autre nom.';
@override
String get autoCarpetaEscuchar => 'Écouter';
@override
String get autoCarpetaFavoritos => 'Favoris';
@override
String get autoCarpetaTodas => 'Toutes les stations';
@override
String get autoCarpetaMisEmisoras => 'Mes stations';
@override
String get autoCarpetaMusicaLocal => 'Musique locale';
@override
String get autoMusicaLocalNoDisponible =>
'Ouvrez PluriWave sur votre téléphone pour lire votre musique';
@override
String get autoCargarMas => 'Plus…';
@override
String get autoOrdenarPorCalidad => 'Trier par qualité';
@override
String get autoReproducirCarpeta => 'Lire le dossier';
@override
String get autoReproducirAleatorio => 'Lecture aléatoire';
@override
String get autoPistaSinNombre => 'Piste sans nom';
@override
String get recordingsPrivateUseNotice =>
'Les enregistrements sont destinés à votre usage personnel. Merci de respecter les droits de chaque station et de ses contenus.';
@override
String get visualizerRealWaveTitle => 'Onde audio réelle';
@override
String get visualizerRealWaveSubtitle =>
'Les barres suivent le son en cours. Désactivées, elles s\'animent d\'elles-mêmes.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android n\'autorise la lecture du niveau audio qu\'avec l\'autorisation du microphone. PluriWave n\'écoute ni n\'enregistre le microphone : il mesure seulement le son qu\'il diffuse déjà. Vous pouvez le désactiver à tout moment.';
@override
String get visualizerRealWaveEnableAction => 'Activer';
}
+2 -70
View File
@@ -459,14 +459,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get recordingActionRename => 'नाम बदलें';
@override
String get stationActionShare => 'स्टेशन शेयर करें';
@override
String get recordingActionOpenIn => 'दूसरे ऐप में खोलें';
@override
String get recordingOpenNoAppError =>
'इस डिवाइस पर ऐसा कोई ऐप नहीं है जो यह रिकॉर्डिंग चला सके।';
String get recordingActionShare => 'शेयर करें';
@override
String get recordingActionDelete => 'हटाएं';
@@ -1882,8 +1875,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
@override
String get premiumBeneficioAndroidAuto =>
'Android Auto में पूरी सूची: पसंदीदा, मेरे स्टेशन और स्थानीय संगीत (मुफ़्त: सिर्फ़ चुनिंदा स्टेशन)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
@@ -1901,64 +1893,4 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get premiumAhoraNo => 'अभी नहीं';
@override
String get autoErrorEmisoraPremium =>
'यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।';
@override
String get autoErrorBusquedaSinResultados =>
'वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।';
@override
String get autoCarpetaEscuchar => 'सुनें';
@override
String get autoCarpetaFavoritos => 'पसंदीदा';
@override
String get autoCarpetaTodas => 'सभी स्टेशन';
@override
String get autoCarpetaMisEmisoras => 'मेरे स्टेशन';
@override
String get autoCarpetaMusicaLocal => 'लोकल संगीत';
@override
String get autoMusicaLocalNoDisponible =>
'अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें';
@override
String get autoCargarMas => 'और…';
@override
String get autoOrdenarPorCalidad => 'गुणवत्ता के अनुसार क्रमबद्ध करें';
@override
String get autoReproducirCarpeta => 'फ़ोल्डर चलाएँ';
@override
String get autoReproducirAleatorio => 'शफ़ल चलाएँ';
@override
String get autoPistaSinNombre => 'बिना नाम का ट्रैक';
@override
String get recordingsPrivateUseNotice =>
'रिकॉर्डिंग आपके निजी उपयोग के लिए हैं। कृपया हर स्टेशन और उसकी सामग्री के अधिकारों का सम्मान करें।';
@override
String get visualizerRealWaveTitle => 'असली ऑडियो तरंग';
@override
String get visualizerRealWaveSubtitle =>
'बार चल रही आवाज़ का अनुसरण करते हैं। बंद होने पर वे अपने आप चलते हैं।';
@override
String get visualizerRealWavePermissionExplanation =>
'Android माइक्रोफ़ोन अनुमति के बिना ऑडियो स्तर पढ़ने नहीं देता। PluriWave माइक्रोफ़ोन को न सुनता है न रिकॉर्ड करता है: यह केवल पहले से बज रही आवाज़ मापता है। आप इसे कभी भी बंद कर सकते हैं।';
@override
String get visualizerRealWaveEnableAction => 'चालू करें';
}
+2 -70
View File
@@ -459,14 +459,7 @@ class AppLocalizationsId extends AppLocalizations {
String get recordingActionRename => 'Ganti nama';
@override
String get stationActionShare => 'Bagikan stasiun';
@override
String get recordingActionOpenIn => 'Buka di aplikasi lain';
@override
String get recordingOpenNoAppError =>
'Tidak ada aplikasi di perangkat ini yang dapat memutar rekaman ini.';
String get recordingActionShare => 'Bagikan';
@override
String get recordingActionDelete => 'Hapus';
@@ -1893,8 +1886,7 @@ class AppLocalizationsId extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
@override
String get premiumBeneficioAndroidAuto =>
'Seluruh katalog di Android Auto: favorit, stasiun saya, dan musik lokal (gratis: hanya stasiun pilihan)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
@@ -1912,64 +1904,4 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get premiumAhoraNo => 'Nanti saja';
@override
String get autoErrorEmisoraPremium =>
'Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.';
@override
String get autoErrorBusquedaSinResultados =>
'Kami tidak menemukan stasiun itu. Coba nama lain.';
@override
String get autoCarpetaEscuchar => 'Dengarkan';
@override
String get autoCarpetaFavoritos => 'Favorit';
@override
String get autoCarpetaTodas => 'Semua stasiun';
@override
String get autoCarpetaMisEmisoras => 'Stasiun saya';
@override
String get autoCarpetaMusicaLocal => 'Musik lokal';
@override
String get autoMusicaLocalNoDisponible =>
'Buka PluriWave di ponsel untuk membaca musik Anda';
@override
String get autoCargarMas => 'Lainnya…';
@override
String get autoOrdenarPorCalidad => 'Urutkan menurut kualitas';
@override
String get autoReproducirCarpeta => 'Putar folder';
@override
String get autoReproducirAleatorio => 'Putar acak';
@override
String get autoPistaSinNombre => 'Trek tanpa nama';
@override
String get recordingsPrivateUseNotice =>
'Rekaman ditujukan untuk penggunaan pribadi Anda. Hormati hak setiap stasiun dan hak atas kontennya.';
@override
String get visualizerRealWaveTitle => 'Gelombang audio asli';
@override
String get visualizerRealWaveSubtitle =>
'Bilah mengikuti suara yang sedang diputar. Saat nonaktif, bilah bergerak sendiri.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android hanya mengizinkan pembacaan level audio dengan izin mikrofon. PluriWave tidak mendengarkan atau merekam mikrofon: aplikasi hanya mengukur suara yang sedang diputar. Anda dapat menonaktifkannya kapan saja.';
@override
String get visualizerRealWaveEnableAction => 'Aktifkan';
}
+2 -70
View File
@@ -463,14 +463,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get recordingActionRename => 'Rinomina';
@override
String get stationActionShare => 'Condividi stazione';
@override
String get recordingActionOpenIn => 'Apri in un\'altra app';
@override
String get recordingOpenNoAppError =>
'Nessuna app su questo dispositivo può riprodurre questa registrazione.';
String get recordingActionShare => 'Condividi';
@override
String get recordingActionDelete => 'Elimina';
@@ -1907,8 +1900,7 @@ class AppLocalizationsIt extends AppLocalizations {
'Nessuna pubblicità in tutta l\'app';
@override
String get premiumBeneficioAndroidAuto =>
'Tutto il catalogo in Android Auto: preferiti, le mie stazioni e musica locale (gratis: solo stazioni in evidenza)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Registrazione delle stazioni';
@@ -1927,64 +1919,4 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get premiumAhoraNo => 'Non ora';
@override
String get autoErrorEmisoraPremium =>
'Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.';
@override
String get autoErrorBusquedaSinResultados =>
'Non abbiamo trovato quella stazione. Prova con un altro nome.';
@override
String get autoCarpetaEscuchar => 'Ascolta';
@override
String get autoCarpetaFavoritos => 'Preferiti';
@override
String get autoCarpetaTodas => 'Tutte le emittenti';
@override
String get autoCarpetaMisEmisoras => 'Le mie emittenti';
@override
String get autoCarpetaMusicaLocal => 'Musica locale';
@override
String get autoMusicaLocalNoDisponible =>
'Apri PluriWave sul telefono per leggere la tua musica';
@override
String get autoCargarMas => 'Altro…';
@override
String get autoOrdenarPorCalidad => 'Ordina per qualità';
@override
String get autoReproducirCarpeta => 'Riproduci cartella';
@override
String get autoReproducirAleatorio => 'Riproduzione casuale';
@override
String get autoPistaSinNombre => 'Traccia senza nome';
@override
String get recordingsPrivateUseNotice =>
'Le registrazioni sono per il tuo uso personale. Rispetta i diritti di ogni stazione e dei suoi contenuti.';
@override
String get visualizerRealWaveTitle => 'Onda audio reale';
@override
String get visualizerRealWaveSubtitle =>
'Le barre seguono il suono in riproduzione. Se disattivata, si animano da sole.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android consente di leggere il livello audio solo con l\'autorizzazione del microfono. PluriWave non ascolta né registra il microfono: misura soltanto il suono che sta già riproducendo. Puoi disattivarlo quando vuoi.';
@override
String get visualizerRealWaveEnableAction => 'Attiva';
}
+2 -67
View File
@@ -445,13 +445,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get recordingActionRename => '名前を変更';
@override
String get stationActionShare => '放送局を共有';
@override
String get recordingActionOpenIn => '別のアプリで開く';
@override
String get recordingOpenNoAppError => 'この端末には、この録音を再生できるアプリがありません。';
String get recordingActionShare => '共有';
@override
String get recordingActionDelete => '削除';
@@ -1826,8 +1820,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
@override
String get premiumBeneficioAndroidAuto =>
'Android Auto でカタログ全体を利用: お気に入り、マイ局、ローカル音楽 (無料版はおすすめ局のみ)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => '放送局の録音';
@@ -1843,62 +1836,4 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get premiumAhoraNo => '後で';
@override
String get autoErrorEmisoraPremium =>
'この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。';
@override
String get autoErrorBusquedaSinResultados => 'その放送局は見つかりませんでした。別の名前をお試しください。';
@override
String get autoCarpetaEscuchar => '聴く';
@override
String get autoCarpetaFavoritos => 'お気に入り';
@override
String get autoCarpetaTodas => 'すべての局';
@override
String get autoCarpetaMisEmisoras => 'マイ局';
@override
String get autoCarpetaMusicaLocal => 'ローカルの音楽';
@override
String get autoMusicaLocalNoDisponible =>
'音楽を読み込むにはスマートフォンで PluriWave を開いてください';
@override
String get autoCargarMas => 'もっと見る…';
@override
String get autoOrdenarPorCalidad => '音質順に並べ替え';
@override
String get autoReproducirCarpeta => 'フォルダを再生';
@override
String get autoReproducirAleatorio => 'シャッフル再生';
@override
String get autoPistaSinNombre => '名称未設定のトラック';
@override
String get recordingsPrivateUseNotice =>
'録音はあなた個人の利用のためのものです。各放送局とその内容に関する権利を尊重してください。';
@override
String get visualizerRealWaveTitle => '実際の音声波形';
@override
String get visualizerRealWaveSubtitle => 'バーが再生中の音に合わせて動きます。オフのときは独自に動きます。';
@override
String get visualizerRealWavePermissionExplanation =>
'Android では、マイクの権限がないと音声レベルを読み取れません。PluriWave がマイクを聞いたり録音したりすることはありません。再生中の音の大きさを測るだけです。いつでもオフにできます。';
@override
String get visualizerRealWaveEnableAction => 'オンにする';
}
+2 -70
View File
@@ -461,14 +461,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get recordingActionRename => 'Renomear';
@override
String get stationActionShare => 'Compartilhar estação';
@override
String get recordingActionOpenIn => 'Abrir em outro app';
@override
String get recordingOpenNoAppError =>
'Nenhum app deste dispositivo consegue reproduzir esta gravação.';
String get recordingActionShare => 'Compartilhar';
@override
String get recordingActionDelete => 'Excluir';
@@ -1893,8 +1886,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
@override
String get premiumBeneficioAndroidAuto =>
'Todo o catálogo no Android Auto: favoritos, as minhas estações e música local (grátis: apenas estações em destaque)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Gravação de emissoras';
@@ -1912,64 +1904,4 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get premiumAhoraNo => 'Agora não';
@override
String get autoErrorEmisoraPremium =>
'Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.';
@override
String get autoErrorBusquedaSinResultados =>
'Não encontrámos essa estação. Tente outro nome.';
@override
String get autoCarpetaEscuchar => 'Ouvir';
@override
String get autoCarpetaFavoritos => 'Favoritos';
@override
String get autoCarpetaTodas => 'Todas as estações';
@override
String get autoCarpetaMisEmisoras => 'As minhas estações';
@override
String get autoCarpetaMusicaLocal => 'Música local';
@override
String get autoMusicaLocalNoDisponible =>
'Abra o PluriWave no telemóvel para ler a sua música';
@override
String get autoCargarMas => 'Mais…';
@override
String get autoOrdenarPorCalidad => 'Ordenar por qualidade';
@override
String get autoReproducirCarpeta => 'Reproduzir pasta';
@override
String get autoReproducirAleatorio => 'Reprodução aleatória';
@override
String get autoPistaSinNombre => 'Faixa sem nome';
@override
String get recordingsPrivateUseNotice =>
'As gravações são para o teu uso pessoal. Respeita os direitos de cada estação e dos seus conteúdos.';
@override
String get visualizerRealWaveTitle => 'Onda de áudio real';
@override
String get visualizerRealWaveSubtitle =>
'As barras seguem o som que está a tocar. Desativado, animam-se sozinhas.';
@override
String get visualizerRealWavePermissionExplanation =>
'O Android só permite ler o nível de áudio com a permissão do microfone. O PluriWave não escuta nem grava o microfone: apenas mede o som que já está a reproduzir. Podes desativar isto quando quiseres.';
@override
String get visualizerRealWaveEnableAction => 'Ativar';
}
+2 -70
View File
@@ -461,14 +461,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get recordingActionRename => 'Переименовать';
@override
String get stationActionShare => 'Поделиться станцией';
@override
String get recordingActionOpenIn => 'Открыть в другом приложении';
@override
String get recordingOpenNoAppError =>
'На этом устройстве нет приложения, способного воспроизвести эту запись.';
String get recordingActionShare => 'Поделиться';
@override
String get recordingActionDelete => 'Удалить';
@@ -1900,8 +1893,7 @@ class AppLocalizationsRu extends AppLocalizations {
'Никакой рекламы во всём приложении';
@override
String get premiumBeneficioAndroidAuto =>
'Весь каталог в Android Auto: избранное, мои станции и локальная музыка (бесплатно: только рекомендуемые)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Запись радиостанций';
@@ -1919,64 +1911,4 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get premiumAhoraNo => 'Не сейчас';
@override
String get autoErrorEmisoraPremium =>
'Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.';
@override
String get autoErrorBusquedaSinResultados =>
'Мы не нашли такую станцию. Попробуйте другое название.';
@override
String get autoCarpetaEscuchar => 'Слушать';
@override
String get autoCarpetaFavoritos => 'Избранное';
@override
String get autoCarpetaTodas => 'Все станции';
@override
String get autoCarpetaMisEmisoras => 'Мои станции';
@override
String get autoCarpetaMusicaLocal => 'Локальная музыка';
@override
String get autoMusicaLocalNoDisponible =>
'Откройте PluriWave на телефоне, чтобы прочитать вашу музыку';
@override
String get autoCargarMas => 'Ещё…';
@override
String get autoOrdenarPorCalidad => 'Сортировать по качеству';
@override
String get autoReproducirCarpeta => 'Воспроизвести папку';
@override
String get autoReproducirAleatorio => 'Случайное воспроизведение';
@override
String get autoPistaSinNombre => 'Трек без названия';
@override
String get recordingsPrivateUseNotice =>
'Записи предназначены только для личного использования. Уважайте права станций и их контента.';
@override
String get visualizerRealWaveTitle => 'Реальная звуковая волна';
@override
String get visualizerRealWaveSubtitle =>
'Полосы следуют за звучащим аудио. Если выключено, они движутся сами по себе.';
@override
String get visualizerRealWavePermissionExplanation =>
'Android разрешает считывать уровень звука только с разрешением на микрофон. PluriWave не слушает и не записывает микрофон: он лишь измеряет уже воспроизводимый звук. Отключить можно в любой момент.';
@override
String get visualizerRealWaveEnableAction => 'Включить';
}
+2 -65
View File
@@ -443,13 +443,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get recordingActionRename => '重命名';
@override
String get stationActionShare => '分享电台';
@override
String get recordingActionOpenIn => '用其他应用打开';
@override
String get recordingOpenNoAppError => '此设备上没有可播放该录音的应用。';
String get recordingActionShare => '分享';
@override
String get recordingActionDelete => '删除';
@@ -1811,8 +1805,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get premiumBeneficioSinAnuncios => '全应用无广告';
@override
String get premiumBeneficioAndroidAuto =>
'在 Android Auto 中使用完整电台库:收藏、我的电台和本地音乐(免费版仅限精选电台)';
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => '电台录音';
@@ -1828,60 +1821,4 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get premiumAhoraNo => '以后再说';
@override
String get autoErrorEmisoraPremium =>
'该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。';
@override
String get autoErrorBusquedaSinResultados => '没有找到该电台。请换个名称再试。';
@override
String get autoCarpetaEscuchar => '收听';
@override
String get autoCarpetaFavoritos => '收藏';
@override
String get autoCarpetaTodas => '全部电台';
@override
String get autoCarpetaMisEmisoras => '我的电台';
@override
String get autoCarpetaMusicaLocal => '本地音乐';
@override
String get autoMusicaLocalNoDisponible => '请在手机上打开 PluriWave 以读取您的音乐';
@override
String get autoCargarMas => '更多…';
@override
String get autoOrdenarPorCalidad => '按音质排序';
@override
String get autoReproducirCarpeta => '播放文件夹';
@override
String get autoReproducirAleatorio => '随机播放';
@override
String get autoPistaSinNombre => '未命名曲目';
@override
String get recordingsPrivateUseNotice => '录音仅供你个人使用。请尊重各电台及其内容的权利。';
@override
String get visualizerRealWaveTitle => '真实音频波形';
@override
String get visualizerRealWaveSubtitle => '音条会跟随正在播放的声音。关闭时,音条会自行跳动。';
@override
String get visualizerRealWavePermissionExplanation =>
'Android 只有在获得麦克风权限后才允许读取音频电平。PluriWave 不会监听或录制麦克风,只测量它正在播放的声音。你可以随时关闭此功能。';
@override
String get visualizerRealWaveEnableAction => '开启';
}
+2 -36
View File
@@ -10,8 +10,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
@@ -20,7 +18,6 @@ import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'servicios/verificacion_licencia.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
@@ -147,16 +144,6 @@ Future<void> main() async {
// injected into every state/service below.
final prefs = await SharedPreferences.getInstance();
// Silent license re-verification (refund revocation) for the Android Auto
// path: this engine may be the headless one, with no widget tree and so no
// `EstadoEntitlement`. The browse root triggers it fire-and-forget; the
// shared in-flight guard in `verificarLicencia` keeps it to one query at a
// time even when the phone UI checks too.
registrarVerificacionLicenciaAuto(
() =>
verificarLicencia(consultar: compras.consultarPropiedad, prefs: prefs),
);
// User-saved EQ presets for the car's Ecualizador folder, same
// injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a
@@ -209,28 +196,6 @@ Future<void> main() async {
handler,
leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo,
// The PRESET's half of the same seam. Without it the handler enabled
// the equalizer with its hardcoded FLAT preset on any engine where the
// phone UI never ran — i.e. every headless Android Auto bind. There is
// no write port: `EstadoEcualizador` still owns saving presets (a car
// preset choice goes through it), so the handler only ever reads.
leerPresetPersistido: ecualizador.leerPresetPrincipal,
// Skip context («in which list am I»). Bound here, on the audio
// bootstrap path of EVERY engine, precisely because the headless
// Android Auto engine builds no widget tree and therefore no
// `EstadoRadio`: a context only the phone UI could write would be a
// context the car could never have.
leerContextoSalto: contextoSaltoPersistido,
guardarContextoSalto: guardarContextoSalto,
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
// reason as the skip context: `EstadoRadio` — which used to be its only
// writer — belongs to the widget tree, and the Android Auto engine
// builds none, so a session that happened only in the car never updated
// the key and the head unit was offered whatever the PHONE last played.
// The write port is now the key's single writer; the read port feeds the
// cold-start metadata seed and the bare-`play()` resume.
leerUltimaEmisora: ultimaEmisoraPersistida,
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
@@ -365,7 +330,8 @@ bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) => !yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
@@ -1,136 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../estado/estado_visualizador.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// AUDIO group · "Onda real del audio" — the point-of-intent opt-in for the
/// waveform visualizer's microphone permission.
///
/// Before this screen existed, `RECORD_AUDIO` was requested the moment
/// `VisualizadorAudio` subscribed to its native EventChannel, which the home
/// screen's "Escuchar" hero does on the user's FIRST play. A radio app that
/// pops "allow PluriWave to record audio?" the first time you press play is
/// asking for a sensitive permission with zero context, and Play expects
/// context.
///
/// Mirrors `PantallaAjustesSalidaAudio`'s point-of-intent shape (its
/// BLUETOOTH_CONNECT request sits behind the multi-device toggle the same
/// way), with one addition: the explanation is shown and accepted BEFORE the
/// flag flips, since flipping it is what triggers the system dialog.
class PantallaAjustesVisualizador extends StatelessWidget {
const PantallaAjustesVisualizador({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriPushScaffold(
title: l10n.visualizerRealWaveTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoVisualizador()],
),
);
}
}
class _CuerpoVisualizador extends StatelessWidget {
const _CuerpoVisualizador();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoVisualizador>();
final habilitada = estado.ondaRealHabilitada;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// GestureDetector + custom row instead of SwitchListTile, for the
// same reason PantallaAjustesSalidaAudio does it: ListTile ink
// inside PluriGlassSurface's DecoratedBox trips a Material
// assertion.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _alternar(context, estado, !habilitada),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.visualizerRealWaveTitle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.visualizerRealWaveSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: habilitada,
onChanged: (valor) => _alternar(context, estado, valor),
),
],
),
),
const SizedBox(height: 8),
// The same text the confirmation dialog shows, kept permanently on
// screen: a user who already granted the permission should be able
// to re-read what it is for without toggling anything.
Text(
l10n.visualizerRealWavePermissionExplanation,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
/// Turning it OFF is immediate — withdrawing a permission must never be
/// harder than granting it. Turning it ON goes through the explanation
/// first, and only a deliberate confirmation flips the flag.
Future<void> _alternar(
BuildContext context,
EstadoVisualizador estado,
bool habilitada,
) async {
if (!habilitada) {
await estado.cambiarOndaReal(false);
return;
}
final l10n = AppLocalizations.of(context);
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
key: const ValueKey('visualizador-explicacion-permiso'),
title: Text(l10n.visualizerRealWaveTitle),
content: Text(l10n.visualizerRealWavePermissionExplanation),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.visualizerRealWaveEnableAction),
),
],
),
);
if (confirmado != true) return;
await estado.cambiarOndaReal(true);
}
}
-21
View File
@@ -4,7 +4,6 @@ import 'package:provider/provider.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_entitlement.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_visualizador.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
@@ -28,7 +27,6 @@ import 'ajustes/pantalla_ajustes_musica_local.dart';
import 'ajustes/pantalla_ajustes_orden_listas.dart';
import 'ajustes/pantalla_ajustes_salida_audio.dart';
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
import 'ajustes/pantalla_ajustes_visualizador.dart';
import 'ajustes/widgets/fila_ajuste.dart';
import 'pantalla_grabaciones.dart';
@@ -106,9 +104,6 @@ class _AjustesContent extends StatelessWidget {
final esPremium = context.select<EstadoEntitlement, bool>(
(e) => e.esPremium,
);
final ondaRealActiva = context.select<EstadoVisualizador, bool>(
(e) => e.ondaRealHabilitada,
);
return Column(
children: [
@@ -142,22 +137,6 @@ class _AjustesContent extends StatelessWidget {
(_) => const PantallaAjustesSalidaAudio(),
),
),
// Point-of-intent entry for the waveform visualizer's
// microphone opt-in. It is a nav row, not an inline switch,
// because the Settings root carries zero inline controls by
// design — the switch and its explanation live on the detail
// screen, which is also where the user reads what the
// permission is for before granting it.
FilaAjuste(
icon: Icons.graphic_eq_rounded,
titulo: l10n.visualizerRealWaveTitle,
valor: ondaRealActiva ? l10n.equalizerActive : null,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesVisualizador(),
),
),
FilaAjuste(
icon: Icons.bedtime_rounded,
titulo: l10n.timerSectionTitle,
+13 -53
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
@@ -89,24 +90,19 @@ class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
/// WU15: the recordings library — storage usage, browsable rows with
/// inline playback, and a "⋮" menu constrained to exactly
/// Rename/Open-in-another-app/Delete (`recordings-library` spec). Distinct
/// from `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// SETTINGS screen, not this browsable file list.
class PantallaGrabaciones extends StatefulWidget {
const PantallaGrabaciones({
super.key,
ReproductorGrabaciones? reproductor,
Future<bool> Function(String ruta)? abrirEnOtraApp,
Future<void> Function(String ruta)? compartir,
}) : _reproductorInyectado = reproductor,
_abrirEnOtraAppInyectada = abrirEnOtraApp;
_compartirInyectado = compartir;
final ReproductorGrabaciones? _reproductorInyectado;
/// Seam for the local-open action. Was `compartir`, which handed the audio
/// file to the system share sheet — redistribution of someone else's
/// broadcast. It now opens the file in a player already installed on THIS
/// device, and returns whether any app accepted it.
final Future<bool> Function(String ruta)? _abrirEnOtraAppInyectada;
final Future<void> Function(String ruta)? _compartirInyectado;
@override
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
@@ -115,9 +111,8 @@ class PantallaGrabaciones extends StatefulWidget {
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
late final ReproductorGrabaciones _reproductor =
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
late final Future<bool> Function(String ruta) _abrirEnOtraApp =
widget._abrirEnOtraAppInyectada ??
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(ruta);
late final Future<void> Function(String ruta) _compartir =
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
late Future<List<ArchivoGrabacion>> _grabaciones;
final Map<String, Future<Duration?>> _duracionCache = {};
@@ -158,8 +153,8 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
await _renombrar(archivo);
return;
}
if (accion == 'open') {
await _abrirLocalmente(archivo);
if (accion == 'share') {
await _compartir(archivo.ruta);
return;
}
if (accion == 'delete') {
@@ -167,20 +162,6 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
}
}
/// Plays the user's own recording in another app on the same device. A
/// device with no audio viewer installed (and the native side's own
/// fallback to the containing folder failing too) returns `false` — the
/// action then says so instead of looking like a dead menu entry.
Future<void> _abrirLocalmente(ArchivoGrabacion archivo) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final abierto = await _abrirEnOtraApp(archivo.ruta);
if (!mounted || abierto) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingOpenNoAppError)),
);
}
Future<void> _renombrar(ArchivoGrabacion archivo) async {
final nuevoNombre = await showDialog<String>(
context: context,
@@ -305,27 +286,6 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
),
],
),
// Production-readiness pass: recording a broadcast holds up as
// a private copy, and stops holding up the moment the product
// reads as a redistribution tool. The library had no such
// statement at all, while the manifest already publishes the
// recordings folder to the system file manager
// (RecordingsDocumentsProvider). Deliberately factual and
// low-key — a footnote, not a warning banner — and always
// visible, empty library included.
const SizedBox(height: 16),
Padding(
key: const ValueKey('grabaciones-aviso-uso-privado'),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
l10n.recordingsPrivateUseNotice,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
),
],
);
},
@@ -413,7 +373,7 @@ class _FilaGrabacion extends StatelessWidget {
// 44x44/radius-12 art placeholder (recordings carry no per-station
// favicon, so this is a themed fallback square, not invented artwork),
// name, meta line, a 24px play/pause affordance, and the SAME "-"
// menu (Rename/Open in another app/Delete) as before, just restyled.
// menu (Rename/Share/Delete) as before, just restyled.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
@@ -506,8 +466,8 @@ class _FilaGrabacion extends StatelessWidget {
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'open',
child: Text(l10n.recordingActionOpenIn),
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
-9
View File
@@ -6,7 +6,6 @@ import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_ecualizador.dart';
import '../estado/estado_navegacion.dart';
import '../estado/estado_radio.dart';
import '../estado/estado_visualizador.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
@@ -472,14 +471,6 @@ class _EscucharHero extends StatelessWidget {
// Audit 1.7 (t4 lines 66-68): 30 discrete
// bottom-anchored bars, not a continuous stroke.
barrasDiscretas: true,
// Sensitive-permission gate: subscribing to the
// native waveform channel is what makes Android ask
// for RECORD_AUDIO, so it happens only after the
// user opts in from Settings.
capturaRealHabilitada:
context
.watch<EstadoVisualizador>()
.ondaRealHabilitada,
),
],
),
+1 -10
View File
@@ -9,7 +9,6 @@ import 'package:shimmer/shimmer.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_radio.dart';
import '../estado/estado_visualizador.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
@@ -186,10 +185,6 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
color: tokens.warmCoral,
altura: 40,
barrasDiscretas: true,
// Sensitive-permission gate: see the same note on the
// Escuchar hero's visualizer in `pantalla_inicio.dart`.
capturaRealHabilitada:
context.watch<EstadoVisualizador>().ondaRealHabilitada,
gradienteFinAlpha: 0.45,
).pluriFadeIn(
context,
@@ -1138,11 +1133,7 @@ class _BandejaHerramientas extends StatelessWidget {
child: _TileHerramienta(
key: const Key('player-tool-share'),
icon: Icons.share_rounded,
// Shares the STATION — its name and its stream url — never an
// audio file. This used to borrow `recordingActionShare`, the
// recordings library's own menu label, which made one key stand
// for two unrelated actions.
label: l10n.stationActionShare,
label: l10n.recordingActionShare,
onTap: () => compartir('${emisora.nombre}\n${emisora.url}'),
),
),
-278
View File
@@ -1,278 +0,0 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
/// The skip context's persistence key.
///
/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports
/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor
/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the
/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is
/// never constructed there — a context only that class could write would be a
/// context the car can never have.
///
/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly
/// instead of silently leaving every driver context-less after an update.
const claveContextoSalto = 'contexto_salto_v1';
/// Which LIST the driver is walking with the car's previous/next buttons.
///
/// The type is the durable part; the members are not. A group's contents
/// change between sessions (the phone renames it, empties it, deletes it), so
/// remembering the members would be remembering something that expires —
/// [resolverListaContexto] re-resolves against the LIVE lists every time.
enum TipoContextoSalto {
/// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId].
grupoFavoritos,
favoritos,
misEmisoras,
/// The `populares` catalogue snapshot.
todas,
/// The free tier's curated set (`emisorasDestacadas`). The ONLY type that
/// carries [ContextoSalto.uuidsOrdenados] — see that field.
destacadas,
}
/// The remembered playback context: the smallest thing that still identifies
/// the list on the other side of a process restart.
class ContextoSalto {
/// One favourites group, named by its stable id.
const ContextoSalto.grupo(String grupoId)
: tipo = TipoContextoSalto.grupoFavoritos,
grupoFavoritosId = grupoId,
uuidsOrdenados = const [];
const ContextoSalto.favoritos()
: tipo = TipoContextoSalto.favoritos,
grupoFavoritosId = null,
uuidsOrdenados = const [];
const ContextoSalto.misEmisoras()
: tipo = TipoContextoSalto.misEmisoras,
grupoFavoritosId = null,
uuidsOrdenados = const [];
const ContextoSalto.todas()
: tipo = TipoContextoSalto.todas,
grupoFavoritosId = null,
uuidsOrdenados = const [];
/// The free tier's set, FROZEN in [uuids] order.
const ContextoSalto.destacadas(List<String> uuids)
: tipo = TipoContextoSalto.destacadas,
grupoFavoritosId = null,
uuidsOrdenados = uuids;
final TipoContextoSalto tipo;
/// Set only for [TipoContextoSalto.grupoFavoritos].
final String? grupoFavoritosId;
/// The frozen order, set only for [TipoContextoSalto.destacadas].
///
/// Every other type resolves against a list that HAS a stable, user-owned
/// order (the favourites' `orden` column, the custom-stations file, the
/// catalogue snapshot), so freezing it would only mean ignoring a reorder
/// the user just made on the phone. The free set is the exception:
/// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida,
/// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous`
/// stops being the inverse of `next`. Freezing that order is the fix.
final List<String> uuidsOrdenados;
Map<String, dynamic> aMapa() => {
'tipo': tipo.name,
if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId,
if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados,
};
/// Parses a persisted map, or `null` when it is unusable.
///
/// Tolerant on purpose: this payload survives app updates, backups and
/// hand-edited preference files, and it is read from a steering-wheel
/// button. An unreadable context must mean "derive it again", never a
/// crash.
static ContextoSalto? desdeMapa(Map<String, dynamic> mapa) {
final tipoRaw = mapa['tipo'];
if (tipoRaw is! String) return null;
final tipo = TipoContextoSalto.values
.where((t) => t.name == tipoRaw)
.firstOrNull;
if (tipo == null) return null;
switch (tipo) {
case TipoContextoSalto.grupoFavoritos:
final grupoId = mapa['grupoId'];
// A group context with no group is not a context.
if (grupoId is! String || grupoId.isEmpty) return null;
return ContextoSalto.grupo(grupoId);
case TipoContextoSalto.favoritos:
return const ContextoSalto.favoritos();
case TipoContextoSalto.misEmisoras:
return const ContextoSalto.misEmisoras();
case TipoContextoSalto.todas:
return const ContextoSalto.todas();
case TipoContextoSalto.destacadas:
final uuids = mapa['uuids'];
if (uuids is! List) return null;
return ContextoSalto.destacadas(uuids.whereType<String>().toList());
}
}
@override
bool operator ==(Object other) =>
other is ContextoSalto &&
other.tipo == tipo &&
other.grupoFavoritosId == grupoFavoritosId &&
_mismosUuids(other.uuidsOrdenados, uuidsOrdenados);
@override
int get hashCode => Object.hash(
tipo,
grupoFavoritosId,
Object.hashAll(uuidsOrdenados),
);
@override
String toString() =>
'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, '
'uuids=${uuidsOrdenados.length})';
static bool _mismosUuids(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
/// Persists [contexto]. Never throws — a failed write costs the driver a
/// re-derivation, an exception would cost them the station change.
Future<void> guardarContextoSalto(
ContextoSalto contexto, {
SharedPreferences? prefs,
}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa()));
} catch (_) {
// Deliberately swallowed — see the doc above.
}
}
/// Reads the persisted context, or `null` when there is none, the payload is
/// unreadable, or prefs themselves fail.
///
/// Follows the same inject-or-`getInstance()` convention as
/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins
/// prefs without a platform channel.
Future<ContextoSalto?> contextoSaltoPersistido({
SharedPreferences? prefs,
}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
final raw = resueltas.getString(claveContextoSalto);
if (raw == null) return null;
final decodificado = jsonDecode(raw);
if (decodificado is! Map) return null;
return ContextoSalto.desdeMapa(Map<String, dynamic>.from(decodificado));
} catch (_) {
return null;
}
}
/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an
/// empty list when it no longer resolves at all.
///
/// Pure — no handler, no prefs — so every degradation rule below is testable
/// on its own. An empty result means "this memory has expired": the caller
/// derives a fresh context instead (and, failing that, leaves playback alone —
/// never jumps somewhere arbitrary mid-drive).
///
/// Degradation rules, all of them deliberate. The group chain is the owner's,
/// decided from real use in the car:
/// * remembered group ALIVE -> it is walked, even when the playing station
/// has LEFT it (the caller then takes the group's first station) and even
/// when it is down to a single member (skipping there simply leaves the
/// driver where they are — a one-station group is still a group).
/// * remembered group DELETED, or alive but EMPTY -> widen to all
/// favourites, whether or not the playing station is still one of them:
/// "if the whole group is gone, pick a station from the favourites".
/// * no favourites left -> empty, i.e. the no-stations behaviour.
/// * every OTHER context type still expires when the playing station left
/// its list (unfavourited, removed from the catalogue snapshot) — the
/// owner's decision was about the group chain only.
/// * [TipoContextoSalto.destacadas] alone honours
/// [ContextoSalto.uuidsOrdenados] — see that field for why.
List<Emisora> resolverListaContexto({
required ContextoSalto contexto,
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
required List<Emisora> destacadas,
required List<GrupoFavoritos> grupos,
}) {
bool contiene(List<Emisora> lista) =>
lista.any((e) => e.uuid == actual.uuid);
switch (contexto.tipo) {
case TipoContextoSalto.grupoFavoritos:
final grupoId = contexto.grupoFavoritosId;
if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) {
return const [];
}
final existe = grupos.any((g) => g.id == grupoId);
final miembros =
favoritos.where((e) => e.grupoFavoritosId == grupoId).toList();
if (existe && miembros.isNotEmpty) {
// A surviving group is honoured as-is. The station does NOT have to
// still be in it — the caller takes the group's first station rather
// than wandering off to another list.
return miembros;
}
// Group deleted (or alive but empty, which offers no station to take):
// widen to all favourites. Unlike the other context types this does not
// require the station to still BE a favourite — the caller takes the
// first one.
return favoritos;
case TipoContextoSalto.favoritos:
return contiene(favoritos) ? favoritos : const [];
case TipoContextoSalto.misEmisoras:
return contiene(misEmisoras) ? misEmisoras : const [];
case TipoContextoSalto.todas:
return contiene(todas) ? todas : const [];
case TipoContextoSalto.destacadas:
// The frozen order is authoritative. `actual` is resolvable from itself
// so a station frozen into the walk from a previous session still
// resolves even when it never belonged to the curated set.
final porUuid = <String, Emisora>{
for (final e in destacadas) e.uuid: e,
actual.uuid: actual,
};
final lista = <Emisora>[
for (final uuid in contexto.uuidsOrdenados)
if (porUuid[uuid] != null) porUuid[uuid]!,
];
return lista.any((e) => e.uuid == actual.uuid) ? lista : const [];
}
}
/// The free tier's frozen walk order: the curated set in its compiled-in
/// order, with [actual] prepended when it does not belong to it.
///
/// Prepending rather than dropping keeps both buttons alive for a station left
/// over from a premium session (or from `ultima_emisora_v1`): a walk the
/// playing station is not part of would make `emisoraVecina` return `null` and
/// both buttons would be dead.
List<String> uuidsCongeladosDestacadas({
required Emisora actual,
required List<Emisora> destacadas,
}) => [
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
...destacadas.map((e) => e.uuid),
];
-196
View File
@@ -1,196 +0,0 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
/// The last-played station's persistence key.
///
/// MUST stay byte-identical to `EstadoRadio._keyUltimaEmisora`
/// (`lib/estado/estado_radio.dart`), which is the only writer. It is
/// duplicated here rather than exported from there on purpose: this file has
/// to be readable from the headless Android Auto engine, where `EstadoRadio`
/// is never constructed, and importing a `ChangeNotifier` that pulls in the
/// whole app-state graph just to read one string constant would drag the
/// entire phone-side stack into a car bind. `emisoras_destacadas_test.dart`
/// pins the literal so a rename on either side fails loudly.
const claveUltimaEmisora = 'ultima_emisora_v1';
/// The stations a FREE-tier driver can browse and play in the car
/// (fix/auto-quality-guidelines, item 6).
///
/// Compiled into the binary, on purpose. Everything else the car could show
/// is empty on the bind a Play reviewer actually performs: a fresh install
/// is free tier (`esPremiumPersistido` is `getBool(...) ?? false`, no trial
/// key), `FuenteEmisorasAutoLocal.todas()` is literally
/// `_snapshotTodas ?? const []` until `EstadoRadio` pushes a network
/// snapshot that a headless bind never fetches, favourites and custom
/// stations are empty, and `ultima_emisora_v1` is absent. A curated const
/// list is the ONLY thing that can put real, playable rows in front of that
/// reviewer.
///
/// Deliberately small. This is not a catalogue — the catalogue is the
/// premium feature. Six rows is enough to prove the app works and short
/// enough to read at a glance from a driving position.
///
/// `favicon` is null for every entry on purpose: `artUriPara` then resolves
/// the on-brand bundled `station_art_*` drawable, so a browse row needs no
/// network at all to render its artwork.
///
/// `uuid`s are app-owned (`pw-destacada-*`), not Radio Browser uuids: these
/// rows must resolve identically whether or not the catalogue is reachable,
/// and a Radio Browser uuid we cannot re-fetch would be a promise this file
/// cannot keep.
const List<Emisora> emisorasDestacadas = [
Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
),
Emisora(
uuid: 'pw-destacada-france-inter',
nombre: 'France Inter',
url: 'https://icecast.radiofrance.fr/franceinter-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
),
Emisora(
uuid: 'pw-destacada-deutschlandfunk',
nombre: 'Deutschlandfunk',
url: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3',
pais: 'Germany',
codigoPais: 'DE',
idioma: 'german',
),
Emisora(
uuid: 'pw-destacada-kexp',
nombre: 'KEXP 90.3 FM',
url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
Emisora(
uuid: 'pw-destacada-radio-paradise',
nombre: 'Radio Paradise',
url: 'https://stream.radioparadise.com/mp3-128',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
Emisora(
uuid: 'pw-destacada-soma-groove-salad',
nombre: 'SomaFM Groove Salad',
url: 'https://ice1.somafm.com/groovesalad-128-mp3',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
];
/// The free tier's complete, ordered station set: the last station the user
/// actually played (when one is persisted) first, then [emisorasDestacadas],
/// deduplicated by `uuid`.
///
/// Last-played goes first because it is the single row a returning driver is
/// most likely to want, and because it is the only entry that can make the
/// free folder feel like *their* app rather than a demo. It is NOT appended
/// a second time when it already belongs to the curated set.
///
/// Follows `esPremiumPersistido({SharedPreferences? prefs})`'s
/// inject-or-`getInstance()` convention (`estado_entitlement.dart`), so a
/// test can pin prefs without a platform channel.
///
/// Never throws: a corrupt/foreign `ultima_emisora_v1` payload, or a
/// `SharedPreferences` failure, degrades to the curated set alone. This runs
/// inside `getChildren`, and a browse call that throws is a dead folder.
Future<List<Emisora>> resolverEmisorasDestacadas({
SharedPreferences? prefs,
}) async {
final ultima = await _ultimaEmisora(prefs: prefs);
if (ultima == null) return emisorasDestacadas;
return [
ultima,
...emisorasDestacadas.where((e) => e.uuid != ultima.uuid),
];
}
/// Whether [uuid] belongs to [destacadas] — the predicate every play-path
/// gate reads to tell "free content" from "the premium catalogue".
///
/// Pure, and takes the free universe rather than resolving it, so a caller
/// that already holds the list (every one of them does — it also needs it to
/// build the response) asks the question without a second prefs round trip.
///
/// A `null` or empty [uuid] is never free: `emisora:` with no tail is a
/// malformed id, and matching it against an entry with an empty uuid would be
/// a resolution hole rather than a feature.
bool esEmisoraGratuita(String? uuid, List<Emisora> destacadas) =>
uuid != null && uuid.isNotEmpty && destacadas.any((e) => e.uuid == uuid);
/// [esEmisoraGratuita] against the CURRENT free set, resolved here. For
/// callers that do not already hold the list.
Future<bool> esEmisoraGratuitaPorUuid(
String uuid, {
SharedPreferences? prefs,
}) async =>
esEmisoraGratuita(uuid, await resolverEmisorasDestacadas(prefs: prefs));
/// Reads the persisted last-played station, or `null` when there is none.
///
/// Public because the Android Auto "recent" browse root
/// (`AudioService.recentRootId`) needs exactly this one station and nothing
/// else: `onGetRoot` (`AudioService.java:817-821`) answers `recent` whenever
/// the head unit sends `EXTRA_RECENT`, which Android Auto does on every
/// reconnect, and the platform expects a SINGLE resume item there — not a
/// station list, and not an empty folder.
///
/// Tier-independent on purpose: this station is by definition one the user
/// has already played on this device, so offering to resume it is never
/// leaking premium content they have not already had.
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
_ultimaEmisora(prefs: prefs);
/// Writes [emisora] as the last-played station — the SINGLE writer of
/// [claveUltimaEmisora].
///
/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio`
/// because the key has to be written from the engine Android Auto starts,
/// which builds no widget tree and therefore never constructs `EstadoRadio`
/// at all: a session that happened only in the car used to leave the key
/// holding whatever the PHONE last played, so the head unit's resume row and
/// the free tier's featured folder were both stale on the next connect.
///
/// Deliberately NOT swallowing failures here: the handler port that calls it
/// traces and swallows (a persistence failure must never break playback),
/// and a silent `catch` in BOTH places would make a dead write channel
/// invisible from a car logcat.
Future<void> guardarUltimaEmisoraPersistida(
Emisora emisora, {
SharedPreferences? prefs,
}) async {
final resueltas = prefs ?? await SharedPreferences.getInstance();
await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap()));
}
/// Reads the persisted last-played station, or `null` when there is none,
/// the payload is unreadable, or prefs themselves fail.
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
final raw = resueltas.getString(claveUltimaEmisora);
if (raw == null) return null;
final emisora = Emisora.fromMap(jsonDecode(raw) as Map<String, dynamic>);
// A record with no uuid or no url cannot be turned into a playable
// `emisora:<uuid>` row, so it is worse than absent: it would occupy the
// first slot with a row that does nothing when tapped.
if (emisora.uuid.isEmpty || emisora.url.isEmpty) return null;
return emisora;
} catch (_) {
return null;
}
}
+104 -372
View File
@@ -10,8 +10,6 @@ import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'contexto_reproduccion.dart';
import 'emisoras_destacadas.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -202,97 +200,10 @@ abstract class FuenteEmisorasAuto {
}) {}
}
/// Every user-readable label of the Android Auto browse tree, already
/// resolved to one locale by the caller.
///
/// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can
/// read gets translated. This bundle replaces the previous
/// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key"
/// convention, which was defensible only while those labels sat deep inside
/// a premium tree and stopped being defensible the moment Google Play
/// reviewed the car surface on an English head unit.
///
/// It exists as a plain value object rather than an `AppLocalizations`
/// dependency so [ConstructorArbolAuto] stays a PURE builder — the same
/// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler,
/// which can resolve localizations headlessly through
/// `resolverLocalizacionesRespaldo`, builds one via
/// `etiquetasArbolAutoDesde` and hands it in.
///
/// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`,
/// …). Those are ranges of Latin letters, not prose — translating them would
/// make them lie about which filenames they contain.
class EtiquetasArbolAuto {
const EtiquetasArbolAuto({
required this.escuchar,
required this.favoritos,
required this.todasLasEmisoras,
required this.misEmisoras,
required this.musicaLocal,
required this.musicaLocalNoDisponible,
required this.cargarMas,
required this.ordenarPorCalidad,
required this.reproducirCarpeta,
required this.reproducirAleatorio,
required this.pistaSinNombre,
});
/// Fallback bundle for callers that have no localizations to hand: pure
/// builder tests, and any future non-car consumer.
///
/// It is NOT what the car shows. `ServicioAudio` always injects a bundle
/// resolved from `AppLocalizations`, in every browse and playback path
/// that can produce a label — `etiquetas_arbol_auto_test.dart` is the
/// guard that no NEW hardcoded label can be introduced alongside these.
static const respaldo = EtiquetasArbolAuto(
escuchar: 'Escuchar',
favoritos: 'Favoritos',
todasLasEmisoras: 'Todas las emisoras',
misEmisoras: 'Mis emisoras',
musicaLocal: 'Música Local',
musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música',
cargarMas: 'Más…',
ordenarPorCalidad: 'Ordenar por calidad',
reproducirCarpeta: 'Reproducir carpeta',
reproducirAleatorio: 'Reproducir aleatorio',
pistaSinNombre: 'Pista sin nombre',
);
/// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]).
final String escuchar;
/// Premium root folders.
final String favoritos;
final String todasLasEmisoras;
final String misEmisoras;
final String musicaLocal;
/// The non-playable row shown when the local-music folder cannot be read
/// from the car ([ConstructorArbolAuto.idLocalNoLista]).
final String musicaLocalNoDisponible;
/// Trailing "load more" row of every paged local-music view.
final String cargarMas;
/// Local-folder navigation and action rows.
final String ordenarPorCalidad;
final String reproducirCarpeta;
final String reproducirAleatorio;
/// Fallback title for a local file whose name is blank after stripping.
final String pistaSinNombre;
}
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
/// resolution. No platform dependency — fully testable without a running
/// car or a real `AudioHandler`.
class ConstructorArbolAuto {
const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo});
/// The already-localized labels this builder stamps onto every
/// user-readable `MediaItem` it produces.
final EtiquetasArbolAuto etiquetas;
/// Root folder ids (Design "media-id scheme"). The tree root itself is
/// identified by [AudioService.browsableRootId], not by a constant here —
/// the handler compares against it directly before calling [raiz].
@@ -300,17 +211,6 @@ class ConstructorArbolAuto {
static const idTodas = 'todas';
static const idMisEmisoras = 'mis_emisoras';
/// Root folder id for the FREE tier's only browsable folder
/// (fix/auto-quality-guidelines, item 8).
///
/// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and
/// [idEcualizador] it has its own dedicated children ([hijosDestacadas]),
/// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the
/// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is
/// empty on the bind a Play reviewer actually performs.
static const idDestacadas = 'destacadas';
/// Root folder id for the local-music browsable root (Design "media-id
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
@@ -422,10 +322,8 @@ class ConstructorArbolAuto {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
/// The root folders, all non-playable, and TIER-DEPENDENT: Favoritos,
/// Todas las emisoras, Mis emisoras and optionally Música Local for a
/// premium driver; the single [idDestacadas] folder for a free one (see
/// [premium] below).
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local, Ecualizador), all non-playable.
///
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// There is NO `Ecualizador` folder. The car's only equalizer control is
@@ -445,77 +343,53 @@ class ConstructorArbolAuto {
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
/// is configured") — the caller lo deriva de
/// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
/// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
/// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a
/// propósito, para que el tier gratuito ni siquiera pague ese round trip
/// nativo), keeping this builder itself synchronous and side-effect free.
/// nodo), keeping this builder itself synchronous and side-effect free.
///
/// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used
/// to be accepted and ignored, on the theory that "the root keeps the same
/// visible folder labels for free users" was friendlier than a reduced
/// menu. It was not: every one of those four folders dead-ended on a single
/// non-playable "Función Premium" row, and Google Play cited exactly that
/// against the Android for Cars App Quality Guidelines.
///
/// The free root is therefore ONE browsable folder, [idDestacadas], and the
/// premium-only folders are OMITTED rather than shown-and-blocked: a folder
/// a driver cannot use is worse than a folder that is not there.
///
/// It must stay at least one BROWSABLE item, never a bare playable one:
/// `audio_service` 0.18.18 discards `rootHints`
/// (`AudioService.java:817-826`), so this code cannot detect whether the
/// head unit accepts a `FLAG_PLAYABLE` root child, and the documented
/// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is
/// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on
/// such a unit.
///
/// Every label here comes from [etiquetas], already resolved to the head
/// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the
/// four premium folders.
///
/// The four premium ones used to be hardcoded Spanish, on the theory that
/// they were leaf rows deep inside a tree only a user who had already
/// chosen the app would reach. That was never a rule, only an untested
/// assumption, and it is retired: anything a user can read gets
/// translated. `escuchar` was localized first (it is 100% of what a free
/// Play reviewer sees), which is exactly why the rest had to follow.
///
/// [tituloDestacadas] stays as an explicit per-call override of
/// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle.
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
/// same visible folder labels for every tier — "keeps the same visible
/// folder labels for free users" is the explicit design choice, so a free
/// driver still sees a real, familiar menu rather than a wall of "Función
/// Premium" rows. The lock itself is enforced one level DOWN, at the
/// `getChildren` choke point (see [itemPremiumBloqueado] and
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these
/// folders as a free user reveals the lock there, never here.
List<MediaItem> raiz({
required bool incluirMusicaLocal,
required bool premium,
String? tituloDestacadas,
}) =>
premium
? [
_carpeta(idFavoritos, etiquetas.favoritos),
_carpeta(idTodas, etiquetas.todasLasEmisoras),
_carpeta(idMisEmisoras, etiquetas.misEmisoras),
if (incluirMusicaLocal)
_carpeta(idMusicaLocal, etiquetas.musicaLocal),
]
: [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)];
}) => [
_carpeta(idFavoritos, 'Favoritos'),
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
];
/// The free tier's playable station rows (fix/auto-quality-guidelines,
/// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium
/// folders use, capped like every other folder.
///
/// Separate from [hijos] because that path is gated on [_idsCarpetas] and
/// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold
/// headless bind — which is precisely the bind this folder has to survive.
/// An empty [emisoras] returns `[]` rather than any placeholder row: a
/// non-playable row in the car tree is the thing Play cited.
List<MediaItem> hijosDestacadas(List<Emisora> emisoras) =>
emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
/// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4):
/// the single non-playable item every non-root folder collapses to for a
/// free-tier user. Hardcoded Spanish label, matching every other car-tree
/// label in this file (never routed through `AppLocalizations` —
/// established convention, see [_tituloMasLocal]'s doc).
static const idPremiumInfo = 'premium:info';
/// El item de [idLocalNoLista]. Rotulado con
/// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
/// head unit. No reproducible — seleccionarlo es un no-op.
/// The single locked item shown for ANY non-root folder when the browsing
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a
/// crash (Spec "Free-tier user selects a locked item").
MediaItem itemPremiumBloqueado() => MediaItem(
id: idPremiumInfo,
title: 'Función Premium',
playable: false,
extras: _contentStyleLista,
);
/// El item de [idLocalNoLista]. Etiqueta en castellano hardcodeado, como
/// TODAS las etiquetas del árbol del coche en este archivo (ver
/// [itemPremiumBloqueado]): convención establecida, nunca
/// `AppLocalizations`. No reproducible — seleccionarlo es un no-op.
MediaItem itemLocalNoDisponible() => MediaItem(
id: idLocalNoLista,
title: etiquetas.musicaLocalNoDisponible,
title: 'Abre PluriWave en el móvil para leer tu música',
playable: false,
extras: _contentStyleLista,
);
@@ -614,15 +488,20 @@ class ConstructorArbolAuto {
return (documentId, pagina);
}
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
/// (Design ADR-5) — matches every other car-tree label in this file
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
static const _tituloMasLocal = 'Más…';
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
/// Rotulado con [EtiquetasArbolAuto.cargarMas].
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
MediaItem(
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
title: etiquetas.cargarMas,
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
@@ -747,41 +626,41 @@ class ConstructorArbolAuto {
/// for small folders.
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
/// The "sort by quality" mode-entry `MediaItem` (Design ADR-4):
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
/// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
/// Hardcoded Spanish label, matching every other car-tree label in this
/// file — never routed through `AppLocalizations` (established
/// car-tree-label precedent, see [_tituloMasLocal]).
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
etiquetas.ordenarPorCalidad,
'Ordenar por calidad',
);
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
/// round-trips via [bucketLocalDesde].
///
/// [etiqueta] is an alphabetical RANGE (e.g. `'A-F'`), and it is the one
/// user-visible car-tree string that deliberately does NOT go through
/// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames
/// actually start with, so translating it would make it lie.
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
/// alphabetical-range label (e.g. `'A-F'`), matching every other
/// car-tree label in this file — never routed through `AppLocalizations`.
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// The "play folder" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
/// [EtiquetasArbolAuto.reproducirCarpeta].
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
/// matching every other car-tree label in this file — never routed
/// through `AppLocalizations`.
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
title: etiquetas.reproducirCarpeta,
title: 'Reproducir carpeta',
playable: true,
extras: _contentStyleGrid,
);
/// The "shuffle play" playable action item (Design ADR-5),
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
/// mirrors [_itemReproducirCarpeta].
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
title: etiquetas.reproducirAleatorio,
title: 'Reproducir aleatorio',
playable: true,
extras: _contentStyleGrid,
);
@@ -863,7 +742,7 @@ class ConstructorArbolAuto {
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
title: etiquetas.cargarMas,
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
@@ -877,7 +756,7 @@ class ConstructorArbolAuto {
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
title: etiquetas.cargarMas,
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
@@ -957,7 +836,7 @@ class ConstructorArbolAuto {
final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre);
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty)
@@ -1074,56 +953,24 @@ class ConstructorArbolAuto {
}
}
/// Whether [parentMediaId] is content the FREE tier is allowed to browse
/// (fix/auto-quality-guidelines, item 10): the browsable root itself, the
/// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:<uuid>`
/// whose uuid belongs to [destacadas].
///
/// Everything else — the catalogue folders, favourites, custom stations,
/// local music, the equalizer folder, group folders, local tracks, and any
/// station uuid that is not in the free set — is premium content.
///
/// Pure and id-shaped, with the free universe INJECTED, so the whole matrix
/// is testable without prefs or a handler.
bool idPermitidoEnFree(
String parentMediaId, {
required List<Emisora> destacadas,
}) {
if (parentMediaId == AudioService.browsableRootId) return true;
if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true;
if (!parentMediaId.startsWith(_prefijoEmisora)) return false;
final uuid = parentMediaId.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return false;
return destacadas.any((e) => e.uuid == uuid);
}
/// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren`
/// choke point, called BEFORE any other resolution.
///
/// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to
/// content-scoping. It used to answer ANY non-root id, for a free-tier user,
/// with a single non-playable "Función Premium" row — which is what Google
/// Play cited on version code 157 ("clicking on stop button makes the entire
/// app useless" was the headline, but the browse tree it was reviewed
/// against was four folders that each dead-ended on that row). A
/// non-playable row reachable from a head unit's CACHED tree is a citation
/// waiting to happen, so there is no longer any code path that can produce
/// one: the blocked branch returns the free tier's own playable stations.
///
/// Returns `null` when the caller should proceed with its normal resolution
/// (premium, or free-tier content the free tier owns).
///
/// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the
/// caller resolves it once per browse. Passing an empty list is legal and
/// yields an empty blocked response — still never a dead row.
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any
/// other resolution. For the root itself this NEVER blocks (the root always
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays
/// visible for every tier). For any non-root [parentMediaId] and a free-tier
/// [premium], it returns the single locked item regardless of what the id
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from
/// before a downgrade is blocked exactly the same way as a legitimate
/// current folder id (android-auto-media spec "Free-Tier Browse Never
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the
/// caller should proceed with its normal resolution (root, or premium).
List<MediaItem>? respuestaBloqueadaPorEntitlement({
required String parentMediaId,
required bool premium,
required List<Emisora> destacadas,
}) {
if (parentMediaId == AudioService.browsableRootId) return null;
if (premium) return null;
if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null;
return ConstructorArbolAuto().hijosDestacadas(destacadas);
return [ConstructorArbolAuto().itemPremiumBloqueado()];
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -1135,21 +982,17 @@ List<MediaItem>? respuestaBloqueadaPorEntitlement({
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
/// never called and no exception propagates (Spec "Unknown or stale media
/// id").
///
/// RETURNS whether it actually dispatched (fix/auto-quality-guidelines,
/// item 12). The caller needs to tell "played" from "resolved to nothing"
/// so the second case can publish an explained error to the car instead of
/// leaving the driver with a tap that did nothing and said nothing.
Future<bool> reproducirPorMediaId(
Future<void> reproducirPorMediaId(
String id, {
required FuenteEmisorasAuto fuente,
required Future<void> Function(MediaItem) reproducir,
}) async {
final uuid = uuidDeMediaIdEmisora(id);
if (uuid == null) return false;
if (!id.startsWith(_prefijoEmisora)) return;
final uuid = id.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return;
final emisora = await fuente.porUuid(uuid);
if (emisora == null) return false;
if (emisora == null) return;
final item = MediaItem(
id: emisora.url,
@@ -1165,58 +1008,6 @@ Future<bool> reproducirPorMediaId(
extras: {'uuid': emisora.uuid},
);
await reproducir(item);
return true;
}
/// The uuid inside an `emisora:<uuid>` media id, or `null` for any other
/// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a
/// folder id) and an empty tail both answer `null`.
///
/// Extracted (fix/auto-quality-guidelines, item 11) because the play-path
/// entitlement gate has to ask the same question `reproducirPorMediaId` asks,
/// one step earlier: "is this a station id, and which station?".
String? uuidDeMediaIdEmisora(String id) {
if (!id.startsWith(_prefijoEmisora)) return null;
final uuid = id.substring(_prefijoEmisora.length);
return uuid.isEmpty ? null : uuid;
}
/// A [FuenteEmisorasAuto] over nothing but the free tier's station set
/// (fix/auto-quality-guidelines, item 12).
///
/// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the
/// window between the headless Android Auto engine starting and `main.dart`
/// registering the real source. A tap arriving in that window used to return
/// in silence; the free set is compiled into the binary, so it can always be
/// answered.
///
/// Reports the free stations through [todas] (they are, from the car's point
/// of view, everything there is) and nothing through the curated lists, which
/// a headless bind could not populate anyway.
class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto {
FuenteEmisorasAutoDestacadas(this._destacadas);
final List<Emisora> _destacadas;
@override
Future<List<Emisora>> favoritos() async => const [];
@override
Future<List<Emisora>> misEmisoras() async => const [];
@override
Future<List<Emisora>> todas() async => _destacadas;
@override
Future<List<GrupoFavoritos>> grupos() async => const [];
@override
Future<Emisora?> porUuid(String uuid) async {
for (final emisora in _destacadas) {
if (emisora.uuid == uuid) return emisora;
}
return null;
}
}
/// Which list previous/next should walk for [actual]: the NARROWEST context
@@ -1247,52 +1038,6 @@ List<Emisora> listaParaSaltoEmisora({
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
final contexto = contextoParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
);
if (contexto == null) return const [];
switch (contexto.tipo) {
case TipoContextoSalto.grupoFavoritos:
return favoritos
.where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId)
.toList();
case TipoContextoSalto.favoritos:
return favoritos;
case TipoContextoSalto.misEmisoras:
return misEmisoras;
case TipoContextoSalto.todas:
return todas;
case TipoContextoSalto.destacadas:
// Never produced by [contextoParaSaltoEmisora] — the free set is
// resolved by the handler, which owns the entitlement read.
return const [];
}
}
/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised
/// — so it can be remembered across a process restart.
///
/// The car kills and restarts the engine on every reconnect, and a list of
/// stations is not something that survives that: its members change while the
/// app is dead. The NAME of the list does survive, which is what
/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against
/// whatever the lists hold next time.
///
/// [listaParaSaltoEmisora] is implemented on top of this so the walked list
/// and the remembered context can never disagree (pinned by a test that runs
/// both over the same scenarios).
///
/// Returns `null` when [actual] belongs to none of the three lists — the
/// caller then has no context to remember and leaves playback alone.
ContextoSalto? contextoParaSaltoEmisora({
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
Emisora? enLista(List<Emisora> lista) {
for (final e in lista) {
@@ -1310,13 +1055,13 @@ ContextoSalto? contextoParaSaltoEmisora({
if (grupo != GrupoFavoritos.sinAsignarId) {
final delGrupo =
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
if (delGrupo.length > 1) return ContextoSalto.grupo(grupo);
if (delGrupo.length > 1) return delGrupo;
}
return const ContextoSalto.favoritos();
return favoritos;
}
if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras();
if (enLista(todas) != null) return const ContextoSalto.todas();
return null;
if (enLista(misEmisoras) != null) return misEmisoras;
if (enLista(todas) != null) return todas;
return const [];
}
/// The station before or after [actual] in [lista], wrapping around at both
@@ -1443,22 +1188,25 @@ Future<void> seleccionarPresetEqPorMediaId(
if (!activo) await activarEcualizador(true);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
/// `'Música Local'`, etc.), none of which go through `AppLocalizations`.
const _tituloLocalFallback = 'Pista sin nombre';
/// Filename → display title (Design "Title = filename minus extension"):
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
/// dot, or the dot is the first character — e.g. a hidden file like
/// `.mp3`), falling back to [sinNombre] when the result would be blank.
///
/// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than
/// hardcoded: it is a title the driver reads, so it is translated like every
/// other car-tree label.
String _tituloDesdeNombre(String nombre, String sinNombre) {
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
/// blank.
String _tituloDesdeNombre(String nombre) {
final recortado = nombre.trim();
if (recortado.isEmpty) return sinNombre;
if (recortado.isEmpty) return _tituloLocalFallback;
final ultimoPunto = recortado.lastIndexOf('.');
final sinExtension =
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
final resultado = sinExtension.trim();
return resultado.isEmpty ? sinNombre : resultado;
return resultado.isEmpty ? _tituloLocalFallback : resultado;
}
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
@@ -1731,13 +1479,12 @@ Future<void> reproducirCarpetaLocal(
Future<MediaItem?> construirMediaItemColaLocal(
NodoLocal nodo, {
required FuenteMusicaLocalAuto fuente,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
if (contentUri == null || contentUri.isEmpty) return null;
return MediaItem(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave',
// Item 3: a queued local track had NO artUri at all before — reuses
// [artUriLocal] (the SAME on-brand rotation the browse tree's
@@ -1801,9 +1548,8 @@ Future<Map<String, MetadatosPista>> _metadatosDeConCache(
Future<List<MediaItem>?> hijosMusicaLocal(
String parentMediaId, {
required FuenteMusicaLocalAuto? fuente,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
final constructor = ConstructorArbolAuto();
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
// routing order is irrelevant to correctness (every prefix in this file
@@ -1894,11 +1640,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
/// Now Playing title consistent with what the user tapped without requiring
/// a second native round trip.
String _tituloDesdeDocumentId(String documentId, String sinNombre) {
String _tituloDesdeDocumentId(String documentId) {
final ultimaBarra = documentId.lastIndexOf('/');
final segmento =
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
return _tituloDesdeNombre(segmento, sinNombre);
return _tituloDesdeNombre(segmento);
}
/// Routing seam between a car-tapped `pista:<docId>` media id and the
@@ -1917,7 +1663,6 @@ Future<void> reproducirPistaLocal(
String id, {
required FuenteMusicaLocalAuto fuente,
required Future<void> Function(MediaItem) reproducir,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
if (!esPistaMediaId(id)) return;
final documentId = id.substring(_prefijoPista.length);
@@ -1928,7 +1673,7 @@ Future<void> reproducirPistaLocal(
final pista = PistaLocal(
documentId: documentId,
titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre),
titulo: _tituloDesdeDocumentId(documentId),
contentUri: contentUri,
);
@@ -2023,16 +1768,6 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
return _snapshotTodas ?? const [];
}
/// Resolves a station uuid across every list this source can reach.
///
/// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST
/// (fix/auto-quality-guidelines, item 7). It has to be searched at all
/// because on a cold headless bind the three lists above are all empty —
/// `todas()` is `_snapshotTodas ?? const []`, favourites and custom
/// stations have nothing persisted on a fresh install — so a curated
/// `emisora:<uuid>` resolved to `null` and tapping the row did NOTHING.
/// It is searched last so a live catalogue/favourite record for the same
/// uuid (richer metadata, the user's own group assignment) still wins.
@override
Future<Emisora?> porUuid(String uuid) async {
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
@@ -2041,9 +1776,6 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
if (emisora.uuid == uuid) return emisora;
}
}
for (final emisora in await resolverEmisorasDestacadas()) {
if (emisora.uuid == uuid) return emisora;
}
return null;
}
File diff suppressed because it is too large Load Diff
+2 -96
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
@@ -39,25 +38,6 @@ class EventoCompra {
final String? mensaje;
}
/// Outcome of the SILENT ownership query ([PuertoCompras.consultarPropiedad])
/// used to re-verify the persisted premium flag (refund revocation).
///
/// Only [poseida] and [noPoseida] are definitive answers from Play; anything
/// that is not a clean answer (offline, billing unavailable, query error,
/// exception, timeout, pending purchase) is [desconocido], which the
/// verification policy treats as "change nothing" (fail-open, ADR-2).
enum ResultadoVerificacionLicencia {
/// Play reports the premium product as purchased on this account.
poseida,
/// Play answered successfully and the premium product is NOT among the
/// account's purchases (e.g. refunded or revoked).
noPoseida,
/// No trustworthy answer — never used to revoke.
desconocido,
}
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
/// this port, never on `in_app_purchase` directly — matches
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
@@ -75,24 +55,13 @@ abstract class PuertoCompras {
/// Re-queries Play Billing for a prior purchase on this account.
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
/// depends on [PuertoCompras] instead.
class ServicioComprasPlayBilling implements PuertoCompras {
ServicioComprasPlayBilling({
InAppPurchase? inAppPurchase,
Future<QueryPurchaseDetailsResponse> Function()? consultarComprasPasadas,
Duration limiteConsultaPropiedad = const Duration(seconds: 10),
}) : _iap = inAppPurchase ?? InAppPurchase.instance,
_consultarComprasPasadasInyectada = consultarComprasPasadas,
_limiteConsultaPropiedad = limiteConsultaPropiedad {
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
: _iap = inAppPurchase ?? InAppPurchase.instance {
_sub = _iap.purchaseStream.listen(
_alRecibirCompras,
onError: (Object error) {
@@ -108,15 +77,6 @@ class ServicioComprasPlayBilling implements PuertoCompras {
static const idProducto = 'pluriwave_premium';
final InAppPurchase _iap;
/// Test seam for [consultarPropiedad]; `null` in production, where the
/// Android platform addition's `queryPastPurchases` is used.
final Future<QueryPurchaseDetailsResponse> Function()?
_consultarComprasPasadasInyectada;
/// Upper bound for [consultarPropiedad]: a hung BillingClient connection
/// resolves to [ResultadoVerificacionLicencia.desconocido].
final Duration _limiteConsultaPropiedad;
final _eventos = StreamController<EventoCompra>.broadcast();
StreamSubscription<List<PurchaseDetails>>? _sub;
@@ -165,33 +125,6 @@ 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) {
if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
@@ -243,33 +176,6 @@ 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> {
T? get firstOrNull => isEmpty ? null : first;
}
-21
View File
@@ -256,27 +256,6 @@ class ServicioEcualizador {
return prefs.getBool(_keyActivo);
}
/// The persisted principal preset, or `null` when the user has never saved
/// one.
///
/// The exact sibling of [leerActivo] and narrow for the same reason: its
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
/// engine — including the headless one Android Auto starts, where there is
/// no widget tree and `EstadoEcualizador` never exists to push a preset
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
/// and mutates nothing.
///
/// `null` (nothing saved, or an unreadable value) is preserved rather than
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
/// not this service — decides what "never persisted" means, and so a seed
/// with nothing to say does not overwrite anything.
Future<PresetEcualizador?> leerPresetPrincipal() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) return null;
return _leerPresetPrincipal(prefs);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
-37
View File
@@ -213,43 +213,6 @@ class ServicioFavoritos {
);
}
/// Restaura un favorito tal como estaba en el dispositivo de origen,
/// preservando su `orden` y su `grupo_id`.
/// Usado exclusivamente por importarConfig, igual que [restaurarGrupo].
///
/// Existe porque [agregar] NO sirve como primitiva de restauración: es la
/// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un
/// `orden` al final de la lista, cosa correcta para una emisora recién
/// marcada (que de verdad no pertenece a ningún grupo) y destructiva para
/// una copia de seguridad, que trae ambos campos. Reusarla era la causa de
/// que los grupos volvieran vacíos tras restaurar.
///
/// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no
/// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo
/// que una copia editada a mano o restaurada a medias no puede dejar
/// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los
/// grupos ANTES de este bucle, así que en el camino normal siempre existen.
Future<void> restaurarFavorito(Emisora emisora) async {
final db = await _database;
final existe =
Sqflite.firstIntValue(
await db.rawQuery(
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
[emisora.grupoFavoritosId],
),
) ??
0;
final restaurada =
existe > 0
? emisora
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId);
await db.insert(
'favoritos',
restaurada.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> eliminarGrupo(String id) async {
if (id == GrupoFavoritos.sinAsignarId) return;
final db = await _database;
-218
View File
@@ -1,218 +0,0 @@
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;
}
+132 -146
View File
@@ -40,164 +40,150 @@ class HojaPremium extends StatelessWidget {
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(24),
// The sheet's height is data-dependent: five benefit bullets whose
// wrapped line count varies per locale, plus an optional
// purchase/restore result banner. Together they already overflow a
// short viewport by a couple of pixels, and a translation one word
// longer would make it worse. Scrolling is the only shape that
// cannot overflow, and it costs nothing when everything fits.
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.workspace_premium_rounded,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.premiumHojaTitulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.workspace_premium_rounded,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.premiumHojaTitulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
// Explicit, obvious dismiss affordance (fix/import-alarmas-y-
// paywall): a purchase sheet the user cannot easily escape is
// a dark pattern and a Play policy risk. Reachable without
// buying or restoring, same weight as any other icon button.
IconButton(
key: const ValueKey('hoja-premium-cerrar'),
icon: const Icon(Icons.close_rounded),
tooltip: l10n.closeAction,
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
const SizedBox(height: 12),
// Concrete, honest value list — accuracy is non-negotiable here:
// these five are the ONLY things premium unlocks. The phone
// equalizer stays free for everyone and must NEVER appear here;
// only its Android Auto surface is affected, as a consequence of
// Auto itself being gated.
//
// The Android Auto line describes what PRO adds in the CAR — the
// full catalogue, favourites, my stations, local music — because
// the free tier already gets a real, playable featured folder
// there. A bare "Android Auto" bullet sold something free users
// already have.
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
const SizedBox(height: 12),
Text(
l10n.premiumPagoUnico,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
),
// Explicit, obvious dismiss affordance (fix/import-alarmas-y-
// paywall): a purchase sheet the user cannot easily escape is
// a dark pattern and a Play policy risk. Reachable without
// buying or restoring, same weight as any other icon button.
IconButton(
key: const ValueKey('hoja-premium-cerrar'),
icon: const Icon(Icons.close_rounded),
tooltip: l10n.closeAction,
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
const SizedBox(height: 12),
// Concrete, honest value list — accuracy is non-negotiable here:
// these five are the ONLY things premium unlocks. The phone
// equalizer stays free for everyone and must NEVER appear here;
// only its Android Auto surface is affected, as a consequence of
// Auto itself being gated.
_BeneficioPremium(texto: l10n.premiumBeneficioSinAnuncios),
_BeneficioPremium(texto: l10n.premiumBeneficioAndroidAuto),
_BeneficioPremium(texto: l10n.premiumBeneficioGrabacion),
_BeneficioPremium(texto: l10n.premiumBeneficioVacaciones),
_BeneficioPremium(texto: l10n.premiumBeneficioAlarmasIlimitadas),
const SizedBox(height: 12),
Text(
l10n.premiumPagoUnico,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
),
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
),
IconButton(
key: const ValueKey('hoja-premium-resultado-descartar'),
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => entitlement.consumirResultadoUsuario(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
),
IconButton(
key: const ValueKey('hoja-premium-resultado-descartar'),
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => entitlement.consumirResultadoUsuario(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
if (entitlement.esPremium)
Padding(
key: const ValueKey('hoja-premium-activo'),
padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.premiumActivo,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
FilledButton.icon(
key: const ValueKey('hoja-premium-comprar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.comprar(),
icon:
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
),
if (entitlement.esPremium)
Padding(
key: const ValueKey('hoja-premium-activo'),
padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.premiumActivo,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 10),
OutlinedButton(
key: const ValueKey('hoja-premium-restaurar'),
)
else
FilledButton.icon(
key: const ValueKey('hoja-premium-comprar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
: () => entitlement.comprar(),
icon:
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
),
const SizedBox(height: 10),
OutlinedButton(
key: const ValueKey('hoja-premium-restaurar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
),
if (!entitlement.esPremium) ...[
const SizedBox(height: 4),
// Clearly-labelled, always-reachable decline — same weight as
// any other secondary action, never made harder to find than
// buying (hard constraint: no dark patterns, no guilt-shaming
// decline copy).
TextButton(
key: const ValueKey('hoja-premium-ahora-no'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.premiumAhoraNo),
),
],
],
),
],
),
),
);
+1 -35
View File
@@ -32,17 +32,6 @@ class VisualizadorAudio extends StatefulWidget {
/// player: `.45`, t4 line 121), so this is a parameter, not a constant.
final double gradienteFinAlpha;
/// Whether the user has opted in to reading the REAL audio level.
///
/// Subscribing to the native `pluriwave/audio_visualizer` EventChannel is
/// what makes `MainActivity.startVisualizerWhenAllowed` request
/// `RECORD_AUDIO`, so this flag is the Dart-side gate on a sensitive
/// permission, not a cosmetic preference. Defaults to `false`: without the
/// opt-in the widget never touches the channel and animates the synthetic
/// wave instead, which is exactly what it already did whenever the
/// permission was denied.
final bool capturaRealHabilitada;
const VisualizadorAudio({
super.key,
required this.estadoStream,
@@ -53,7 +42,6 @@ class VisualizadorAudio extends StatefulWidget {
this.anchuraTotal = double.infinity,
this.barrasDiscretas = false,
this.gradienteFinAlpha = 0.3,
this.capturaRealHabilitada = false,
});
@override
@@ -113,31 +101,9 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
_sincronizarOndaReal();
}
@override
void didUpdateWidget(VisualizadorAudio oldWidget) {
super.didUpdateWidget(oldWidget);
// The opt-in can be revoked while this widget is mounted; dropping the
// subscription is what makes the native side release the Visualizer.
if (oldWidget.capturaRealHabilitada != widget.capturaRealHabilitada) {
if (!widget.capturaRealHabilitada) {
unawaited(_ondaSubscription?.cancel());
_ondaSubscription = null;
_ultimaOndaReal = null;
}
_sincronizarOndaReal();
}
}
void _sincronizarOndaReal() {
final sessionId = _sessionId;
// `capturaRealHabilitada` first: subscribing to the EventChannel is what
// triggers the native RECORD_AUDIO request, so the opt-in gates the
// subscription itself, never just the rendering of its result.
final puedeCapturar =
widget.capturaRealHabilitada &&
sessionId != null &&
sessionId > 0 &&
_activo;
final puedeCapturar = sessionId != null && sessionId > 0 && _activo;
if (!puedeCapturar) {
unawaited(_ondaSubscription?.cancel());
+1 -1
View File
@@ -366,7 +366,7 @@ packages:
source: hosted
version: "3.3.0"
in_app_purchase_android:
dependency: "direct main"
dependency: transitive
description:
name: in_app_purchase_android
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
+1 -5
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.3.3+164
version: 1.3.1+157
environment:
sdk: ^3.7.0
@@ -55,10 +55,6 @@ dependencies:
# In-app purchase
in_app_purchase: ^3.2.0
# Direct dependency only for `InAppPurchaseAndroidPlatformAddition
# .queryPastPurchases` (silent license re-verification in
# `ServicioComprasPlayBilling.consultarPropiedad`).
in_app_purchase_android: ^0.5.0
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
@@ -1,43 +0,0 @@
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);
});
});
}
@@ -4,8 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/main.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'helpers/handlers_audio.dart';
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
///
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
@@ -30,8 +28,6 @@ import 'helpers/handlers_audio.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
test('resumed + coche ya suscrito + latch libre invalida', () {
expect(
@@ -113,7 +109,7 @@ void main() {
testWidgets('arranque headless: hay View desde el primer frame, pero sin '
'Activity ni coche suscrito el latch NO se gasta y sigue disponible '
'para cuando el coche por fin navegue', (tester) async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
var invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
@@ -148,7 +144,7 @@ void main() {
testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) '
'empuja de verdad por el stream de hijos de la raíz', (tester) async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
// El coche navegó la raíz durante el arranque headless: el sujeto
@@ -194,7 +190,7 @@ void main() {
group('hayCocheSuscritoAlArbol', () {
test('es false sin handler suscrito y true en cuanto el coche navega un '
'id', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
expect(hayCocheSuscritoAlArbol(), isFalse);
-53
View File
@@ -1944,59 +1944,6 @@ void main() {
},
);
});
// ---------------------------------------------------------------------------
// The handler REJECTED the toggle (native setEnabled threw): the handler
// rolls its own flag back, so this class must not keep — nor persist — a
// value the engine refused.
// ---------------------------------------------------------------------------
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
test(
'adopta el valor real del handler y NO persiste el valor rechazado',
() async {
final fakeAudio = _FakeAudioEqRechazaConmutacion();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
fakeServicio.guardarActivoLlamadas = 0;
var avisos = 0;
eq.addListener(() => avisos++);
await eq.cambiarActivo(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The native call failed, so the handler kept the equalizer ON.
expect(fakeAudio.ecualizadorActivo, isTrue);
expect(
eq.activo,
isTrue,
reason: 'the UI must show what the engine really does',
);
expect(avisos, greaterThanOrEqualTo(1));
expect(
fakeServicio.guardarActivoLlamadas,
equals(0),
reason: 'a rejected value must never reach disk',
);
expect(fakeServicio.config.activo, isTrue);
eq.dispose();
},
);
});
}
/// Fake handler that REFUSES every on/off change: it records the call (the
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
/// rolling its flag back when the native `setEnabled` throws.
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
}
}
/// Fake whose [guardarActivo] stays pending until released, and releases the
-159
View File
@@ -2,10 +2,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'
show registrarInvalidacionArbolAuto;
import 'package:pluriwave/servicios/servicio_compras.dart';
import 'package:pluriwave/servicios/verificacion_licencia.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Fake [PuertoCompras] (Design ADR-2): never touches `in_app_purchase`,
@@ -14,12 +11,6 @@ class _PuertoComprasFalso implements PuertoCompras {
final _eventos = StreamController<EventoCompra>.broadcast();
int comprasIntentadas = 0;
int restaurosIntentados = 0;
int consultasPropiedad = 0;
/// What the silent ownership query answers. Defaults to [desconocido] so
/// every pre-existing test keeps its old behavior (fail-open: no change).
ResultadoVerificacionLicencia propiedad =
ResultadoVerificacionLicencia.desconocido;
@override
Stream<EventoCompra> get eventos => _eventos.stream;
@@ -34,32 +25,11 @@ class _PuertoComprasFalso implements PuertoCompras {
restaurosIntentados++;
}
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async {
consultasPropiedad++;
return propiedad;
}
void emitir(EventoCompra evento) => _eventos.add(evento);
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() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -357,135 +327,6 @@ 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)', () {
test('lee la flag persistida directamente desde prefs', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
@@ -1,177 +0,0 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Copia de seguridad — ROUND TRIP de los grupos de favoritos y de la
/// asignación emisora -> grupo.
///
/// Reportado desde el uso real: al restaurar una copia en otro dispositivo
/// los grupos volvían VACÍOS y todas las emisoras aparecían en «Sin
/// asignar». El sobre exportado siempre llevó ambas cosas (`gruposFavoritos`
/// desde v2, y `grupo_id` dentro de cada entrada de `favoritos`, porque es
/// una clave intrínseca de `Emisora.toMap()`); lo que fallaba era la
/// APLICACIÓN del estado: `importarConfig` reusaba `ServicioFavoritos.agregar`,
/// la primitiva de «marcar como favorita», que fuerza `sin_asignar` y un
/// `orden` nuevo a propósito.
///
/// Por eso estos tests prueban el VIAJE COMPLETO (origen -> exportar ->
/// destino limpio -> importar), no la forma del sobre: la forma ya estaba
/// bien y aun así el usuario perdía sus grupos.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_export_grupos_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
var contadorArchivos = 0;
Future<EstadoRadio> crearRadio() async {
final prefs = await SharedPreferences.getInstance();
// Un archivo POR instancia: `importarConfig` escribe siempre en el que
// resuelva `resolverArchivoCustom`, y origen y destino no pueden
// compartirlo.
final archivoCustom = File(
'${tempDir.path}/emisoras_custom_${contadorArchivos++}.json',
);
if (!archivoCustom.existsSync()) {
await archivoCustom.writeAsString('[]');
}
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: () async => archivoCustom,
prefs: prefs,
iniciarAutomaticamente: false,
);
await radio.ecualizador.cargarPersistido();
return radio;
}
Emisora emisora(String uuid, String nombre) => Emisora(
uuid: uuid,
nombre: nombre,
url: 'https://example.com/$uuid.mp3',
);
group('EstadoRadio export/import — grupos de favoritos', () {
test('round trip: los grupos y la asignación de CADA emisora sobreviven '
'al viaje origen -> copia -> destino limpio', () async {
final origen = await crearRadio();
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
await origen.toggleFavorito(emisora('rock-2', 'Rock Dos'));
await origen.toggleFavorito(emisora('jazz-1', 'Jazz Uno'));
await origen.toggleFavorito(emisora('suelta', 'Sin grupo'));
await origen.crearGrupoFavoritos('Rock');
await origen.crearGrupoFavoritos('Jazz');
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
final jazz = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Jazz');
await origen.asignarGrupoFavorito('rock-1', rock.id);
await origen.asignarGrupoFavorito('rock-2', rock.id);
await origen.asignarGrupoFavorito('jazz-1', jazz.id);
final copia = await origen.exportarConfig();
final destino = await crearRadio();
await destino.importarConfig(copia);
// Los grupos vuelven, con su nombre y su orden.
final gruposDestino = destino.gruposFavoritos;
expect(
gruposDestino.map((g) => g.id),
containsAll(<String>[rock.id, jazz.id]),
);
expect(gruposDestino.firstWhere((g) => g.id == rock.id).nombre, 'Rock');
expect(gruposDestino.firstWhere((g) => g.id == jazz.id).nombre, 'Jazz');
// Y la asignación de CADA emisora vuelve con ellos.
String grupoDe(String uuid) =>
destino.listaFavoritos.firstWhere((e) => e.uuid == uuid)
.grupoFavoritosId;
expect(grupoDe('rock-1'), rock.id);
expect(grupoDe('rock-2'), rock.id);
expect(grupoDe('jazz-1'), jazz.id);
expect(grupoDe('suelta'), GrupoFavoritos.sinAsignarId);
});
test('una copia ANTIGUA sin `gruposFavoritos` importa limpiamente y deja '
'intactos los grupos que ya existen en el dispositivo', () async {
final destino = await crearRadio();
await destino.crearGrupoFavoritos('Mío');
final propio = destino.gruposFavoritos.firstWhere(
(g) => g.nombre == 'Mío',
);
await destino.toggleFavorito(emisora('local-1', 'Local Uno'));
await destino.asignarGrupoFavorito('local-1', propio.id);
// v1: ni `gruposFavoritos` ni `alarmas` ni preferencias. La regla del
// sobre es que un campo AUSENTE no toca ese estado.
await destino.importarConfig(<String, dynamic>{
'version': 1,
'favoritos': <Map<String, dynamic>>[],
'emisorasCustom': <Map<String, dynamic>>[],
'presetsEcualizador': <String, dynamic>{},
});
expect(destino.gruposFavoritos.any((g) => g.id == propio.id), isTrue);
expect(
destino.gruposFavoritos.firstWhere((g) => g.id == propio.id).nombre,
'Mío',
);
expect(
destino.listaFavoritos.firstWhere((e) => e.uuid == 'local-1')
.grupoFavoritosId,
propio.id,
);
});
test('los grupos importados quedan visibles SIN reiniciar: importarConfig '
'recarga la lista en memoria y notifica', () async {
final origen = await crearRadio();
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
await origen.crearGrupoFavoritos('Rock');
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
await origen.asignarGrupoFavorito('rock-1', rock.id);
final copia = await origen.exportarConfig();
final destino = await crearRadio();
var notificaciones = 0;
destino.addListener(() => notificaciones++);
await destino.importarConfig(copia);
expect(notificaciones, greaterThan(0));
expect(destino.gruposFavoritos.any((g) => g.id == rock.id), isTrue);
expect(
destino.listaFavoritos.single.grupoFavoritosId,
rock.id,
reason:
'la vista de favoritos agrupa por `grupoFavoritosId`: si la lista '
'en memoria no se recarga tras restaurar los grupos, la pantalla '
'sigue mostrando todo en «Sin asignar» hasta reiniciar',
);
});
});
}
+29 -39
View File
@@ -7,7 +7,6 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -793,16 +792,21 @@ void main() {
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
// The record is now written by the audio handler's `_cambiarFuente`
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
// `ultima_emisora_v1` and the only one that also exists on the headless
// Android Auto engine — `EstadoRadio` used to write it too and no
// longer does. Seeded through that same production function here, so
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
// a real payload instead of one a fake invented. The write itself is
// covered end to end in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
await guardarUltimaEmisoraPersistida(emisora);
final estadoUno = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
@@ -844,18 +848,9 @@ void main() {
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'deja de estar ensombrecida por la seleccion previa del telefono',
() async {
// The PERSISTENCE half of this scenario moved to the handler, which is
// the only writer that exists on a car-only session — it is covered by
// «playFromMediaId desde el coche persiste ESA emisora» in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
// `EstadoRadio` still owns here, and what this test now pins, is the
// shadowing fix: a car selection bypasses `reproducir()`, so without
// the `estadoStream` listener `_emisoraSeleccionada` would keep
// shadowing the car's station on the `emisoraActual` getter.
'también se recuerda para la próxima instancia', () async {
final audio = _AudioControlado();
final estado = EstadoRadio(
final estadoUno = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
@@ -864,16 +859,7 @@ void main() {
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final desdeElTelefono = emisoraDemo(
uuid: 'phone-picked',
nombre: 'Elegida en el telefono',
);
unawaited(estado.reproducir(desdeElTelefono));
audio.completar(desdeElTelefono.uuid);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
await estadoUno.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
@@ -881,14 +867,18 @@ void main() {
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(
estado.emisoraActual?.uuid,
desdeCoche.uuid,
reason:
'the car changed the station without going through reproducir(); '
'the phone UI must follow it instead of keeping the previous '
'selection on screen',
final estadoDos = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
}
+1 -39
View File
@@ -171,45 +171,7 @@ class FakeServicioFavoritos extends ServicioFavoritos {
@override
Future<void> agregar(Emisora emisora) async {
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
// FIEL a producción (`ServicioFavoritos.agregar`): esta es la primitiva de
// «marcar como favorita», y fuerza `sin_asignar` además de un `orden`
// nuevo. El doble NO lo hacía, así que cualquier test de import escrito
// contra él salía verde mientras el dispositivo real perdía la asignación
// de grupo. Para RESTAURAR una copia existe `restaurarFavorito`.
_favoritos.add(
emisora.copyWith(
orden: _favoritos.length,
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
),
);
}
@override
Future<void> restaurarFavorito(Emisora emisora) async {
// Fiel a producción: preserva `orden` y `grupo_id`, cayendo a
// `sin_asignar` cuando el grupo de la copia no existe.
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
final existe = _grupos.any((g) => g.id == emisora.grupoFavoritosId);
_favoritos.add(
existe
? emisora
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId),
);
}
@override
Future<void> restaurarGrupo(GrupoFavoritos grupo) async {
// Sin este override la llamada caía en la implementación REAL de sqflite
// y explotaba con «databaseFactory not initialized»; solo pasaba
// desapercibido porque todos los tests de import existentes mandaban
// `gruposFavoritos: []`.
if (grupo.esSinAsignar) return;
final index = _grupos.indexWhere((g) => g.id == grupo.id);
if (index == -1) {
_grupos.add(grupo);
} else {
_grupos[index] = grupo;
}
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
}
@override
-46
View File
@@ -1,46 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Test-isolation seam for [PluriWaveAudioHandler].
///
/// A handler nobody releases keeps running after the test that built it: its
/// terminal-state floor timer, its `ControladorReconexion` backoff (1/2/4/8/16
/// s, longer than most of the tests that arm it) and anything still queued on
/// its source-change chain. When one of those finally performs a source change
/// it calls `_crearPlayer()`, which reads the CURRENT
/// [PluriWaveAudioHandler.fabricaReproductorPrueba] — so a dead handler builds
/// a double bound to a LATER test's script and drives it, incrementing that
/// test's counters for work it never asked for.
///
/// That is why `servicio_audio_transporte_test.dart` behaved differently run
/// alone and run inside the whole suite. A suite that passes under those
/// conditions passes by luck, and luck runs out on a broken build exactly when
/// it matters.
///
/// Usage — call ONCE at the top of `main()` and build every handler through
/// the returned function:
///
/// ```dart
/// final crearHandler = registrarHandlersLiberables();
/// ...
/// final handler = crearHandler();
/// ```
///
/// The `tearDown` it registers covers every group in the file.
PluriWaveAudioHandler Function() registrarHandlersLiberables() {
final creados = <PluriWaveAudioHandler>[];
tearDown(() async {
// Released in reverse creation order so a handler built on top of an
// earlier one is torn down first. `liberar` is idempotent, so a test that
// already released its own handler is fine.
for (final handler in creados.reversed) {
await handler.liberar();
}
creados.clear();
});
return () {
final handler = PluriWaveAudioHandler();
creados.add(handler);
return handler;
};
}
Binary file not shown.
+14 -9
View File
@@ -46,9 +46,20 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
('pt', 'welcomeBullet2Title'),
('ru', 'welcomeBullet2Title'),
('zh', 'welcomeBullet2Title'),
// `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.
(
'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'),
// ---------------------------------------------------------------------
// Pure symbols / placeholders -- no translatable text at all.
@@ -289,10 +300,4 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
'restaurarCompras',
), // iap-freemium-unlock new key -- "Restaurar compras" is the standard
// Portuguese store wording and coincides with es word for word.
(
'pt',
'autoCarpetaFavoritos',
), // fix/auto-quality-guidelines car-tree label -- "Favoritos" is the same
// word in pt and es, exactly like the already-listed ('pt',
// 'favoritesTitle') above, which carries this very value.
};
-54
View File
@@ -1,54 +0,0 @@
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'));
});
}
@@ -1,135 +0,0 @@
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,7 +8,6 @@ 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';
@@ -109,9 +108,6 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: MaterialApp(
locale: const Locale('en'),
+1 -9
View File
@@ -8,7 +8,6 @@ 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';
@@ -55,9 +54,6 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: MaterialApp(
locale: const Locale('en'),
@@ -104,7 +100,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 4 nav rows, no inline controls', (
testWidgets('AUDIO group renders exactly 3 nav rows, no inline controls', (
tester,
) async {
setLargeSurface(tester);
@@ -118,10 +114,6 @@ 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
+21 -139
View File
@@ -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/Open-in-another-app/Delete.
/// constrained to exactly Rename/Share/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, `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).
/// 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).
///
/// 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<bool> Function(String ruta)? abrirEnOtraApp,
Future<void> Function(String ruta)? compartir,
}) {
return ListenableProvider<EstadoGrabacion>.value(
value: estado,
@@ -82,7 +82,7 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaGrabaciones(
reproductor: reproductor,
abrirEnOtraApp: abrirEnOtraApp ?? (_) async => true,
compartir: compartir ?? (_) async {},
),
),
);
@@ -117,66 +117,6 @@ 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 '
@@ -439,12 +379,9 @@ void main() {
expect(find.byIcon(Icons.pause_circle_filled_rounded), findsNothing);
});
// 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 {
testWidgets('15.3: the "⋮" menu exposes exactly Rename, Share, Delete', (
tester,
) async {
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
@@ -468,10 +405,7 @@ void main() {
find.widgetWithText(PopupMenuItem<String>, 'Rename'),
findsOneWidget,
);
expect(
find.widgetWithText(PopupMenuItem<String>, 'Open in another app'),
findsOneWidget,
);
expect(find.widgetWithText(PopupMenuItem<String>, 'Share'), findsOneWidget);
expect(
find.widgetWithText(PopupMenuItem<String>, 'Delete'),
findsOneWidget,
@@ -621,15 +555,10 @@ void main() {
skip: true,
);
/// 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>[];
testWidgets(
'15.4-C: Share invokes the injected share callback with the file path',
(tester) async {
final compartidos = <String>[];
final estado = EstadoGrabacion(
esPremium: () => true,
servicio: _FakeServicioGrabacionConArchivos([
@@ -642,68 +571,21 @@ void main() {
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
abrirEnOtraApp: (ruta) async {
abiertos.add(ruta);
return true;
compartir: (ruta) async {
compartidos.add(ruta);
},
),
);
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 tester.tap(find.widgetWithText(PopupMenuItem<String>, 'Share'));
await pumpStable(tester);
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,
);
},
);
});
expect(compartidos, [fijaA.ruta]);
},
);
}
// ── Infrastructure ──────────────────────────────────────────────────────────
-4
View File
@@ -7,7 +7,6 @@ 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';
@@ -805,9 +804,6 @@ 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,7 +5,6 @@ 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';
@@ -107,9 +106,6 @@ 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'),
@@ -486,32 +482,6 @@ 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,7 +9,6 @@ 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';
@@ -81,9 +80,6 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => true),
),
@@ -9,7 +9,6 @@ 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';
@@ -79,9 +78,6 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => true),
),
-142
View File
@@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Requested: the Android Auto playback screen must offer previous/next for
@@ -173,145 +172,4 @@ void main() {
);
});
});
group('contextoParaSaltoEmisora — la MISMA decisión, nombrada para poder '
'recordarla entre procesos', () {
Emisora favorita(String uuid, String grupo) => Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
final rock1 = favorita('rock1', 'g-rock');
final rock2 = favorita('rock2', 'g-rock');
final jazz1 = favorita('jazz1', 'g-jazz');
test('nombra el grupo cuando el salto se queda dentro del grupo', () {
expect(
contextoParaSaltoEmisora(
actual: rock1,
favoritos: [rock1, jazz1, rock2],
misEmisoras: const [],
todas: const [],
),
const ContextoSalto.grupo('g-rock'),
);
});
test('nombra la lista de cada uno de los otros tres casos', () {
expect(
contextoParaSaltoEmisora(
actual: jazz1,
favoritos: [rock1, jazz1, rock2],
misEmisoras: const [],
todas: const [],
),
const ContextoSalto.favoritos(),
reason: 'un grupo de un solo miembro cae a todos los favoritos',
);
expect(
contextoParaSaltoEmisora(
actual: c,
favoritos: [a, b],
misEmisoras: [c, a],
todas: [a, b, c],
),
const ContextoSalto.misEmisoras(),
);
expect(
contextoParaSaltoEmisora(
actual: c,
favoritos: [a],
misEmisoras: [b],
todas: [a, b, c],
),
const ContextoSalto.todas(),
);
});
test('null cuando la emisora no está en ninguna lista', () {
expect(
contextoParaSaltoEmisora(
actual: emisora('huerfana'),
favoritos: [a],
misEmisoras: [b],
todas: [a, b],
),
isNull,
);
});
test('CONCUERDA con listaParaSaltoEmisora en todos los casos: son la '
'misma decisión y no pueden divergir', () {
final escenarios = <List<List<Emisora>>>[
[
[rock1],
[rock1, jazz1, rock2],
const [],
const [],
],
[
[jazz1],
[rock1, jazz1, rock2],
const [],
const [],
],
[
[c],
[a, b],
[c, a],
[a, b, c],
],
[
[c],
[a],
[b],
[a, b, c],
],
[
[emisora('huerfana')],
[a],
[b],
[a, b],
],
];
for (final escenario in escenarios) {
final actual = escenario[0].single;
final favoritos = escenario[1];
final misEmisoras = escenario[2];
final todas = escenario[3];
final contexto = contextoParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
);
final porContexto =
contexto == null
? const <Emisora>[]
: resolverListaContexto(
contexto: contexto,
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
destacadas: const [],
grupos: const [
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
);
expect(
porContexto,
listaParaSaltoEmisora(
actual: actual,
favoritos: favoritos,
misEmisoras: misEmisoras,
todas: todas,
),
);
}
});
});
}
@@ -1,308 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Contexto de reproducción — el «en qué lista estoy» que sobrevive a que el
/// proceso muera.
///
/// Mismo molde headless-safe que `emisoras_destacadas.dart`: solo
/// `shared_preferences` y modelos, jamás `EstadoRadio` ni un `ChangeNotifier`,
/// porque este módulo tiene que leerse desde el motor sin árbol de widgets que
/// levanta Android Auto.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Emisora emisora(String uuid, {String grupo = GrupoFavoritos.sinAsignarId}) =>
Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
group('ContextoSalto — serialización', () {
test('la clave de persistencia queda fijada literalmente', () {
// Un rename silencioso aquí no rompe nada en compilación y deja al
// conductor sin contexto tras actualizar: se fija a propósito.
expect(claveContextoSalto, 'contexto_salto_v1');
});
test('round trip de los tres tipos que llevan carga útil', () {
for (final contexto in [
const ContextoSalto.grupo('g-rock'),
const ContextoSalto.favoritos(),
const ContextoSalto.misEmisoras(),
const ContextoSalto.todas(),
const ContextoSalto.destacadas(['a', 'b', 'c']),
]) {
expect(ContextoSalto.desdeMapa(contexto.aMapa()), contexto);
}
});
test('un payload corrupto o ajeno devuelve null en vez de lanzar', () {
expect(ContextoSalto.desdeMapa(const {}), isNull);
expect(ContextoSalto.desdeMapa(const {'tipo': 'inventado'}), isNull);
expect(
ContextoSalto.desdeMapa(const {'tipo': 'grupoFavoritos'}),
isNull,
reason: 'un contexto de grupo sin id de grupo no resuelve a nada',
);
expect(
ContextoSalto.desdeMapa(const {'tipo': 'destacadas', 'uuids': 7}),
isNull,
);
});
});
group('persistencia', () {
setUp(() => SharedPreferences.setMockInitialValues({}));
test('round trip por disco', () async {
final prefs = await SharedPreferences.getInstance();
await guardarContextoSalto(
const ContextoSalto.grupo('g-rock'),
prefs: prefs,
);
expect(
await contextoSaltoPersistido(prefs: prefs),
const ContextoSalto.grupo('g-rock'),
);
});
test('sin nada persistido devuelve null', () async {
final prefs = await SharedPreferences.getInstance();
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
});
test('un JSON ilegible degrada a null, nunca lanza: esto se lee desde un '
'botón del volante', () async {
SharedPreferences.setMockInitialValues({
claveContextoSalto: 'esto no es json',
});
final prefs = await SharedPreferences.getInstance();
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
SharedPreferences.setMockInitialValues({
claveContextoSalto: jsonEncode({'tipo': 'inventado'}),
});
expect(
await contextoSaltoPersistido(
prefs: await SharedPreferences.getInstance(),
),
isNull,
);
});
});
group('resolverListaContexto — degradación del contexto recordado', () {
final rock1 = emisora('rock1', grupo: 'g-rock');
final rock2 = emisora('rock2', grupo: 'g-rock');
final jazz1 = emisora('jazz1', grupo: 'g-jazz');
final suelta = emisora('suelta');
final favoritos = [rock1, jazz1, rock2, suelta];
const grupos = [
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
];
List<Emisora> resolver(
ContextoSalto contexto,
Emisora actual, {
List<Emisora>? favs,
List<GrupoFavoritos>? gruposVivos,
List<Emisora> misEmisoras = const [],
List<Emisora> todas = const [],
List<Emisora> destacadas = const [],
}) => resolverListaContexto(
contexto: contexto,
actual: actual,
favoritos: favs ?? favoritos,
misEmisoras: misEmisoras,
todas: todas,
destacadas: destacadas,
grupos: gruposVivos ?? grupos,
);
test('el grupo recordado se recorre con sus miembros VIVOS, no con el '
'snapshot', () {
final nuevo = emisora('rock3', grupo: 'g-rock');
expect(
resolver(
const ContextoSalto.grupo('g-rock'),
rock1,
favs: [rock1, jazz1, rock2, nuevo],
),
[rock1, rock2, nuevo],
);
});
test('el grupo recordado ya NO existe -> cae a todos los favoritos', () {
expect(
resolver(
const ContextoSalto.grupo('g-borrado'),
rock1,
gruposVivos: const [
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
),
favoritos,
);
});
test('el grupo SIGUE VIVO con un solo miembro -> se honra igual: un grupo '
'de una emisora sigue siendo el grupo que eligió el conductor', () {
expect(
resolver(
const ContextoSalto.grupo('g-jazz'),
jazz1,
),
[jazz1],
);
});
test('el grupo sigue vivo pero la emisora que suena ya NO pertenece a él '
'-> se PERMANECE en el grupo (el llamador coge su primera emisora)',
() {
expect(
resolver(const ContextoSalto.grupo('g-rock'), jazz1),
[rock1, rock2],
);
});
test('el grupo sigue vivo pero se quedó VACÍO -> no hay primera emisora '
'que coger, así que se ensancha a todos los favoritos', () {
expect(
resolver(
const ContextoSalto.grupo('g-rock'),
jazz1,
favs: [jazz1, suelta],
),
[jazz1, suelta],
);
});
test('el grupo fue borrado y la emisora ya NO es favorita -> aun así se '
'cae a los favoritos: el llamador elegirá una de ellas', () {
expect(
resolver(
const ContextoSalto.grupo('g-borrado'),
emisora('fuera'),
gruposVivos: const [],
),
favoritos,
);
});
test('no quedan favoritos -> lista vacía: el comportamiento de siempre '
'cuando no hay emisoras agregadas', () {
expect(
resolver(
const ContextoSalto.grupo('g-rock'),
emisora('fuera'),
favs: const [],
gruposVivos: const [],
),
isEmpty,
);
});
test('la emisora salió de favoritos por completo -> el contexto de '
'FAVORITOS se descarta', () {
expect(
resolver(const ContextoSalto.favoritos(), emisora('fuera')),
isEmpty,
);
});
test('favoritos / misEmisoras / todas se resuelven contra su lista viva', () {
expect(resolver(const ContextoSalto.favoritos(), rock1), favoritos);
final propia = emisora('propia');
expect(
resolver(
const ContextoSalto.misEmisoras(),
propia,
misEmisoras: [propia, suelta],
),
[propia, suelta],
);
final catalogo = emisora('catalogo');
expect(
resolver(
const ContextoSalto.todas(),
catalogo,
todas: [catalogo, rock1],
),
[catalogo, rock1],
);
});
test('destacadas respeta el ORDEN CONGELADO, que es la razón de existir '
'del snapshot: la lista viva se reordena sola en cada lectura', () {
final fip = emisora('fip');
final soma = emisora('soma');
final ajena = emisora('ajena');
expect(
resolver(
const ContextoSalto.destacadas(['ajena', 'fip', 'soma']),
ajena,
destacadas: [fip, soma],
),
[ajena, fip, soma],
reason:
'la emisora que suena entra en la lista aunque no esté en el set '
'curado; si no, ambos botones morirían',
);
});
test('destacadas: un uuid del snapshot que ya no resuelve se descarta', () {
final fip = emisora('fip');
final soma = emisora('soma');
expect(
resolver(
const ContextoSalto.destacadas(['fip', 'retirada', 'soma']),
fip,
destacadas: [fip, soma],
),
[fip, soma],
);
});
test('destacadas: si la emisora que suena no está en el snapshot el '
'contexto se descarta', () {
final fip = emisora('fip');
expect(
resolver(
const ContextoSalto.destacadas(['fip']),
emisora('otra'),
destacadas: [fip],
),
isEmpty,
);
});
});
group('uuidsCongeladosDestacadas', () {
test('respeta el orden curado y antepone la emisora que suena cuando no '
'pertenece al set', () {
final fip = emisora('fip');
final soma = emisora('soma');
expect(
uuidsCongeladosDestacadas(actual: fip, destacadas: [fip, soma]),
['fip', 'soma'],
);
expect(
uuidsCongeladosDestacadas(
actual: emisora('ajena'),
destacadas: [fip, soma],
),
['ajena', 'fip', 'soma'],
);
});
});
}
@@ -1,125 +0,0 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Free-tier featured set (fix/auto-quality-guidelines, item 6).
///
/// The whole compliance story rests on this: a Play reviewer on a fresh
/// install is ALWAYS free tier, has no network catalogue snapshot, no
/// favourites, no custom stations and no `ultima_emisora_v1` — so the free
/// root's single folder MUST still resolve to real, playable stations from
/// nothing but the binary itself.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const ultima = Emisora(
uuid: 'uuid-ultima',
nombre: 'Ultima escuchada',
url: 'https://ultima.example/stream',
);
group('resolverEmisorasDestacadas', () {
test('cold bind: sin red, sin EstadoRadio y con prefs vacías devuelve '
'>= 3 emisoras reales', () async {
SharedPreferences.setMockInitialValues({});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.length, greaterThanOrEqualTo(3));
expect(
destacadas.every((e) => e.uuid.isNotEmpty),
isTrue,
reason: 'un uuid vacío no se puede resolver desde emisora:<uuid>',
);
expect(
destacadas.every(
(e) => e.url.startsWith('http://') || e.url.startsWith('https://'),
),
isTrue,
);
expect(
destacadas.map((e) => e.uuid).toSet().length,
destacadas.length,
reason: 'uuids duplicados romperían porUuid',
);
});
test('con ultima_emisora_v1 presente: va PRIMERA y no se duplica',
() async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(ultima.toMap()),
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.first.uuid, ultima.uuid);
expect(
destacadas.where((e) => e.uuid == ultima.uuid).length,
1,
reason: 'la última escuchada no puede aparecer dos veces',
);
expect(destacadas.length, emisorasDestacadas.length + 1);
});
test('la última escuchada YA curada no añade una segunda fila', () async {
final yaCurada = emisorasDestacadas.first;
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(yaCurada.toMap()),
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.first.uuid, yaCurada.uuid);
expect(destacadas.length, emisorasDestacadas.length);
});
test('ultima_emisora_v1 corrupta degrada al set curado, nunca lanza',
() async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: 'no-es-json{{',
});
final destacadas = await resolverEmisorasDestacadas();
expect(destacadas.length, emisorasDestacadas.length);
});
});
group('esEmisoraGratuitaPorUuid', () {
test('un uuid curado es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(
await esEmisoraGratuitaPorUuid(emisorasDestacadas.first.uuid),
isTrue,
);
});
test('la última escuchada es gratuita aunque no esté curada', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora: jsonEncode(ultima.toMap()),
});
expect(await esEmisoraGratuitaPorUuid(ultima.uuid), isTrue);
});
test('un uuid del catálogo Radio Browser NO es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(await esEmisoraGratuitaPorUuid('uuid-del-catalogo'), isFalse);
});
test('uuid vacío nunca es gratuito', () async {
SharedPreferences.setMockInitialValues({});
expect(await esEmisoraGratuitaPorUuid(''), isFalse);
});
});
test('claveUltimaEmisora coincide con la que persiste EstadoRadio', () {
expect(claveUltimaEmisora, 'ultima_emisora_v1');
});
}
@@ -1,267 +0,0 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Free-tier Android Auto surface (fix/auto-quality-guidelines, items 7, 8
/// and 10).
///
/// Google Play returned "Approved with Issues" against the Android for Cars
/// App Quality Guidelines on version code 157. The free root advertised four
/// folders that each dead-ended on a single non-playable "Función Premium"
/// row, and on a cold headless bind every one of the underlying lists is
/// empty anyway. This suite pins the replacement: ONE browsable folder that
/// resolves to real, playable stations.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FuenteEmisorasAutoLocal.porUuid — item 7', () {
// Cold bind shape: `todas()` is `_snapshotTodas ?? const []`, no
// favourites (sqflite is not initialised under `flutter test`, so the
// read throws and degrades to `[]`), and a custom-stations path that
// does not exist.
FuenteEmisorasAutoLocal fuenteFria() => FuenteEmisorasAutoLocal(
resolverRutaCustom: () async => 'no/existe/emisoras_custom.json',
);
setUp(() => SharedPreferences.setMockInitialValues({}));
test('en frío resuelve un uuid destacado (antes devolvía null y la fila '
'no hacía nada al tocarla)', () async {
final fuente = fuenteFria();
final resuelta = await fuente.porUuid(emisorasDestacadas.first.uuid);
expect(resuelta, isNotNull);
expect(resuelta!.url, emisorasDestacadas.first.url);
});
test('en frío resuelve la última escuchada persistida', () async {
const ultima = Emisora(
uuid: 'uuid-ultima',
nombre: 'Ultima',
url: 'https://ultima.example/stream',
);
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"uuid-ultima","nombre":"Ultima",'
'"url":"https://ultima.example/stream"}',
});
final resuelta = await fuenteFria().porUuid(ultima.uuid);
expect(resuelta?.url, ultima.url);
});
test('un uuid desconocido sigue devolviendo null', () async {
expect(await fuenteFria().porUuid('uuid-inexistente'), isNull);
});
test('el snapshot vivo gana al set destacado para el MISMO uuid',
() async {
final fuente = fuenteFria();
final delCatalogo = Emisora(
uuid: emisorasDestacadas.first.uuid,
nombre: 'Version viva',
url: 'https://viva.example/stream',
);
fuente.actualizarSnapshot(todas: [delCatalogo]);
final resuelta = await fuente.porUuid(delCatalogo.uuid);
expect(resuelta?.url, 'https://viva.example/stream');
});
});
group('raiz(premium:) — item 8', () {
test('free: exactamente UNA carpeta navegable, y ninguna de las cuatro '
'que morían en la fila premium', () {
final constructor = ConstructorArbolAuto();
// `incluirMusicaLocal: true` a propósito: ni siquiera con carpeta
// local configurada puede el tier gratuito ver ese nodo.
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
expect(libre, hasLength(1));
expect(libre.single.id, ConstructorArbolAuto.idDestacadas);
expect(libre.single.playable, isFalse);
expect(libre.single.title, isNotEmpty);
expect(
libre.map((m) => m.id),
isNot(
anyOf(
contains(ConstructorArbolAuto.idFavoritos),
contains(ConstructorArbolAuto.idTodas),
contains(ConstructorArbolAuto.idMisEmisoras),
contains(ConstructorArbolAuto.idMusicaLocal),
),
),
);
});
test('premium: el árbol de hoy, sin cambios (guardia de regresión)', () {
final constructor = ConstructorArbolAuto();
expect(
constructor
.raiz(incluirMusicaLocal: true, premium: true)
.map((m) => m.id),
[
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
],
);
expect(
constructor
.raiz(incluirMusicaLocal: false, premium: true)
.map((m) => m.id),
[
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
],
);
expect(
constructor
.raiz(incluirMusicaLocal: true, premium: true)
.every((m) => m.playable == false),
isTrue,
);
});
test('la raíz del tier gratuito SIEMPRE lleva una carpeta navegable: '
'audio_service 0.18.18 descarta los rootHints, así que un root de '
'un solo item PLAYABLE se renderiza vacío en una unidad que solo '
'acepta FLAG_BROWSABLE', () {
final libre = ConstructorArbolAuto().raiz(
incluirMusicaLocal: false,
premium: false,
);
expect(libre.any((m) => m.playable == false), isTrue);
});
test('el titulo de la unica carpeta gratuita lo decide el LLAMANTE, no '
'una constante castellana de este archivo (hallazgo 4)', () {
final libre = ConstructorArbolAuto().raiz(
incluirMusicaLocal: false,
premium: false,
tituloDestacadas: 'Listen',
);
expect(
libre.single.title,
'Listen',
reason:
'this one label is 100% of the browse tree a free-tier (i.e. '
'every Play reviewer) driver ever sees; the pure builder stays '
'AppLocalizations-free, so the handler has to hand it the string',
);
});
});
group('hijosDestacadas — item 8/9', () {
test('mapea a items PLAYABLE con id emisora:<uuid>', () {
final items = ConstructorArbolAuto().hijosDestacadas(emisorasDestacadas);
expect(items, hasLength(emisorasDestacadas.length));
expect(items.every((m) => m.playable == true), isTrue);
expect(items.first.id, 'emisora:${emisorasDestacadas.first.uuid}');
expect(items.every((m) => m.artUri != null), isTrue);
});
test('lista vacía devuelve lista vacía, nunca lanza', () {
expect(ConstructorArbolAuto().hijosDestacadas(const []), isEmpty);
});
});
group('respuestaBloqueadaPorEntitlement — item 10', () {
List<MediaItem>? gate(String id, {required bool premium}) =>
respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: premium,
destacadas: emisorasDestacadas,
);
test('premium: nada se bloquea', () {
for (final id in [
AudioService.browsableRootId,
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idDestacadas,
'emisora:uuid-del-catalogo',
]) {
expect(gate(id, premium: true), isNull, reason: id);
}
});
test('free: la raíz y el contenido gratuito PASAN', () {
expect(gate(AudioService.browsableRootId, premium: false), isNull);
expect(gate(ConstructorArbolAuto.idDestacadas, premium: false), isNull);
for (final e in emisorasDestacadas) {
expect(gate('emisora:${e.uuid}', premium: false), isNull);
}
});
test('free: el catálogo premium se bloquea', () {
for (final id in [
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idEcualizador,
'grupo:algo',
'emisora:uuid-del-catalogo',
'pista:doc-id',
]) {
expect(gate(id, premium: false), isNotNull, reason: id);
}
});
test('la rama bloqueada devuelve el contenido gratuito, NUNCA una fila '
'no reproducible: eso es exactamente lo que Play citó', () {
final bloqueada = gate(ConstructorArbolAuto.idTodas, premium: false);
expect(bloqueada, isNotNull);
expect(bloqueada, isNotEmpty);
expect(
bloqueada!.every((m) => m.playable == true),
isTrue,
reason: 'una fila no reproducible en el árbol es la cita de Play',
);
expect(bloqueada.map((m) => m.id), [
for (final e in emisorasDestacadas) 'emisora:${e.uuid}',
]);
});
test('sin destacadas resolubles la rama bloqueada sigue sin inventar una '
'fila muerta', () {
final bloqueada = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idTodas,
premium: false,
destacadas: const [],
);
expect(bloqueada, isEmpty);
});
});
test('idPremiumInfo / itemPremiumBloqueado ya no existen — item 10', () {
// Guardia estructural: si alguien los reintroduce, este archivo deja de
// compilar por el `expect` de abajo, no por un comentario. La única
// prueba real es que `ConstructorArbolAuto` no expone ningún item no
// reproducible fuera de las carpetas.
final libre = ConstructorArbolAuto().raiz(
incluirMusicaLocal: true,
premium: false,
);
expect(libre.every((m) => m.id != 'premium:info'), isTrue);
});
}
+78 -114
View File
@@ -1,142 +1,106 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Android Auto entitlement gating — the id-shape matrix.
///
/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
/// assert the opposite design: that every non-root id, for a free-tier user,
/// collapsed to a single non-playable `premium:info` row. Google Play cited
/// that browse tree against the Android for Cars App Quality Guidelines, so
/// the contract is now content-scoping — the free tier sees LESS, never a
/// row that does nothing.
///
/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in
/// `navegacion_auto_destacadas_test.dart`; this file pins the decision
/// surface ([idPermitidoEnFree]) across every id shape the tree can produce,
/// including the stale/deep-linked ones a head unit's cached tree replays.
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content",
/// design.md ADR-4). All pure — no handler instantiation needed
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test).
void main() {
const gratuitas = [
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
];
group('raiz(premium:) — root keeps its labels for every tier', () {
test('premium: identical to today\'s tree (regression guard)', () {
final constructor = ConstructorArbolAuto();
group('idPermitidoEnFree', () {
test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
expect(
idPermitidoEnFree(
AudioService.browsableRootId,
destacadas: gratuitas,
),
isTrue,
final premiumConLocal = constructor.raiz(
incluirMusicaLocal: true,
premium: true,
);
});
test('la carpeta gratuita pasa', () {
expect(
idPermitidoEnFree(
ConstructorArbolAuto.idDestacadas,
destacadas: gratuitas,
),
isTrue,
final premiumSinLocal = constructor.raiz(
incluirMusicaLocal: false,
premium: true,
);
expect(premiumConLocal.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
]);
expect(premiumConLocal.every((m) => m.playable == false), isTrue);
expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue);
expect(premiumSinLocal.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
]);
});
test('un emisora:<uuid> del set gratuito pasa', () {
for (final e in gratuitas) {
expect(
idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas),
isTrue,
reason: e.uuid,
);
}
test('free: same folder ids/titles, non-blank, never playable', () {
final constructor = ConstructorArbolAuto();
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
expect(libre, isNotEmpty);
expect(libre.map((m) => m.id), [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
]);
expect(libre.every((m) => m.playable == false), isTrue);
});
});
test(
'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium',
() {
final item = ConstructorArbolAuto().itemPremiumBloqueado();
expect(item.id, 'premium:info');
expect(item.playable, isFalse);
expect(item.title, isNotEmpty);
},
);
group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () {
test('root nunca es bloqueada (root siempre resuelve via raiz)', () {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: AudioService.browsableRootId,
premium: false,
);
expect(respuesta, isNull);
});
test('las carpetas premium NO pasan', () {
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
for (final id in [
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
ConstructorArbolAuto.idEcualizador,
// Stale/deep-linked id from before a downgrade — the backstop must
// not special-case known ids (Spec "Stale folder id bypass
// attempt").
'emisora:algun-uuid-viejo',
'grupo:algo',
]) {
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
reason: id);
}
});
test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
'ésa es la propiedad de seguridad que el rediseño tenía que '
'conservar', () {
for (final id in [
'emisora:uuid-del-catalogo',
'grupo:algun-grupo',
'pista:doc-id',
'carpeta_local:doc-id',
'carpeta_local_reproducir:doc-id',
'carpeta_local_aleatorio:doc-id',
'eq_preset:Rock',
'premium:info', // la fila muerta que ya no existe
'',
]) {
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
reason: id);
}
});
test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () {
expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse);
});
test('con el set gratuito vacío solo pasan la raíz y su carpeta', () {
expect(
idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []),
isTrue,
);
expect(
idPermitidoEnFree(
ConstructorArbolAuto.idDestacadas,
destacadas: const [],
),
isTrue,
);
expect(
idPermitidoEnFree('emisora:libre-1', destacadas: const []),
isFalse,
);
});
});
group('respuestaBloqueadaPorEntitlement', () {
test('premium: ningún id se bloquea, ni siquiera uno inventado', () {
for (final id in [
AudioService.browsableRootId,
ConstructorArbolAuto.idTodas,
'emisora:cualquier-cosa',
'basura',
]) {
expect(
respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: true,
destacadas: gratuitas,
),
isNull,
reason: id,
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: id,
premium: false,
);
expect(respuesta, hasLength(1));
expect(respuesta!.single.id, 'premium:info');
}
});
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
final bloqueada = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
premium: false,
destacadas: gratuitas,
test('cualquier id no-root, en premium, no es bloqueada', () {
final respuesta = respuestaBloqueadaPorEntitlement(
parentMediaId: ConstructorArbolAuto.idFavoritos,
premium: true,
);
expect(bloqueada, isNotNull);
expect(bloqueada!.every((m) => m.playable == true), isTrue);
expect(respuesta, isNull);
});
});
}
@@ -1,221 +0,0 @@
import 'dart:ui' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Every user-readable label of the Android Auto browse tree is translated.
///
/// The owner's rule, after Play saw a Spanish-only car tree on a head unit
/// in any of the 13 shipped locales: anything a user can read gets
/// translated. The two ARB guards (`arb_parity_test`/`arb_anti_copy_test`)
/// only ever see strings that already entered the ARB system, so neither
/// could catch a label hardcoded in `navegacion_auto.dart` that never
/// became a key. This file closes that hole from the CONSUMPTION side
/// (the tree really renders the injected locale);
/// `test/l10n/etiquetas_arbol_auto_test.dart` closes it from the SOURCE
/// side (no new hardcoded label can be added at all).
Future<Map<String, MetadatosPista>> _sinMetadatos(List<String> ids) async =>
const {};
List<NodoLocal> _pistas(int cuantas, {String prefijo = 'cancion'}) =>
List.generate(
cuantas,
(i) => NodoLocal(
documentId: 'doc-$prefijo-$i',
nombre: '${prefijo}_${i.toString().padLeft(3, '0')}.mp3',
esDirectorio: false,
),
);
void main() {
final ingles = lookupAppLocalizations(const Locale('en'));
group('EtiquetasArbolAuto desde AppLocalizations', () {
test('mapea cada etiqueta del árbol a su clave ARB del locale', () {
final etiquetas = etiquetasArbolAutoDesde(ingles);
expect(etiquetas.escuchar, ingles.autoCarpetaEscuchar);
expect(etiquetas.favoritos, ingles.autoCarpetaFavoritos);
expect(etiquetas.todasLasEmisoras, ingles.autoCarpetaTodas);
expect(etiquetas.misEmisoras, ingles.autoCarpetaMisEmisoras);
expect(etiquetas.musicaLocal, ingles.autoCarpetaMusicaLocal);
expect(
etiquetas.musicaLocalNoDisponible,
ingles.autoMusicaLocalNoDisponible,
);
expect(etiquetas.cargarMas, ingles.autoCargarMas);
expect(etiquetas.ordenarPorCalidad, ingles.autoOrdenarPorCalidad);
expect(etiquetas.reproducirCarpeta, ingles.autoReproducirCarpeta);
expect(etiquetas.reproducirAleatorio, ingles.autoReproducirAleatorio);
expect(etiquetas.pistaSinNombre, ingles.autoPistaSinNombre);
});
test('ninguna etiqueta inglesa cae en el castellano de respaldo', () {
final etiquetas = etiquetasArbolAutoDesde(ingles);
const respaldo = EtiquetasArbolAuto.respaldo;
expect(etiquetas.favoritos, isNot(respaldo.favoritos));
expect(etiquetas.todasLasEmisoras, isNot(respaldo.todasLasEmisoras));
expect(etiquetas.misEmisoras, isNot(respaldo.misEmisoras));
expect(etiquetas.musicaLocal, isNot(respaldo.musicaLocal));
expect(
etiquetas.musicaLocalNoDisponible,
isNot(respaldo.musicaLocalNoDisponible),
);
expect(etiquetas.cargarMas, isNot(respaldo.cargarMas));
expect(etiquetas.ordenarPorCalidad, isNot(respaldo.ordenarPorCalidad));
expect(etiquetas.reproducirCarpeta, isNot(respaldo.reproducirCarpeta));
expect(etiquetas.reproducirAleatorio, isNot(respaldo.reproducirAleatorio));
expect(etiquetas.pistaSinNombre, isNot(respaldo.pistaSinNombre));
});
});
group('ConstructorArbolAuto rotula con las etiquetas inyectadas', () {
final constructor = ConstructorArbolAuto(
etiquetas: etiquetasArbolAutoDesde(ingles),
);
test('la raíz premium rotula sus cuatro carpetas en el locale', () {
final raiz = constructor.raiz(incluirMusicaLocal: true, premium: true);
expect(raiz.map((i) => i.title).toList(), [
ingles.autoCarpetaFavoritos,
ingles.autoCarpetaTodas,
ingles.autoCarpetaMisEmisoras,
ingles.autoCarpetaMusicaLocal,
]);
});
test('la raíz gratuita sigue rotulando Escuchar en el locale', () {
final raiz = constructor.raiz(incluirMusicaLocal: false, premium: false);
expect(raiz.single.title, ingles.autoCarpetaEscuchar);
});
test('el item de música local no disponible va en el locale', () {
expect(
constructor.itemLocalNoDisponible().title,
ingles.autoMusicaLocalNoDisponible,
);
});
test('las acciones de carpeta y la entrada de orden van en el '
'locale', () async {
final items = await constructor.itemsLocales(
_pistas(3),
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final titulos = items.map((i) => i.title).toList();
expect(titulos, contains(ingles.autoReproducirCarpeta));
expect(titulos, contains(ingles.autoReproducirAleatorio));
expect(titulos, contains(ingles.autoOrdenarPorCalidad));
});
test('el item "cargar más" de las tres vistas paginadas va en el '
'locale', () async {
final nodos = _pistas(60);
final porNombre = await constructor.itemsLocales(
nodos,
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final porCalidad = await constructor.itemsLocalesOrdenCalidad(
nodos,
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final porBucket = await constructor.itemsLocalesBucket(
_pistas(60, prefijo: 'apple'),
documentIdPadre: 'padre',
idxBucket: 0,
metadatosDe: _sinMetadatos,
);
expect(porNombre.last.title, ingles.autoCargarMas);
expect(porCalidad.last.title, ingles.autoCargarMas);
expect(porBucket.last.title, ingles.autoCargarMas);
});
test('un nombre de fichero en blanco cae en la pista sin nombre del '
'locale', () async {
final items = await constructor.itemsLocales(
const [
NodoLocal(
documentId: 'doc-vacio',
nombre: ' ',
esDirectorio: false,
),
],
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
final pista = items.singleWhere((i) => i.id.startsWith('pista:'));
expect(pista.title, ingles.autoPistaSinNombre);
});
test('los rangos alfabéticos NO se traducen: son rangos de letras '
'latinas, no prosa', () async {
final items = await constructor.itemsLocales(
_pistas(60),
documentIdPadre: 'padre',
metadatosDe: _sinMetadatos,
);
expect(items.map((i) => i.title), containsAll(['A-F', 'G-M']));
});
});
group('hijosMusicaLocal propaga las etiquetas', () {
test('la carpeta raíz local rotula sus acciones en el locale', () async {
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: _FuenteLocalFalsa(),
etiquetas: etiquetasArbolAutoDesde(ingles),
);
expect(
items!.map((i) => i.title),
containsAll([
ingles.autoReproducirCarpeta,
ingles.autoReproducirAleatorio,
]),
);
});
});
}
/// Minimal in-memory [FuenteMusicaLocalAuto]: one playable track at the
/// tree root, no metadata, native channel available.
class _FuenteLocalFalsa implements FuenteMusicaLocalAuto {
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async =>
EstadoCarpetaLocal.configurada;
@override
Future<List<NodoLocal>> hijos(String documentId) async =>
documentId.isEmpty
? const [
NodoLocal(
documentId: 'doc-0',
nombre: 'cancion.mp3',
esDirectorio: false,
),
]
: const [];
@override
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds) =>
_sinMetadatos(documentIds);
@override
Future<String?> uriContenidoDePista(String documentId) async =>
'content://fake/$documentId';
}
+3 -5
View File
@@ -2135,11 +2135,9 @@ void main() {
/// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se
/// lee como «no tengo música», que es justo la conclusión equivocada).
///
/// La etiqueta sale de `EtiquetasArbolAuto.musicaLocalNoDisponible`,
/// como TODAS las etiquetas legibles del árbol del coche: todo lo que
/// un usuario lee se traduce. Este test solo comprueba que hay UNA
/// etiqueta no vacía; el idioma concreto lo cubre
/// `navegacion_auto_localizacion_test.dart`.
/// La etiqueta va en castellano hardcodeado, como TODAS las etiquetas
/// del árbol del coche en `navegacion_auto.dart` (ver
/// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`.
test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item '
'explicativo NO reproducible, no una carpeta vacía', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
@@ -1,679 +0,0 @@
import 'dart:async';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Free-tier Android Auto surface, handler side (fix/auto-quality-guidelines,
/// items 9, 11, 12, 13, 14).
///
/// Every test here runs with `_fuenteNavegacionGlobal` NEVER registered —
/// this file never calls `registrarFuenteNavegacion`. That is the exact bind
/// a Play reviewer performs: Android Auto starts the headless engine, and
/// until (and even after) `main.dart` wires its sources, the car's browse and
/// play paths must produce real, playable content out of the binary alone.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier: `esPremiumPersistido` is
// `getBool('compra_premium_v1') ?? false`, and there is no trial key.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.lectorLocalePlataforma =
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
});
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
group('debeBloquearCambioDeEmisora — item 11', () {
test('free + emisora gratuita: NO bloquea', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
isFalse,
);
});
test('free + emisora premium: bloquea (propiedad de seguridad original — '
'un emisora:<uuid> rancio de antes de una bajada de tier no puede '
'sonar)', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
isTrue,
);
});
test('premium: nunca bloquea', () {
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
isFalse,
);
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: true),
isFalse,
);
});
});
group('getChildren — item 9', () {
test('sin fuente de navegación registrada, la carpeta gratuita devuelve '
'>= 3 items REPRODUCIBLES', () async {
final handler = crearHandler();
final items = await handler.getChildren(
ConstructorArbolAuto.idDestacadas,
);
expect(items.length, greaterThanOrEqualTo(3));
expect(items.every((m) => m.playable == true), isTrue);
expect(items.every((m) => m.id.startsWith('emisora:')), isTrue);
});
test('la raíz gratuita es UNA carpeta y no paga el round trip nativo de '
'estadoCarpeta()', () async {
final fuenteLocal = _FuenteMusicaLocalEspia();
registrarFuenteMusicaLocal(fuenteLocal);
final handler = crearHandler();
final raiz = await handler.getChildren(AudioService.browsableRootId);
expect(raiz.map((m) => m.id), [ConstructorArbolAuto.idDestacadas]);
expect(
fuenteLocal.llamadasEstadoCarpeta,
0,
reason:
'estadoCarpeta() viaja por un MethodChannel que NO existe en el '
'motor headless; el tier gratuito no puede ver Música Local, así '
'que preguntarlo solo añade una vía de fallo en la raíz',
);
});
test('una carpeta premium NO devuelve una fila no reproducible: devuelve '
'el contenido gratuito', () async {
final handler = crearHandler();
final items = await handler.getChildren(ConstructorArbolAuto.idTodas);
expect(items, isNotEmpty);
expect(items.every((m) => m.playable == true), isTrue);
expect(items.every((m) => m.id != 'premium:info'), isTrue);
});
});
group('playFromMediaId — item 12', () {
test('free + uuid gratuito: SUENA', () async {
final handler = crearHandler();
final destacada = emisorasDestacadas.first;
await handler.playFromMediaId('emisora:${destacada.uuid}');
await pumpEventQueue();
expect(guion.urlsSolicitadas, contains(destacada.url));
});
test('free + uuid premium: publica error CON errorCode y errorMessage '
'localizado, nunca vuelve en silencio', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
await handler.playFromMediaId('emisora:uuid-del-catalogo');
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.processingState, AudioProcessingState.error);
expect(estado.errorCode, isNotNull);
expect(
estado.errorMessage,
lookupAppLocalizations(const Locale('en')).autoErrorEmisoraPremium,
);
expect(guion.urlsSolicitadas, isEmpty);
});
test('free + pista local: bloqueada, y también publica el error', () async {
final handler = crearHandler();
await handler.playFromMediaId('pista:doc-id');
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
});
test('free + uuid inexistente en NINGÚN sitio: error, no silencio',
() async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:');
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
});
});
group('playFromSearch — items 12 y 13', () {
test('free: una consulta que casa con el set gratuito SUENA', () async {
final handler = crearHandler();
final destacada = emisorasDestacadas.first;
await handler.playFromSearch(destacada.nombre);
await pumpEventQueue();
expect(guion.urlsSolicitadas, contains(destacada.url));
});
test('free: una consulta sin resultados publica error localizado, nunca '
'vuelve en silencio', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
final handler = crearHandler();
await handler.playFromSearch('emisora que no existe en ningun sitio');
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.processingState, AudioProcessingState.error);
expect(estado.errorCode, isNotNull);
expect(estado.errorMessage, textos().autoErrorBusquedaSinResultados);
});
test('consulta VACÍA ("Reproduce PluriWave") arranca algo — free', () async {
final handler = crearHandler();
await handler.playFromSearch('');
await pumpEventQueue();
expect(guion.urlsSolicitadas, isNotEmpty);
});
test('consulta VACÍA arranca algo — PREMIUM (antes fallaba incluso para '
'un cliente que había pagado)', () async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final handler = crearHandler();
await handler.playFromSearch(' ');
await pumpEventQueue();
expect(guion.urlsSolicitadas, isNotEmpty);
});
test('consulta vacía prefiere la ÚLTIMA escuchada', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"uuid-ultima","nombre":"Ultima",'
'"url":"https://ultima.example/stream"}',
});
final handler = crearHandler();
await handler.playFromSearch('');
await pumpEventQueue();
expect(guion.urlsSolicitadas, ['https://ultima.example/stream']);
});
});
group('un rechazo NO destruye una sesion que esta sonando — hallazgo 1', () {
/// Puts the handler in the exact state the reviewer reproduces: a free
/// station tapped from the browse tree and audibly playing.
Future<PluriWaveAudioHandler> sonando() async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
guion.ultimoReproductor!.emitir(
PlayerState(true, ProcessingState.ready),
);
await pumpEventQueue();
expect(
handler.playbackState.value.playing,
isTrue,
reason: 'precondicion: la emisora esta sonando',
);
return handler;
}
test(
'una busqueda por voz fallida deja el estado publicado FUERA de error y '
'sigue ofreciendo play/pause/stop',
() async {
final handler = await sonando();
// The free candidate set is only the six compiled-in stations, so
// almost any spoken station name misses. That must not cost the
// driver the whole now-playing screen.
await handler.playFromSearch('BBC');
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason:
'AudioService.java:601-611 maps `error` to STATE_ERROR with the '
'`playing` flag IGNORED, so publishing it over live audio '
'replaces the transport row with an error the session can never '
'clear: playerStateStream is .distinct() (nothing more comes '
'from a steadily playing ExoPlayer) and _bufferedSub re-asserts '
'it ~2x/second through copyWith',
);
expect(
estado.playing,
isTrue,
reason: 'el audio sigue sonando; el estado tiene que decirlo',
);
expect(
estado.systemActions,
containsAll(<MediaAction>[
MediaAction.play,
MediaAction.pause,
MediaAction.stop,
]),
);
expect(
estado.controls.map((c) => c.action),
containsAll(<MediaAction>[MediaAction.pause, MediaAction.stop]),
);
},
);
test('y el motivo del fallo SIGUE llegando al head unit', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
// AudioService.java:541-544 calls `setErrorMessage` from `setState`
// regardless of processingState, so the text still reaches
// PlaybackStateCompat without STATE_ERROR.
expect(
handler.playbackState.value.errorMessage,
textos().autoErrorBusquedaSinResultados,
);
expect(handler.playbackState.value.errorCode, isNotNull);
});
test(
'sin sesion viva el rechazo SI es terminal: error explicado (el caso en '
'que el coche no tiene nada que perder)',
() async {
final handler = crearHandler();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
expect(handler.playbackState.value.errorMessage, isNotNull);
},
);
/// CONTRACT for an action refusal published over a LIVE session
/// (`_publicarErrorAuto`'s non-terminal branch):
///
/// 1. the code and the message are published immediately and stand for
/// [PluriWaveAudioHandler.ventanaErrorAccionAuto] (the two tests
/// above pin step 1);
/// 2. when that window elapses they are BOTH cleared, and nothing else
/// about the state moves;
/// 3. any real player transition arriving first clears them early — a
/// genuine state change supersedes a stale refusal;
/// 4. only the fields this refusal published are ever cleared, so a
/// reconnect status message that replaced them survives.
///
/// It has to be bounded: `_bufferedSub` republishes
/// `playbackState.value.copyWith(...)` ~2x/second and `copyWith` carries
/// every omitted field forward (audio_service.dart:400-427), so
/// `AudioService.java:541-544` re-calls `setErrorMessage(code, msg)` on
/// every one of those pushes. Without a clear, one voice miss makes the
/// session advertise an error for the rest of the station's playback,
/// over audible healthy audio.
group('y el rechazo es TRANSITORIO: nada lo arrastra para siempre', () {
setUp(() {
PluriWaveAudioHandler.ventanaErrorAccionAuto = const Duration(
milliseconds: 80,
);
});
tearDown(() {
PluriWaveAudioHandler.ventanaErrorAccionAuto =
PluriWaveAudioHandler.ventanaErrorAccionAutoPorDefecto;
});
test('al pasar la ventana, codigo y mensaje desaparecen', () async {
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(
handler.playbackState.value.errorCode,
isNotNull,
reason: 'precondicion: el rechazo se publico',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.errorCode,
isNull,
reason:
'every later push carries the code forward through copyWith, so '
'the session would keep telling the head unit it is in error '
'while the station plays perfectly',
);
expect(estado.errorMessage, isNull);
expect(
estado.playing,
isTrue,
reason: 'clearing the refusal must not touch the session itself',
);
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason: 'nor its processing state',
);
});
test(
'y un cambio de estado real del reproductor los limpia antes',
() async {
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(handler.playbackState.value.errorMessage, isNotNull);
// The driver pauses: a genuine transition. `manejarEstadoPlayer`
// omitted both fields, so `copyWith` carried the refusal into the
// paused state and every state after it.
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.ready),
);
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.errorCode, isNull);
expect(estado.errorMessage, isNull);
},
);
test('y stop() limpia el codigo, no solo el mensaje', () async {
final handler = await sonando();
await handler.playFromSearch('BBC');
await pumpEventQueue();
expect(handler.playbackState.value.errorCode, isNotNull);
await handler.stop();
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(
estado.errorCode,
isNull,
reason:
'stop() cleared errorMessage but omitted errorCode, so the idle '
'it publishes shipped a stale ERROR_CODE_PREMIUM_ACCOUNT_'
'REQUIRED (4) with no message to explain it',
);
expect(estado.errorMessage, isNull);
});
});
});
group('errorCode se limpia en un cambio de fuente — item 6', () {
test('un rechazo premium seguido de una emisora gratuita NO arrastra el '
'codigo de error', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:uuid-del-catalogo');
await pumpEventQueue();
expect(
handler.playbackState.value.errorCode,
isNotNull,
reason: 'precondicion: el rechazo publico un codigo',
);
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
expect(
handler.playbackState.value.errorCode,
isNull,
reason:
'`_cambiarFuente` cleared errorMessage but omitted errorCode, and '
'copyWith carries an omitted field forward, so a stale code rode '
'along indefinitely',
);
expect(handler.playbackState.value.errorMessage, isNull);
});
});
group('getChildren(recentRootId) — reanudacion del head unit', () {
test('con una ultima emisora persistida devuelve EXACTAMENTE un item '
'reproducible', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"uuid-ultima","nombre":"Ultima",'
'"url":"https://ultima.example/stream"}',
});
final handler = crearHandler();
final items = await handler.getChildren(AudioService.recentRootId);
expect(
items,
hasLength(1),
reason:
'onGetRoot (AudioService.java:817-821) answers "recent" whenever '
'the head unit sends EXTRA_RECENT, which Android Auto does on '
'reconnect, and the platform expects exactly one resume item — '
'free tier used to fall through and return all six stations',
);
expect(items.single.playable, isTrue);
expect(items.single.id, 'emisora:uuid-ultima');
});
test('sin ultima emisora devuelve lista vacia y no lanza', () async {
final handler = crearHandler();
expect(await handler.getChildren(AudioService.recentRootId), isEmpty);
});
test('premium tambien obtiene su tile de reanudacion, no una lista '
'vacia', () async {
SharedPreferences.setMockInitialValues({
'compra_premium_v1': true,
claveUltimaEmisora:
'{"uuid":"uuid-premium","nombre":"Premium",'
'"url":"https://premium.example/stream"}',
});
final handler = crearHandler();
final items = await handler.getChildren(AudioService.recentRootId);
expect(items, hasLength(1));
expect(items.single.id, 'emisora:uuid-premium');
});
});
group('la raiz gratuita esta LOCALIZADA — hallazgo 4', () {
test('en un motor headless en ingles la unica carpeta que un revisor de '
'Play ve NO sale en castellano', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
final raiz = await handler.getChildren(AudioService.browsableRootId);
expect(raiz, hasLength(1));
expect(
raiz.single.title,
lookupAppLocalizations(const Locale('en')).autoCarpetaEscuchar,
reason:
'raiz(premium: false) is 100% of the browse tree a Play reviewer '
'ever sees, on a device in any of the 13 shipped locales — the '
'hardcoded-Spanish car-label convention stops being defensible '
'once one label IS the whole free root',
);
expect(raiz.single.title, isNot('Escuchar'));
});
test('en castellano sigue diciendo Escuchar', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
final handler = crearHandler();
final raiz = await handler.getChildren(AudioService.browsableRootId);
expect(raiz.single.title, 'Escuchar');
});
});
group('botones de salto — item 14', () {
test('free: el salto CICLA dentro del set gratuito en vez de no hacer '
'nada', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
});
test('free: el salto hacia atrás envuelve al final del set', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
await handler.skipToPrevious();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
});
test('free: los botones se SIGUEN anunciando (un boton que funciona es '
'mejor UX que un hueco, y controls/systemActions se construyen en un '
'listener sincrono que no puede await-ear prefs)', () async {
final handler = crearHandler();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
// `controls`/`systemActions` are only rebuilt from a real player event,
// so the double has to emit one for this assertion to mean anything.
guion.ultimoReproductor!.emitir(
PlayerState(true, ProcessingState.ready),
);
await pumpEventQueue();
final estado = handler.playbackState.value;
expect(estado.systemActions, contains(MediaAction.skipToNext));
expect(estado.systemActions, contains(MediaAction.skipToPrevious));
});
});
}
/// Cuenta los round trips nativos que la raíz del árbol dispara.
class _FuenteMusicaLocalEspia implements FuenteMusicaLocalAuto {
int llamadasEstadoCarpeta = 0;
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async {
llamadasEstadoCarpeta++;
return EstadoCarpetaLocal.configurada;
}
@override
Future<List<NodoLocal>> hijos(String documentId) async => const [];
@override
Future<String?> uriContenidoDePista(String documentId) async => null;
@override
Future<Map<String, MetadatosPista>> metadatosDe(
List<String> documentIds,
) async => const {};
}
/// Misma forma que el doble de `servicio_audio_transporte_test.dart`.
class _GuionReproductor {
int llamadasPlay = 0;
final urlsSolicitadas = <String>[];
/// The handler rebuilds its player on every source change, so a test that
/// needs to drive player events has to reach the LATEST instance.
_ReproductorFalso? ultimoReproductor;
}
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
void emitir(PlayerState estado) => _estados.add(estado);
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.urlsSolicitadas.add(url);
return null;
}
@override
Future<void> play() async {
_guion.llamadasPlay++;
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -1,618 +0,0 @@
import 'dart:async';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Contexto de reproducción en el HANDLER — «al conectar el coche, siguiente/
/// anterior ya no recuerdan en qué lista estaba».
///
/// El handler es el único que existe en los dos motores (el del móvil y el
/// headless que levanta Android Auto sin Activity), así que es él quien tiene
/// que escribir y leer el contexto. Lo hace por un PUERTO inyectado en
/// `registrarHandler`, exactamente igual que el flag on/off del ecualizador:
/// `servicio_audio.dart` no importa `shared_preferences` ni conoce
/// `EstadoRadio`, que en el coche no llega a construirse nunca.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
late _PuertoContexto puerto;
setUp(() {
guion = _GuionReproductor();
puerto = _PuertoContexto();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
// `_fuenteNavegacionGlobal` es global de módulo: se deja siempre en una
// fuente vacía para que un test no herede las listas del anterior.
registrarFuenteNavegacion(_FuenteFalsa());
});
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
Emisora favorita(String uuid, String grupo) => Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
final rock1 = favorita('rock1', 'g-rock');
final rock2 = favorita('rock2', 'g-rock');
final rock3 = favorita('rock3', 'g-rock');
final jazz1 = favorita('jazz1', 'g-jazz');
final jazz2 = favorita('jazz2', 'g-jazz');
const gruposVivos = [
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
];
PluriWaveAudioHandler handlerConPuerto() {
final handler = crearHandler();
registrarHandler(
handler,
leerContextoSalto: puerto.leer,
guardarContextoSalto: puerto.guardar,
);
return handler;
}
Future<void> premium() async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
}
group('escritura — el contexto se fija en TODO camino que arranca una '
'emisora, no solo en el del árbol del coche', () {
setUp(premium);
test('playFromMediaId (toque en el árbol del coche)', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:rock1');
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
});
test('playFromSearch (voz)', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromSearch('jazz1');
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.favoritos());
});
test('playMediaItem (el camino del móvil: ServicioAudio.reproducir)',
() async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock2, l10n: textos()));
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
});
test('una PISTA LOCAL no escribe contexto de emisora: su cola es otra '
'cosa y pisarlo dejaría la radio recorriendo una lista ajena',
() async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(
const MediaItem(
id: 'content://media/documents/pista-1',
title: 'Pista local',
),
);
await pumpEventQueue();
expect(puerto.guardados, isEmpty);
});
});
group('lectura — el contexto recordado sobrevive al reinicio del proceso',
() {
setUp(premium);
test('la fuente aún no está registrada cuando arranca la emisora (carrera '
'real del bind en frío): el salto usa el contexto de la sesión '
'anterior en vez de morir', () async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
// Bind en frío: `main.dart` todavía no ha llamado a
// registrarFuenteNavegacion cuando el coche pide reproducir.
registrarFuenteNavegacion(_FuenteFalsa());
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
// La fuente llega después, ya con las listas cargadas.
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
expect(puerto.lecturas, greaterThan(0));
});
test('el contexto recordado MANDA sobre la rederivación: el registro de '
'favoritos perdió el grupo, y aun así se recorre el grupo', () async {
// Escenario real: el snapshot que empuja el móvil llega antes de que
// los grupos estén cargados, así que `favoritos()` reporta
// «sin asignar» un rato. Sin memoria, el salto se ensancha a todos los
// favoritos justo en mitad del trayecto.
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
});
});
group('degradación del contexto recordado', () {
setUp(premium);
test('el grupo recordado fue BORRADO -> se recorren todos los favoritos',
() async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [
favorita('rock1', GrupoFavoritos.sinAsignarId),
jazz1,
jazz2,
],
grupos: const [
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, jazz1.url);
});
test('el grupo SIGUE VIVO pero la emisora se salió de él -> se PERMANECE '
'en el grupo y suena su PRIMERA emisora', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2, jazz1], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
guion.urlsSolicitadas.clear();
// El móvil saca rock1 del grupo a media marcha: g-rock sigue existiendo
// y sigue teniendo emisoras, solo que ya no la que suena.
registrarFuenteNavegacion(
_FuenteFalsa(
// jazz1 va DELANTE de rock2 a propósito: si el contexto se
// ensanchara a todos los favoritos, «siguiente» sonaría jazz1.
favoritos: [
favorita('rock1', GrupoFavoritos.sinAsignarId),
jazz1,
rock2,
rock3,
],
grupos: gruposVivos,
),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
expect(
puerto.persistido,
const ContextoSalto.grupo('g-rock'),
reason: 'el grupo sobrevive, así que el contexto no se rederiva',
);
});
test('el grupo sigue vivo con UNA sola emisora -> el salto NO se ensancha '
'a todos los favoritos: se queda donde está', () async {
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [rock1, rock2, jazz1, jazz2],
grupos: gruposVivos,
),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
// rock2 sale del grupo: g-rock se queda solo con la emisora que suena.
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [
rock1,
favorita('rock2', GrupoFavoritos.sinAsignarId),
jazz1,
jazz2,
],
grupos: gruposVivos,
),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas, isEmpty);
expect(puerto.persistido, const ContextoSalto.grupo('g-rock'));
});
test('el grupo fue BORRADO y la emisora ya NO es favorita -> suena la '
'PRIMERA de los favoritos que queden', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
registrarFuenteNavegacion(
_FuenteFalsa(
favoritos: [jazz1, jazz2],
grupos: const [
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
],
),
);
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, jazz1.url);
});
test('no queda ningún favorito y la emisora no está en ninguna lista -> '
'no-op silencioso, nunca un salto arbitrario', () async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(_FuenteFalsa(grupos: gruposVivos));
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas, isEmpty);
});
});
group('tier gratuito', () {
test('la lista de salto queda CONGELADA en el orden curado: que cambie '
'`ultima_emisora_v1` a media marcha no reordena el recorrido, así '
'que «anterior» vuelve a deshacer «siguiente»', () async {
// `resolverEmisorasDestacadas` se recompone como
// `[última reproducida, ...curadas]`, así que la lista SE REORDENA SOLA
// según suena cada emisora. Recorrerla en vivo hacía que «anterior»
// dejara de ser el inverso de «siguiente» a mitad de trayecto.
final kexp = emisorasDestacadas[3];
final siguienteCurada = emisorasDestacadas[4];
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"${kexp.uuid}","nombre":"${kexp.nombre}",'
'"url":"${kexp.url}"}',
});
final handler = handlerConPuerto();
// Fija la divergencia: la lista VIVA con `ultima` = KEXP empieza por
// KEXP, así que recorrerla daría la primera curada. Sin esta línea el
// test de abajo pasaría también con el comportamiento antiguo.
final enVivo = await resolverEmisorasDestacadas();
expect(
emisoraVecina(kexp, enVivo, haciaAtras: false)?.url,
emisorasDestacadas.first.url,
);
await handler.playFromMediaId('emisora:${kexp.uuid}');
await pumpEventQueue();
await handler.skipToNext();
await pumpEventQueue();
expect(
guion.urlsSolicitadas.last,
siguienteCurada.url,
reason:
'en vivo la lista sería [KEXP, ...resto], y «siguiente» se iría a '
'la primera curada en vez de a la que va detrás de KEXP',
);
// El móvil (vivo) persiste la emisora que acaba de sonar.
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
claveUltimaEmisora,
'{"uuid":"${siguienteCurada.uuid}",'
'"nombre":"${siguienteCurada.nombre}",'
'"url":"${siguienteCurada.url}"}',
);
await handler.skipToPrevious();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, kexp.url);
});
test('una emisora ajena al set curado se ANTEPONE al recorrido congelado, '
'para que ningún botón quede muerto', () async {
SharedPreferences.setMockInitialValues({
claveUltimaEmisora:
'{"uuid":"ajena","nombre":"Ajena",'
'"url":"https://example.com/ajena"}',
});
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:ajena');
await pumpEventQueue();
expect(
puerto.guardados.last,
ContextoSalto.destacadas([
'ajena',
...emisorasDestacadas.map((e) => e.uuid),
]),
);
await handler.skipToPrevious();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
});
test('un contexto PREMIUM recordado NO se camina en tier gratuito: una '
'cuenta degradada no sigue recorriendo el catálogo', () async {
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
expect(
puerto.guardados.last.tipo,
TipoContextoSalto.destacadas,
reason: 'el contexto de un usuario free es SIEMPRE el set gratuito',
);
});
test('free: aunque el grupo de favoritos siga VIVO, el recorrido no entra '
'en él tampoco en el segundo salto', () async {
// La nueva caída «grupo vivo -> su primera emisora» no puede convertirse
// en una puerta trasera al contenido de pago.
puerto.persistido = const ContextoSalto.grupo('g-rock');
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, rock2, rock3], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas, [
emisorasDestacadas[1].url,
emisorasDestacadas[2].url,
]);
});
});
group('robustez del puerto', () {
setUp(premium);
test('sin puerto registrado el salto sigue funcionando por rederivación '
'(un test de widgets, o el arranque antes de main.dart)', () async {
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = crearHandler();
registrarHandler(handler);
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
});
test('un puerto que revienta degrada a rederivación en vez de dejar al '
'conductor sin botones', () async {
puerto.explota = true;
registrarFuenteNavegacion(
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
);
final handler = handlerConPuerto();
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
await pumpEventQueue();
guion.urlsSolicitadas.clear();
await handler.skipToNext();
await pumpEventQueue();
expect(guion.urlsSolicitadas.last, rock2.url);
});
});
}
/// Doble del puerto de persistencia que `main.dart` ata a
/// `contexto_reproduccion.dart`.
class _PuertoContexto {
ContextoSalto? persistido;
final guardados = <ContextoSalto>[];
int lecturas = 0;
bool explota = false;
Future<ContextoSalto?> leer() async {
lecturas++;
if (explota) throw StateError('disco ilegible');
return persistido;
}
Future<void> guardar(ContextoSalto contexto) async {
if (explota) throw StateError('disco de solo lectura');
guardados.add(contexto);
persistido = contexto;
}
}
class _FuenteFalsa implements FuenteEmisorasAuto {
_FuenteFalsa({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) : _favoritos = favoritos ?? const [],
_misEmisoras = misEmisoras ?? const [],
_todas = todas ?? const [],
_grupos = grupos ?? const [];
final List<Emisora> _favoritos;
final List<Emisora> _misEmisoras;
final List<Emisora> _todas;
final List<GrupoFavoritos> _grupos;
@override
Future<List<Emisora>> favoritos() async => _favoritos;
@override
Future<List<Emisora>> misEmisoras() async => _misEmisoras;
@override
Future<List<Emisora>> todas() async => _todas;
@override
Future<List<GrupoFavoritos>> grupos() async => _grupos;
@override
Future<Emisora?> porUuid(String uuid) async {
for (final lista in [_favoritos, _misEmisoras, _todas]) {
for (final e in lista) {
if (e.uuid == uuid) return e;
}
}
for (final e in await resolverEmisorasDestacadas()) {
if (e.uuid == uuid) return e;
}
return null;
}
@override
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {}
}
/// Misma forma que el doble de `servicio_audio_auto_free_test.dart`.
class _GuionReproductor {
final urlsSolicitadas = <String>[];
_ReproductorFalso? ultimoReproductor;
}
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.urlsSolicitadas.add(url);
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -1,374 +0,0 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
///
/// Reported by the owner: the toggle behaves correctly from the phone screen
/// but from the car it «sometimes sounds like a doubled equalization and
/// sometimes does nothing».
///
/// Three independent causes, one per group below:
///
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
/// `eq-estado-unico` item A); the preset never did. On a headless Android
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
/// to push the real preset — enabling the equalizer from the car applied
/// FLAT.
///
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
/// the preset's gains, so the native effect was re-activated carrying
/// whatever band levels the previous preset had left in it and only
/// afterwards were the intended ones written, band by band. That audible
/// gap is the «doubled equalization».
///
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
/// change and never restored it until the fresh player attached. Every
/// native EQ path is gated on that flag, so a car toggle landing inside
/// the window flipped the icon and the flag but never touched the audio —
/// the «does nothing» — and the EQ button itself vanished from the car's
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
/// `const []` when unavailable) and came back seconds later.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductorEq guion;
setUp(() {
guion = _GuionReproductorEq();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
});
group('A — the preset is seeded from disk on a headless engine', () {
test('registrarHandler consults the injected preset port exactly once '
'and seeds the handler with it, with no widget tree', () async {
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
handler,
leerPresetPersistido: () async {
lecturas++;
return PresetEcualizador.jazz;
},
);
await pumpEventQueue();
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.presetActual,
PresetEcualizador.jazz,
reason:
'from the car the handler is the ONLY owner of the preset — '
'nothing else ever pushes one on a headless engine',
);
});
test('a read failure leaves the historical default instead of '
'propagating', () async {
final handler = crearHandler();
registrarHandler(
handler,
leerPresetPersistido: () async => throw StateError('sin disco'),
);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.flat);
});
test('without a preset port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
await handler.aplicarPreset(PresetEcualizador.rock);
registrarHandler(handler);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.rock);
});
test('a preset already chosen while the disk read was in flight WINS — '
'seeding never clobbers a live choice', () async {
final handler = crearHandler();
final lectura = Completer<PresetEcualizador?>();
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
// pushes it while the seed's disk read is still pending.
await handler.aplicarPreset(PresetEcualizador.pop);
lectura.complete(PresetEcualizador.jazz);
await pumpEventQueue();
expect(
handler.presetActual,
PresetEcualizador.pop,
reason:
'the seed exists to fill a VOID, not to overrule the richer '
'per-station/per-device preset the phone UI resolves',
);
});
});
group('B — the preset is pushed BEFORE the effect is enabled', () {
test('enabling applies the gains first and only then flips the native '
'effect on', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: true),
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'enabling first would re-activate the native Equalizer carrying '
'the PREVIOUS preset gains, which is the doubled equalization '
'the owner hears',
);
});
test('disabling only flips the effect off — the band gains are NOT '
'reset', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: false),
[PasoEcualizador.habilitacion],
reason:
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
'the effect and RETAINS its band levels, and the enable path '
'rewrites them before re-enabling anyway — zeroing them would be '
'one native round trip per band for no audible difference',
);
});
test('the real toggle path executes those steps IN THAT ORDER', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.pasosEcualizadorEjecutados,
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'the ORDER is the fix; asserting only that both happened would '
'stay green against the exact bug being fixed',
);
});
test('the real disable path executes only the habilitacion step', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(false);
expect(handler.pasosEcualizadorEjecutados, [
PasoEcualizador.habilitacion,
]);
});
});
group('C — a station change no longer drops the equalizer', () {
test('once the EQ was available, no state published across a station '
'change and a car toggle has zero custom actions', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
// The EQ action is on the car's now-playing screen before the station
// changes — that is the state the driver is looking at.
await handler.setEcualizadorActivo(true);
final acciones = <int>[];
final sub = handler.playbackState.listen(
(estado) => acciones.add(
estado.controls.where((c) => c.customAction != null).length,
),
);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
// The car tap that used to land inside the window `_recrearPlayer`
// opened. It republishes the controls from `_eqDisponible`, so a flag
// reset to `false` shows up here as an EQ button that disappeared.
await handler.customAction(accionEqToggle);
await sub.cancel();
expect(
acciones,
isNotEmpty,
reason: 'the station change must publish at least one state',
);
expect(
acciones.every((n) => n > 0),
isTrue,
reason:
'the EQ button vanished and reappeared on every station change '
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
'a DEVICE property and does not change with the station. Got '
'$acciones',
);
});
test('the availability flag survives the player rebuild, so a car toggle '
'inside the window still reaches the native effect', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
expect(
handler.ecualizadorDisponible,
isTrue,
reason:
'this is the flag every native EQ path is gated on; false here '
'is exactly the reported «does nothing»',
);
});
});
group('D — a failed native call is traced and never lies', () {
test('a throwing setEnabled is traced instead of swallowed', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.fallosNativosEcualizador,
greaterThan(0),
reason:
'the silent `catch (_) {}` made a dead native equalizer '
'indistinguishable from a working one in a car logcat',
);
});
test('a failed on/off call leaves the published state honest instead of '
'claiming a state the audio does not have', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
handler.ecualizadorActivo,
isFalse,
reason:
'the native effect refused, so the car icon must not read "on" '
'over audio that is not equalized',
);
});
test('a failed on/off call is not persisted', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
isEmpty,
reason:
'persisting a state the device rejected would resurrect it on '
'the next engine start',
);
});
});
}
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
/// device whose native `Equalizer` effect refuses the call. Nothing else is
/// overridden, so the rest of the handler runs unchanged.
class _EcualizadorQueFalla extends AndroidEqualizer {
@override
Future<void> setEnabled(bool enabled) async {
throw StateError('el efecto nativo rechazo la llamada');
}
}
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
/// the handler builds (it rebuilds its player on every source change).
class _GuionReproductorEq {
int llamadasSetUrl = 0;
_ReproductorFalsoEq? ultimoReproductor;
}
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
/// station change can be driven under `flutter test`. Mirrors the double in
/// `servicio_audio_transporte_test.dart`.
class _ReproductorFalsoEq extends AudioPlayer {
_ReproductorFalsoEq(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductorEq _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -2,8 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState;
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-estado-unico — the equalizer's on/off flag gets a SINGLE owner.
///
/// Reported bug: «alguna emisora parece que esta con la ecualizacion activada
@@ -30,8 +28,6 @@ import '../helpers/handlers_audio.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
group('estadoEqInicial (A — seed the handler from disk on every engine)', () {
test('adopts the persisted value when there is one', () {
expect(estadoEqInicial(persistido: false), isFalse);
@@ -50,7 +46,7 @@ void main() {
group('registrarHandler (A — seeding)', () {
test('consults the injected read port exactly once and seeds the handler '
'with the persisted value', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
var lecturas = 0;
registrarHandler(
@@ -72,7 +68,7 @@ void main() {
test('a read failure leaves the handler on the safe default instead of '
'propagating', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(
handler,
@@ -85,7 +81,7 @@ void main() {
test('without a read port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
await handler.setEcualizadorActivo(false);
registrarHandler(handler);
@@ -110,7 +106,7 @@ void main() {
'starts from the persisted value, not from a hardcoded default',
() async {
// One engine does the read `registrarHandler` performs in main.dart.
final primero = crearHandler();
final primero = PluriWaveAudioHandler();
// Pin the module cache to the OPPOSITE value first. Without this the
// test passes for the wrong reason: whatever ran before may already
@@ -127,7 +123,7 @@ void main() {
// Now the construction window: a handler built by `AudioService.init`'s
// builder, with no port of its own yet.
final segundo = crearHandler();
final segundo = PluriWaveAudioHandler();
expect(
segundo.ecualizadorActivo,
@@ -140,12 +136,12 @@ void main() {
test('the cache follows what the handler itself writes, in both '
'directions', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(false);
expect(
crearHandler().ecualizadorActivo,
PluriWaveAudioHandler().ecualizadorActivo,
isFalse,
reason:
'the write side of the cache: a toggle must be visible to the '
@@ -153,14 +149,14 @@ void main() {
);
await handler.setEcualizadorActivo(true);
expect(crearHandler().ecualizadorActivo, isTrue);
expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue);
});
});
group('B — the handler persists its OWN toggle', () {
test('an eq toggle writes through the injected port even with no '
'EstadoEcualizador in play', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
final escrituras = <bool>[];
registrarHandler(
@@ -181,7 +177,7 @@ void main() {
});
test('seeding from disk does NOT write back to disk', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
final escrituras = <bool>[];
registrarHandler(
@@ -196,7 +192,7 @@ void main() {
});
test('a failing write port never breaks the toggle', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(
handler,
@@ -252,7 +248,7 @@ void main() {
group('customAction dispatch (C — zero coverage before this)', () {
test('the accionEqToggle literal routes through decidirToggleEq', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(true);
@@ -265,7 +261,7 @@ void main() {
test('a car toggle persists through the same write port as a phone '
'toggle', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
@@ -286,7 +282,7 @@ void main() {
});
test('an unknown custom action is a silent no-op', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
final antes = handler.ecualizadorActivo;
@@ -384,7 +380,7 @@ void main() {
test('the first non-idle event re-asserts the native effect exactly '
'once, and staying active never re-asserts again', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
@@ -414,7 +410,7 @@ void main() {
test('going idle re-arms the edge, so stop + play re-asserts again — '
'this is the `_reproductorActivo = proc != idle` line', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
@@ -439,7 +435,7 @@ void main() {
});
test('with no native effect attached nothing is ever re-asserted', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
// `_eqDisponible` is false off-device, which is also the real
// "device has no Equalizer effect" case.
@@ -455,7 +451,7 @@ void main() {
group('F — the EQ re-push must not rewind the car progress bar', () {
test('the EQ controls re-push refreshes updatePosition from the '
'player', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
handler.playbackState.add(
handler.playbackState.value.copyWith(
@@ -1,43 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// `mapearGananciaNativa` the hand-off from the app's ±12 dB slider to the
/// device's native `Equalizer`, whose capability is reported as
/// `AndroidEqualizerParameters.min/maxDecibels`
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
/// app's fixed ±12 dB slider scale to whatever range the device's native
/// `Equalizer.getBandLevelRange()` reports.
///
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
/// slider against its own end of the native range, so `+6` on a device
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
/// are already the SAME unit, so that multiplication was a unit error:
/// This is the only source-plausible explanation for the reported «suena muy
/// alto» half of the bug. The original implementation normalised the input
/// across the WHOLE range and mapped it linearly:
///
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
/// decibels", and its Android bridge does `setBandLevel(band,
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
/// bound on the control, not a scale to normalise into.
/// * The app makes the user a decibel promise in three places at once: the
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
/// a lie on every device whose range is not exactly ±12.
/// * Presets are persisted and EXPORTED as those same raw slider dB
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
/// restored on a wider-range phone showed identical numbers and played
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
/// rather than merely its depth.
/// normalizado = (db.clamp(-12, 12) + 12) / 24
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
///
/// So: the number the user reads is the number the device is asked for. The
/// native range only CLAMPS it.
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
/// the range happens to be symmetric. Android does not guarantee that: the
/// AudioEffect Equalizer contract only requires a min/max pair, and real
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
/// band 0 dB — was silently pushing a positive boost into every band, which
/// is audibly louder while the on/off button still reads "off".
///
/// What this deliberately KEEPS from the previous contract — every invariant
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
/// positive reported range, turning a FLAT preset into a boost again), the
/// sign of the user's intent is never inverted, the result never escapes the
/// native range, a device with no headroom above unity can never boost, and a
/// zero-width range collapses to 0.
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
/// sides of the scale are stretched INDEPENDENTLY against their own end of
/// the native range, so the sign of the user's intent is never inverted and
/// the extremes still reach the device's real limits.
void main() {
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
test('symmetric range (the common case) is unchanged', () {
@@ -58,10 +43,6 @@ void main() {
});
test('a wholly positive range still cannot boost a FLAT preset', () {
// This is precisely why the mapping cannot be a plain
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
// bring the «suena muy alto» bug straight back. The clamp window has
// to be widened so that it always contains 0.
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
});
@@ -70,61 +51,36 @@ void main() {
});
});
group('mapearGananciaNativa — the slider dB reach the device literally', () {
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the
// device's headroom. The slider says "12.0dB" and the accessibility
// label says "12.0 decibels", so 12 dB is what the device must be
// asked for. The 7 dB of extra hardware headroom is unreachable by
// design until the slider itself is widened and says so.
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
group('mapearGananciaNativa — the extremes reach the native limits', () {
test('+12 dB maps to the native maximum', () {
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
});
test('-12 dB is delivered as -12 dB', () {
test('-12 dB maps to the native minimum', () {
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
});
test('values beyond the slider scale clamp to the slider limit', () {
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
// The slider scale is the first bound; the device range is the second.
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
test('values beyond the slider scale are clamped, not extrapolated', () {
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
});
});
group('mapearGananciaNativa — the label is the value the device gets', () {
test('+6 dB on a wide-range device is +6 dB, never 10', () {
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
// half of the positive headroom". A slider reading "6.0dB" that
// produced +10 dB of real boost is exactly what made a restored backup
// sound different on a different phone.
group('mapearGananciaNativa — each side scales against its own end', () {
test('half boost is half of the positive headroom', () {
expect(
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
closeTo(6, 1e-9),
closeTo(10, 1e-9),
);
});
test('-6 dB on that same device is -6 dB', () {
test('half cut is half of the negative headroom', () {
expect(
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
closeTo(-6, 1e-9),
);
});
test('the six factory presets keep their shape on an asymmetric device', () {
// Jazz, authored in true dB before any scaling existed. Under the old
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
// different tonal curve, not merely a louder one.
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
final entregado = jazz
.map(
(db) =>
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
)
.toList();
expect(entregado, jazz);
});
test('the sign of the user intent is never inverted', () {
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
final nativo = mapearGananciaNativa(
@@ -141,44 +97,12 @@ void main() {
});
});
group('mapearGananciaNativa — a device narrower than the slider', () {
test('a request that fits is still delivered literally', () {
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
// so a modest device silently under-delivered every request too.
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
});
test('a request beyond the device range clamps to the device limit', () {
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
});
test('a very narrow device still gets a sane, in-range value', () {
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -1.5,
maxDecibels: 1.5,
);
expect(nativo, greaterThanOrEqualTo(-1.5));
expect(nativo, lessThanOrEqualTo(1.5));
expect(nativo.sign, db.sign);
}
});
});
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
test('a device with no headroom above unity can never boost', () {
test('a range with no headroom on one side clamps that side to 0', () {
// A device that reports max == 0 can only cut. Asking for a boost must
// resolve to "no change", never to a negative value.
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0);
});
test('a cut the device could honour exactly is not over-delivered', () {
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
// range on a request for -12 dB. The user asked for -12; -12 is
// representable here, so -12 is what is sent.
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
});
test('a zero-width range collapses everything to 0', () {
+8 -28
View File
@@ -6,8 +6,6 @@ import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Android Auto play-path backstop (design.md ADR-4, android-auto-media
/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station
/// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`,
@@ -22,30 +20,12 @@ import '../helpers/handlers_audio.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
// fix/auto-quality-guidelines item 11: the gate is CONTENT-scoped now.
// Blocking every switch for the free tier is what made the car surface
// useless for the only tier a Play reviewer can be in.
test('free tier: bloquea una emisora del catálogo premium', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
isTrue,
);
});
test('free tier: NO bloquea una emisora del set gratuito', () {
expect(
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
isFalse,
);
test('free tier: bloquea cualquier cambio de emisora/salto', () {
expect(debeBloquearCambioDeEmisora(premium: false), isTrue);
});
test('premium: nunca bloquea', () {
expect(
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
isFalse,
);
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
});
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
@@ -71,7 +51,7 @@ void main() {
test('registrarHandler conecta la invalidación al handler: una llamada '
'notifica la raíz Y Música Local', () async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
final raiz = <Map<String, dynamic>>[];
@@ -106,13 +86,13 @@ void main() {
group('subscribeToChildren', () {
test('el sujeto arranca SIN valor: nada que reenviar en la primera '
'suscripción, así que no hay notifyChildrenChanged espurio', () {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
});
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
expect(
identical(
@@ -132,7 +112,7 @@ void main() {
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
() async {
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
final stream = handler.subscribeToChildren('musica_local');
final recibidos = <Map<String, dynamic>>[];
final sub = stream.listen(recibidos.add);
@@ -163,7 +143,7 @@ void main() {
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
final handler = crearHandler();
final handler = PluriWaveAudioHandler();
final items = await handler.getChildren(AudioService.browsableRootId);
return items.map((i) => i.id).toList();
}
@@ -1,736 +0,0 @@
import 'dart:async';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// Android for Cars App Quality Guidelines — transport state machine.
///
/// Google Play returned "Approved with Issues" against version code 157:
/// «clicking on stop button makes the entire app useless». These tests drive
/// the REAL [PluriWaveAudioHandler] against a scripted [AudioPlayer] double
/// (installed through [PluriWaveAudioHandler.fabricaReproductorPrueba]) so
/// the published `playbackState` sequence — the only thing Android Auto ever
/// sees — can be asserted end to end.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('stop() durante cambios de fuente en vuelo (P0 — botón Stop)', () {
test(
'dos cambios encolados y un stop: el ultimo estado publicado es idle, '
'nunca vuelve a loading',
() async {
final handler = crearHandler();
final publicados = <AudioProcessingState>[];
final sub = handler.playbackState.listen(
(estado) => publicados.add(estado.processingState),
);
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://b', title: 'B'))
.catchError((_) {}),
);
await handler.stop();
// Drain both queued source changes: they must discover the stale
// revision WITHOUT ever publishing again.
await pumpEventQueue();
await sub.cancel();
expect(
publicados.last,
AudioProcessingState.idle,
reason:
'a stale queued source change must never rewrite `loading` over '
'the `idle` that stop() published — that is what leaves Android '
'Auto spinning forever on a dead session. Secuencia: $publicados',
);
},
);
});
group('pause() durante un cambio de fuente en vuelo (P0 — botón Pausa)', () {
test('la emisora NO arranca: _player.play() nunca se invoca', () async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
expect(
guion.llamadasSetUrl,
1,
reason: 'precondicion: el cambio de fuente esta en vuelo',
);
await handler.pause();
guion.completerSetUrl!.complete(null);
await pumpEventQueue();
expect(
guion.llamadasPlay,
0,
reason:
'the user pressed Pause while the station was loading — the load '
'finishing afterwards must never start playback behind their back',
);
});
test(
'y el coche no se queda en el spinner: el estado publicado sale de '
'loading',
() async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
await handler.pause();
guion.completerSetUrl!.complete(null);
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
isNot(AudioProcessingState.loading),
reason:
'withholding the play() must not leave the car showing the '
'spinner the load started with — nothing else will publish, '
'because the player never transitions',
);
expect(handler.playbackState.value.playing, isFalse);
},
);
});
group('Suelo de estado terminal (P0 — nunca un spinner eterno)', () {
setUp(() {
PluriWaveAudioHandler.vigilanciaTransitoria = const Duration(
milliseconds: 60,
);
});
tearDown(() {
PluriWaveAudioHandler.vigilanciaTransitoria =
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto;
});
test(
'un buffering publicado SIN carga viva cae a un estado terminal dentro '
'de la ventana',
() async {
final handler = crearHandler();
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.buffering),
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'precondicion: el coche esta viendo el spinner',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
handler.playbackState.value.processingState,
isIn(const [
AudioProcessingState.ready,
AudioProcessingState.idle,
AudioProcessingState.error,
]),
reason:
'the only exits from loading/buffering are player events that '
'.distinct() can swallow — without a floor the car spins '
'forever over a session nobody is driving',
);
},
);
test('una carga LEGITIMA en vuelo no se interrumpe', () async {
guion.completerSetUrl = Completer<Duration?>();
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {}),
);
await pumpEventQueue();
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
handler.playbackState.value.processingState,
AudioProcessingState.loading,
reason:
'the watchdog is a floor for a STALLED state machine, not a cap '
'on how long a slow station may take to open',
);
});
test(
'un mount estancado NO cae en un idle mudo: publica un motivo legible '
'(hallazgo 3)',
() async {
final handler = crearHandler();
// Exact shape of a stalled icecast mount: the socket opens, `setUrl`
// returns inside the timeout (so no TimeoutException and no
// PlayerException — `_esErrorDeRed` never fires and the reconnect
// machine is never entered), and then no data ever arrives. State
// sits at buffering with `_cambiosEnVuelo` already back to 0.
handler.manejarEstadoPlayer(
PlayerState(false, ProcessingState.buffering),
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'a bare `idle` routes straight into `AudioService._stop()` '
'(audio_service.dart:1131-1135), so the driver got silence, a '
'dead session and no explanation. `error` keeps the session '
'alive and carries a message',
);
expect(
estado.errorMessage,
isNotNull,
reason:
'the floor must say something the driver can read and act on',
);
},
);
test(
'un rebuffer normal a mitad de emision NO se convierte en error '
'(regresion: el suelo miraba solo processingState)',
() async {
final handler = crearHandler();
// Established playback: the stream delivered audio and ExoPlayer
// reached `ready` while playing. This is what separates a re-buffer
// from a mount that never produced a byte.
handler.manejarEstadoPlayer(PlayerState(true, ProcessingState.ready));
// Ordinary mid-stream re-buffer: `bufferForPlaybackAfterRebuffer` is
// 5 s, so a tunnel or an LTE handover routinely holds this state for
// longer than the floor's window. ExoPlayer raised no error, so
// `_intentarReconexion` never ran and `reintentoPendiente` is false;
// `_cambiosEnVuelo` is already 0 because the non-blocking
// `_iniciarPlaySinBloquear` returned long ago.
handler.manejarEstadoPlayer(
PlayerState(true, ProcessingState.buffering),
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'precondicion: el reproductor esta rellenando el buffer',
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
isNot(AudioProcessingState.error),
reason:
'a self-recovering re-buffer over live audio must never be '
'converted into a hard STATE_ERROR: the driver is in a tunnel, '
'not on a dead mount, and `_errorTerminal` latches so nothing '
'the player emits afterwards could undo it',
);
expect(
estado.playing,
isTrue,
reason:
'the player still owns the timeline — publishing `playing: '
'false` over it desynchronises the head unit transport row',
);
expect(
estado.errorMessage,
isNull,
reason: 'nothing failed, so there is nothing to tell the driver',
);
},
);
test(
'un mount que NUNCA entrego audio sigue cayendo al suelo aunque el '
'reproductor diga playing: true',
() async {
final handler = crearHandler();
// `just_audio`'s `playing` is the play-when-ready intent flag: it
// flips to true the moment `play()` is called, whether or not a
// single byte ever arrives. So the stalled icecast mount the floor
// exists for reports `playing: true` too — `playing` alone can never
// be the discriminator.
handler.manejarEstadoPlayer(
PlayerState(true, ProcessingState.buffering),
);
await Future<void>.delayed(const Duration(milliseconds: 250));
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'no `ready` was ever reached on this run, so nothing is '
're-buffering: the driver is staring at a spinner and the floor '
'is the only exit',
);
expect(estado.errorMessage, isNotNull);
},
);
test(
'la ventana del suelo respeta el presupuesto de diez segundos hasta el '
'primer mensaje',
() {
expect(
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto,
lessThanOrEqualTo(const Duration(seconds: 10)),
reason:
'the floor is the ONLY exit for a stalled mount, so its window '
'IS the time-to-first-message for that failure mode; twenty '
"seconds was double the code's own cited budget",
);
},
);
});
group('Error terminal de reproduccion: la sesion sobrevive (hallazgo 2)', () {
test(
'el ultimo estado publicado es error CON mensaje, y no lo sigue un idle',
() async {
// A non-network failure: `_esErrorDeRed` is false, so this goes
// straight down the terminal path instead of the reconnect machine.
guion.errorSetUrl = Exception('mount muerto');
final handler = crearHandler();
final publicados = <AudioProcessingState>[];
final sub = handler.playbackState.listen(
(estado) => publicados.add(estado.processingState),
);
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
// What `_player.stop()` really does: just_audio.dart:1016-1025
// switches to the idle dummy platform, so `playerStateStream` emits a
// distinct (playing:false, idle). The double cannot do that on its
// own, so the test drives the exact event the real player would.
guion.ultimoReproductor!.emitir(
PlayerState(false, ProcessingState.idle),
);
await pumpEventQueue();
await sub.cancel();
final estado = handler.playbackState.value;
expect(
estado.processingState,
AudioProcessingState.error,
reason:
'forwarding that idle makes audio_service call '
'AudioService._stop() -> deactivateMediaSession() + stopSelf(), '
'so PluriWave dropped off the Android Auto playback surface a '
'single event-loop turn after showing the error',
);
expect(estado.errorMessage, isNotNull);
expect(
publicados.last,
isNot(AudioProcessingState.idle),
reason: 'secuencia publicada: $publicados',
);
expect(
handler.mediaItem.value,
isNotNull,
reason:
'Android Auto drops a session with no metadata to show, so '
'nulling the media item on the error path makes the app vanish '
'from the car pane even when the state itself survives — the '
'station that failed has to keep its name on screen',
);
},
);
test(
'un stop() del usuario DESPUES del error sigue produciendo un idle real '
'(la sesion tiene que poder morir cuando el conductor lo pide)',
() async {
guion.errorSetUrl = Exception('mount muerto');
final handler = crearHandler();
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
reason: 'precondicion',
);
await handler.stop();
await pumpEventQueue();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.idle,
reason:
'suppressing the error-driven idle must NEVER make the Stop '
'button unkillable — that is the original citation',
);
},
);
});
group('Presupuesto de tiempo hasta el primer mensaje (<= 10 s)', () {
tearDown(() {
PluriWaveAudioHandler.timeoutCambioFuente =
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
});
test('el timeout por defecto deja el primer mensaje dentro de 10 s', () {
expect(
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto,
lessThanOrEqualTo(const Duration(seconds: 10)),
reason:
'Android for Cars App Quality Guidelines allow ten seconds before '
'the driver must be told something; the first attempt alone used '
'to burn twelve',
);
});
test(
'una fuente que nunca responde publica un mensaje visible al agotar el '
'primer intento',
() async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 100,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
await Future<void>.delayed(const Duration(milliseconds: 300));
expect(
handler.playbackState.value.errorMessage,
isNotNull,
reason:
'the backoff used to publish `buffering` with errorMessage: '
'null, so the car showed a silent spinner for the whole ~100 s '
'reconnect window',
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.buffering,
reason: 'still retrying — the message rides ON TOP of the retry',
);
},
);
test('los reintentos siguen DETRAS del mensaje', () async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 100,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
// First backoff delay is 1 s (ControladorReconexion default).
await Future<void>.delayed(const Duration(milliseconds: 1300));
expect(
guion.llamadasSetUrl,
greaterThanOrEqualTo(2),
reason: 'the reconnect machine keeps working after the first message',
);
expect(
handler.playbackState.value.errorMessage,
isNotNull,
reason:
'and the message survives the retry: re-entering `_cambiarFuente` '
'must not blank the car screen back to a silent spinner',
);
});
});
/// A handler nobody released goes on running: its terminal-state floor
/// timer, its `ControladorReconexion` backoff (1/2/4/8/16 s, which easily
/// outlives the test that armed it) and whatever is still queued on
/// `_colaCambioFuente`. When one of those finally performs a source change
/// it calls `_crearPlayer()`, which reads the CURRENT static
/// `fabricaReproductorPrueba` — so it builds a double bound to a LATER
/// test's script and increments that test's counters for work it never
/// asked for. A suite that passes under those conditions passes by luck.
group('Liberacion del handler: nada sobrevive al test que lo creo', () {
tearDown(() {
PluriWaveAudioHandler.timeoutCambioFuente =
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
});
test(
'un handler liberado NO vuelve a construir un reproductor contra la '
'fabrica del test siguiente',
() async {
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
milliseconds: 60,
);
guion.setUrlCuelga = true;
final handler = crearHandler();
unawaited(
handler
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
.catchError((_) {}),
);
// Long enough for the source-change timeout to fire and the reconnect
// machine to arm its first backoff retry (1 s).
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(
guion.llamadasSetUrl,
1,
reason: 'precondicion: hay un reintento armado detras',
);
await handler.liberar();
// Exactly what the framework does between tests: a brand-new script
// and a factory bound to it. Nothing from the previous test may
// reach this.
final guionSiguiente = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) =>
_ReproductorFalso(guionSiguiente, pipeline, carga);
await Future<void>.delayed(const Duration(milliseconds: 1400));
expect(
guionSiguiente.llamadasSetUrl,
0,
reason:
'the leaked backoff retry re-enters `_cambiarFuente`, which '
'calls `_crearPlayer()` and therefore reads whatever factory is '
'installed NOW — attributing a dead handler s work to the test '
'that happens to be running',
);
expect(
guionSiguiente.ultimoReproductor,
isNull,
reason: 'no player at all may be built against the new script',
);
},
);
test('liberar() es idempotente', () async {
final handler = crearHandler();
await handler.liberar();
await handler.liberar();
});
});
group('Idioma de la superficie del coche (motor sin Activity)', () {
tearDown(() {
PluriWaveAudioHandler.lectorLocalePlataforma =
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
});
/// Drives a NON-network failure through the real source-change path so the
/// terminal error message published to the car can be read back.
Future<String?> mensajeDeError(PluriWaveAudioHandler handler) async {
guion.errorSetUrl = Exception('boom');
await handler
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
.catchError((_) {});
await pumpEventQueue();
return handler.playbackState.value.errorMessage;
}
test(
'sin configurarLocalizaciones, los mensajes salen en el locale de la '
'plataforma, no en es',
() async {
PluriWaveAudioHandler.lectorLocalePlataforma = () =>
const Locale('en');
final handler = crearHandler();
final mensaje = await mensajeDeError(handler);
expect(
mensaje,
lookupAppLocalizations(const Locale('en')).audioErrorUnexpectedPlayback,
reason:
'`configurarLocalizaciones` only ever runs from '
'`mini_reproductor.dart` didChangeDependencies. The headless '
'Android Auto engine has no Activity and no widget tree, so it '
'never ran there and every car message came out in Spanish',
);
expect(
mensaje,
isNot(
lookupAppLocalizations(
const Locale('es'),
).audioErrorUnexpectedPlayback,
),
);
},
);
test('un locale de plataforma no soportado conserva el respaldo es', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('sw');
final handler = crearHandler();
expect(
await mensajeDeError(handler),
lookupAppLocalizations(const Locale('es')).audioErrorUnexpectedPlayback,
reason: 'the existing fallback must survive an unresolvable locale',
);
});
test('configurarLocalizaciones sigue teniendo prioridad', () async {
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
final handler = crearHandler();
handler.configurarLocalizaciones(
lookupAppLocalizations(const Locale('fr')),
);
expect(
await mensajeDeError(handler),
lookupAppLocalizations(const Locale('fr')).audioErrorUnexpectedPlayback,
reason: 'the phone UI still owns the locale once a widget tree exists',
);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
/// When set, `setUrl` completes with this error instead of succeeding.
Object? errorSetUrl;
/// When true, `setUrl` never completes (simulates a dead stream that only
/// the source-change timeout can end).
bool setUrlCuelga = false;
/// When set, `setUrl` returns this completer's future, so a test can hold a
/// source change mid-flight and release it after acting on the handler.
Completer<Duration?>? completerSetUrl;
/// The handler rebuilds its player on every source change, so a test that
/// needs to drive a player event has to reach the LATEST instance.
_ReproductorFalso? ultimoReproductor;
}
/// A [AudioPlayer] whose platform-touching methods are replaced by the script
/// above. Everything else (the rx subjects the constructor wires up) is the
/// real thing, so the handler's stream plumbing is exercised unchanged.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// Drives the exact `playerStateStream` event the real player would emit.
void emitir(PlayerState estado) {
if (!_estados.isClosed) _estados.add(estado);
}
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
if (_guion.setUrlCuelga) return Completer<Duration?>().future;
final pendiente = _guion.completerSetUrl;
if (pendiente != null) return pendiente.future;
final error = _guion.errorSetUrl;
if (error != null) return Future<Duration?>.error(error);
return Future<Duration?>.value(null);
}
@override
Future<void> play() async {
_guion.llamadasPlay++;
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -1,507 +0,0 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Resuming the last station in Android Auto — the three defects that made a
/// car-only session unable to remember, restart or even NAME what it was
/// playing.
///
/// Every test here runs with NO widget tree and NO browse source registered:
/// that is the engine Android Auto actually starts
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
/// no Activity), so `EstadoRadio` — the only thing that used to write
/// `ultima_emisora_v1` — is never constructed there.
///
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
/// session that happened only in the car never updated the key and the
/// head unit was offered the station from the last time the PHONE was
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
/// tier's featured folder was stale too.
///
/// A2. `play()` with no source called `_player.play()`, and
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
/// BEFORE the `_audioSource != null` gate — so the platform was never
/// touched, the returned Future never completed, and `playing: true`
/// was forwarded over `processingState: idle`.
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
/// button, no audio, no title and no artwork (or a
/// `ForegroundServiceStartNotAllowedException` on API 31+).
///
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
/// are the duration update, `_cambiarFuente` and `stop` — so
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
/// native side got no metadata at all.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
/// The free set's first station: resolvable from the binary alone, so it
/// works on a bind where no browse source was ever registered — which is
/// the whole point of these tests.
const emisoraFip = Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
);
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
// false`) and no `ultima_emisora_v1`.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('A1 — el coche escribe la ultima emisora', () {
test(
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
'inyectado, sin arbol de widgets',
() async {
final prefs = await SharedPreferences.getInstance();
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async {
guardadas.add(emisora);
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
},
);
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
await pumpEventQueue();
expect(
guardadas.map((e) => e.uuid),
[emisoraFip.uuid],
reason:
'a car-only session must update `ultima_emisora_v1` itself — '
'`EstadoRadio` is never built on a headless engine',
);
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
expect(persistida?.uuid, emisoraFip.uuid);
expect(
persistida?.url,
emisoraFip.url,
reason:
'the record has to be PLAYABLE: it is what the recent root and '
'`resolverEmisorasDestacadas` hand back to the head unit',
);
},
);
test('playMediaItem directo (voz, telefono) persiste igual', () async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
expect(guardadas.single.url, 'https://ejemplo/stream');
});
test(
'una pista local NO se persiste como ultima emisora',
() async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'content://media/audio/7',
title: 'Pista local',
extras: {'documentId': 'doc-7'},
),
);
await pumpEventQueue();
expect(
guardadas,
isEmpty,
reason:
'`ultima_emisora_v1` feeds the recent root and the featured '
'folder as an `emisora:<uuid>` row — a `content://` track '
'there is a row that does nothing when tapped',
);
},
);
test('un fallo del puerto se traza y NUNCA propaga', () async {
final handler = crearHandler();
registrarHandler(
handler,
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
await pumpEventQueue();
});
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
final handler = crearHandler();
registrarHandler(handler);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
});
});
group('A2 — play() sin fuente no publica una sesion fantasma', () {
test(
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
'reproductor recibe su url',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
contains(emisoraFip.url),
reason:
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
'straight into play(); on a cold engine there is no source, so '
'it has to resolve the persisted station instead',
);
},
);
test(
'y NINGUN estado publicado lleva playing:true sobre processingState '
'idle',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
await sub.cancel();
expect(
fantasmas,
isEmpty,
reason:
'playing:true over idle is what makes `AudioService.java:559` '
'call enterPlayingState() with STATE_NONE — a PluriWave '
'notification with a pause button, no audio and no title',
);
},
);
test(
'sin nada persistido: no se toca el reproductor, no hay estado '
'fantasma y play() no se queda colgado',
() async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
await expectLater(
handler.play().timeout(const Duration(seconds: 2)),
completes,
);
await pumpEventQueue();
await sub.cancel();
expect(
guion.llamadasPlay,
0,
reason:
'with nothing to restore the player must not be touched at '
'all: `just_audio` publishes playing:true before its source '
'gate and never completes the future it returns',
);
expect(guion.llamadasSetUrl, 0);
expect(fantasmas, isEmpty);
},
);
test(
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
'siempre (pausa -> play no reabre nada)',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
await handler.pause();
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
await handler.play();
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
urlsAntes,
reason:
'a resume must NOT re-open the source, and must never replace '
'the live station with the persisted one',
);
expect(handler.intencionReproducir, isTrue);
},
);
});
group('A3 — arranque en frio: el coche recibe metadatos', () {
test(
'con una emisora persistida se publica su mediaItem SIN arrancar '
'reproduccion',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
expect(
handler.mediaItem.value,
isNotNull,
reason:
'`audio_service.dart:1029-1033` returns before setMediaItem '
'when mediaItem is null, so a cold engine sent the head unit '
'no metadata whatsoever',
);
expect(handler.mediaItem.value?.id, emisoraFip.url);
expect(handler.playbackState.value.playing, isFalse);
expect(
guion.llamadasSetUrl,
0,
reason:
'publishing metadata must not open a stream: a cold bind '
'happens on every reconnect and must stay silent',
);
},
);
test('sin nada persistido el mediaItem sigue vacio', () async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
expect(handler.mediaItem.value, isNull);
});
test(
'una emisora que ya empezo a sonar NO es pisada por la siembra',
() async {
final handler = crearHandler();
final lectura = Completer<Emisora?>();
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
await handler.playMediaItem(
const MediaItem(
id: 'https://enVivo/stream',
title: 'En vivo',
extras: {'uuid': 'uuid-en-vivo'},
),
);
lectura.complete(emisoraFip);
await pumpEventQueue();
expect(
handler.mediaItem.value?.id,
'https://enVivo/stream',
reason:
'the seed exists to fill a VOID; clobbering the live station '
'would rename what the driver is listening to',
);
},
);
});
group('getMediaItem resuelve tambien el set destacado', () {
test(
'sin fuente de navegacion registrada, una emisora destacada resuelve',
() async {
final handler = crearHandler();
registrarHandler(handler);
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
expect(
item,
isNotNull,
reason:
'`porUuid` already falls back to the featured set, so the car '
'could BROWSE a featured station and not resolve its media '
'item — the asymmetry is the bug',
);
expect(item?.id, 'emisora:${emisoraFip.uuid}');
expect(item?.title, emisoraFip.nombre);
},
);
test('un id que no es de emisora sigue devolviendo null', () async {
final handler = crearHandler();
registrarHandler(handler);
expect(await handler.getMediaItem('pista:doc-1'), isNull);
expect(await handler.getMediaItem('emisora:'), isNull);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
}
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
/// it never touches the platform and never completes the future it returned.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// A fresh player has no source, exactly like the real one.
bool _fuenteCargada = false;
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
_fuenteCargada = true;
return null;
}
@override
Future<void> play() {
_guion.llamadasPlay++;
if (!_estados.isClosed) {
_estados.add(
PlayerState(
true,
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
),
);
}
// The dangling future: with no source, upstream `play()` awaits a
// `_playingSubject` transition the platform will never produce.
if (!_fuenteCargada) return Completer<void>().future;
return Future<void>.value();
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
+2 -189
View File
@@ -2,8 +2,6 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_android/billing_client_wrappers.dart';
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
@@ -19,12 +17,8 @@ import 'package:pluriwave/servicios/servicio_compras.dart';
class _InAppPurchaseFalso implements InAppPurchase {
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
int restauracionesPedidas = 0;
bool disponible = true;
final completadas = <PurchaseDetails>[];
@override
Future<bool> isAvailable() async => disponible;
@override
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
@@ -58,40 +52,6 @@ PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
status: status,
);
/// A Play Billing past purchase as `queryPastPurchases` returns it.
GooglePlayPurchaseDetails _compraPasada(
PurchaseStatus status, {
String productId = ServicioComprasPlayBilling.idProducto,
}) => GooglePlayPurchaseDetails(
purchaseID: 'GPA.1',
productID: productId,
verificationData: PurchaseVerificationData(
localVerificationData: '{}',
serverVerificationData: 'token',
source: 'google_play',
),
transactionDate: '0',
status: status,
billingClientPurchase: PurchaseWrapper(
orderId: 'GPA.1',
packageName: 'es.freetimelab.pluriwave',
purchaseTime: 0,
purchaseToken: 'token',
signature: 'firma',
products: <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() {
group('eventoDesdeEstadoCompra', () {
test('purchased -> comprada', () {
@@ -177,8 +137,8 @@ void main() {
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final compra = _compraFalsa(PurchaseStatus.purchased)
..pendingCompletePurchase = true;
final compra =
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
iap.emitir(<PurchaseDetails>[compra]);
await Future<void>.delayed(Duration.zero);
@@ -186,153 +146,6 @@ 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', () {
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
});
@@ -105,80 +105,6 @@ void main() {
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(grupos.any((g) => g.id == grupo.id), isFalse);
});
// ── Primitivas de restauración (importarConfig) ────────────────────────────
//
// `agregar` es la primitiva de "marcar como favorita": fuerza `sin_asignar`
// y un `orden` nuevo a propósito, porque una emisora recién marcada no
// pertenece a ningún grupo. Reusarla para RESTAURAR una copia de seguridad
// destruía justo los dos campos que la copia traía. `restaurarFavorito` es
// la primitiva que faltaba.
test('restaurarFavorito preserva el grupo y el orden de la copia', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.restaurarGrupo(
const GrupoFavoritos(id: 'grupo-rock', nombre: 'Rock', orden: 7),
);
await servicio.restaurarFavorito(
_emisora(
'radio-1',
'Radio Uno',
).copyWith(orden: 42, grupoFavoritosId: 'grupo-rock'),
);
final favoritos = await servicio.obtenerTodos();
expect(favoritos.single.grupoFavoritosId, 'grupo-rock');
expect(favoritos.single.orden, 42);
});
test('restaurarFavorito cae a Sin asignar cuando el grupo de la copia no '
'existe (copia editada a mano o restauración parcial)', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.restaurarFavorito(
_emisora(
'radio-1',
'Radio Uno',
).copyWith(orden: 3, grupoFavoritosId: 'grupo-fantasma'),
);
final favoritos = await servicio.obtenerTodos();
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(favoritos.single.orden, 3);
});
test('restaurarGrupo hace upsert preservando id, nombre y orden, y NUNCA '
'duplica el grupo protegido', () async {
final servicio = crearServicio();
addTearDown(servicio.cerrar);
await servicio.restaurarGrupo(
const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz', orden: 5),
);
// Segunda pasada (reimportar la misma copia): upsert, no duplicado.
await servicio.restaurarGrupo(
const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz renombrado', orden: 5),
);
await servicio.restaurarGrupo(
const GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: 'Unassigned',
orden: 0,
protegido: true,
),
);
final grupos = await servicio.obtenerGrupos();
final jazz = grupos.singleWhere((g) => g.id == 'grupo-jazz');
expect(jazz.nombre, 'Jazz renombrado');
expect(jazz.orden, 5);
// El protegido conserva su nombre local: no se importa desde la copia.
expect(grupos.singleWhere((g) => g.esSinAsignar).nombre, 'Sin asignar');
});
}
Emisora _emisora(String uuid, String nombre) {
@@ -1,84 +0,0 @@
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);
},
);
}
@@ -1,272 +0,0 @@
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,10 +24,6 @@ class _PuertoComprasFalso implements PuertoCompras {
@override
Future<void> restaurar() async {}
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
ResultadoVerificacionLicencia.desconocido;
void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close();
+2 -18
View File
@@ -31,10 +31,6 @@ class _PuertoComprasFalso implements PuertoCompras {
@override
Future<void> restaurar() async {}
@override
Future<ResultadoVerificacionLicencia> consultarPropiedad() async =>
ResultadoVerificacionLicencia.desconocido;
void emitir(EventoCompra evento) => _eventos.add(evento);
Future<void> dispose() => _eventos.close();
@@ -220,19 +216,6 @@ 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 {
@@ -351,7 +334,8 @@ void main() {
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null, compras: servicio),
create:
(_) => EstadoEntitlement(prefs: null, compras: servicio),
),
],
child: MaterialApp(
@@ -9,7 +9,6 @@ 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';
@@ -223,9 +222,6 @@ void main() {
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
ChangeNotifierProvider<EstadoVisualizador>(
create: (_) => EstadoVisualizador(prefs: null),
),
],
child: testApp(const PantallaAjustes()),
),
@@ -1,143 +0,0 @@
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);
}
});
}