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
FreeTLab 4ca2813267 feat(ecualizador): include equalizer on/off toggle in export/import
The backup envelope carried favorites, EQ presets, alarms and the
multi-device toggle but not EstadoEcualizador's own on/off flag, so
restoring a backup on another device silently kept that device's
existing toggle state instead of the source device's.

Bumps the backup schema to v4 (additive over v3): the flag is only
written when explicitly provided, so old exports stay at v2/v3.
Importing an old backup without the field leaves the current toggle
untouched rather than defaulting it. Applying the imported value
reuses EstadoEcualizador.cambiarActivo so it persists and pushes to
the live audio engine exactly like a manual toggle.
2026-08-28 23:07:01 +02:00
FreeTLab a2bed18937 fix(paywall): add dismiss controls and honest premium copy
The premium sheet had no close affordance or way to defer, and its
copy only said "Función Premium" without stating what it unlocks.
Adds a header close (X) button and a "not now" secondary action so
dismissal is never harder than purchasing, and replaces the bare
title with a concrete, honest breakdown of the 5 things premium
unlocks (no ads, Android Auto, station recording, alarm vacation
ranges, unlimited alarms) plus the one-time-purchase framing. The
phone equalizer is never listed, since it stays free for everyone.
New l10n keys added to all 13 locales.
2026-08-28 22:47:53 +02:00
FreeTLab e57f7bb17b fix(alarmas): reload and re-sync alarms after backup import
Importing a backup wrote the alarm/vacation/exception block straight to
SharedPreferences but never told EstadoAlarmas about it, so the UI kept
showing the pre-import alarms, a later edit could persist that stale
state back over the imported one, and imported alarms were never
(re)scheduled with the Android native layer. The backup screen now
calls EstadoAlarmas.cargarPersistidasSinRecalcular() followed by
refrescarProgramacion() after a successful import, extracted into a
directly-testable aplicarImportacionConfig() function.
2026-08-28 22:47:38 +02:00
ShanaiaBot fdddd95199 chore: bump version to 1.3.2+155 [ci skip] 2026-08-28 20:07:12 +02:00
FreeTLab 1bfd5a2348 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m54s
2026-08-28 20:06:20 +02:00
FreeTLab 4ea5d2056c test(alarmas): anchor the vacation pill test on a relative future range
The test hardcoded 4-18 August 2026, which was in the future when it was
written and is now in the past. The pill only renders for the active or
next range, so the assertion started failing purely because the calendar
moved on — the production code was never wrong.

Anchor the range on next month (days 4-18, so it never straddles a month
boundary) and assert against rangoFechasCorto, the same pure formatter the
widget uses, so the test checks that the pill is rendered rather than
restating the formatter's own output.
2026-08-28 20:06:10 +02:00
ShanaiaBot b5940b2758 chore: bump version to 1.3.1+154 [ci skip] 2026-08-28 20:02:32 +02:00
FreeTLab 9efa6d8937 merge: bring the freemium/IAP release line into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
main had drifted behind PRO by the whole 1.3.0 line: the freemium/IAP
feature, the code-review fixes, the real AdMob ids, the equalizer
cross-surface resync and the closed-testing ad switch all shipped through
PRO only. This reconciles main so day-to-day work no longer branches from
a stale base.

# Conflicts:
#	pubspec.yaml
2026-08-28 19:59:12 +02:00
ShanaiaBot 186ff45105 chore: bump version to 1.2.29+151 [ci skip] 2026-08-07 17:18:26 +02:00
72 changed files with 4472 additions and 512 deletions
+17 -4
View File
@@ -237,11 +237,24 @@ jobs:
- name: Build AAB release
run: flutter build appbundle --release
# El nombre lleva RAMA y CÓDIGO DE VERSIÓN, no solo el semver.
#
# Antes, cada build de 1.3.0 se llamaba `pluriwave-v1.3.0.aab` y caía en
# la misma carpeta, así que main y PRO se pisaban y tres builds distintos
# eran indistinguibles una vez descargados: el navegador los guarda como
# "(1)", "(2)"... y ya no se sabe cuál es cuál. Eso costó subir a Play
# Console un código de versión ya usado, dos veces.
#
# Con `pluriwave-PRO-v1.3.0+156.aab` el archivo se identifica solo,
# incluso semanas después y fuera de este repo.
- name: Publicar en ftl-builds (Zimaboard)
run: |
VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk"
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
BUILD_NUMBER="${{ steps.version.outputs.build_number }}"
BRANCH="${CURRENT_REF#refs/heads/}"
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
@@ -252,8 +265,8 @@ jobs:
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
- name: Preparar credenciales de Google Play
if: ${{ gitea.ref == 'refs/heads/PRO' }}
@@ -10,7 +10,6 @@ import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.media.audiofx.Visualizer
import android.app.AlarmManager
@@ -26,6 +25,7 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity
import es.freetimelab.pluriwave.fileactions.FileActionsHandler
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
@@ -269,10 +269,31 @@ class MainActivity : AudioServiceActivity() {
}
activeInstance = this
// fix/android-auto-musica-local: los cuatro metodos SAF que solo
// necesitan un ContentResolver viven en FileActionsHandler, dentro del
// paquete plugin `packages/pluriwave_file_actions`. Alli
// PluriWaveFileActionsPlugin los registra en TODOS los engines via
// GeneratedPluginRegistrant -- incluido el headless que audio_service
// crea para Android Auto, donde este configureFlutterEngine nunca
// corre.
//
// Este engine SI tiene Activity, asi que instala UN solo handler para
// todo el canal, superconjunto del del plugin: primero delega en el
// handler compartido (misma y unica implementacion) y, si este no
// reconoce el metodo, atiende sus propios metodos ligados a la
// Activity (picker SAF e intents de la carpeta de grabaciones).
//
// El orden esta garantizado: GeneratedPluginRegistrant corre DENTRO
// del constructor de FlutterEngine, y configureFlutterEngine solo
// puede ejecutarse despues, con el engine ya construido. Este handler
// siempre pisa al del plugin en una Activity, nunca al reves.
val fileActionsHandler = FileActionsHandler(applicationContext)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
fileActionsChannel
).setMethodCallHandler { call, result ->
if (fileActionsHandler.manejar(call, result)) return@setMethodCallHandler
when (call.method) {
"openDirectory" -> {
val path = call.argument<String>("path")
@@ -327,53 +348,11 @@ class MainActivity : AudioServiceActivity() {
pendingMusicFolderResult = null
}
}
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
// ---- android-auto-local-music-phase2 (static review only) ----
"readAudioMetadataBatch" -> {
val treeUri = call.argument<String>("treeUri")
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
Log.d(
tag,
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any?>>())
} else {
result.success(readAudioMetadataBatch(treeUri, documentIds))
}
}
// listAudioChildren / resolvePlayableUri /
// hasPersistedPermission / readAudioMetadataBatch los
// atiende FileActionsHandler arriba (item 3): no necesitan
// Activity, asi que tienen que poder registrarse tambien en
// un engine que no la tiene.
else -> result.notImplemented()
}
}
@@ -423,241 +402,6 @@ class MainActivity : AudioServiceActivity() {
super.onActivityResult(requestCode, resultCode, data)
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only — Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* audio MIME types at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) — used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
contentResolver.persistedUriPermissions.any { it.uri == parsed && it.isReadPermission }
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
/**
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
* static review only — Design "Interfaces / Contracts"): for each of
* [documentIds], extracts title/artist/bitrate/sample-rate and the
* embedded picture via [extraerMetadatosPista]. Never throws across the
* channel boundary — a malformed [treeUri] (or any other unexpected
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
* already isolated inside [extraerMetadatosPista].
*/
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
return try {
val parsedTree = Uri.parse(treeUri)
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
} catch (error: Throwable) {
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
emptyList()
}
}
/**
* Extracts one [documentId]'s embedded metadata via
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
* review only — mirrors [listAudioChildren]/[resolvePlayableUri]'s
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
* public constant below API 31) is gated behind
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
* available since API 10 and read unconditionally. A resolvable
* embedded picture is handed to [cachearArteEmbebido]; art-cache
* failures degrade that single field to `null` without failing the
* whole entry. On ANY failure for this [documentId] (unsupported
* format, permission edge case, corrupt file), the row degrades to an
* all-null-but-`documentId` entry instead of throwing —
* `retriever.release()` always runs via `finally`.
*/
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
val retriever = MediaMetadataRetriever()
return try {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
retriever.setDataSource(this, documentUri)
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
val bitrate = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull()
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
// public constant exists on this minSdk, so the raw key is
// used directly, guarded by the version check above.
retriever.extractMetadata(38)?.toIntOrNull()
} else {
null
}
val artUri = try {
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
error
)
null
}
mapOf(
"documentId" to documentId,
"titulo" to titulo,
"artista" to artista,
"bitrate" to bitrate,
"sampleRate" to sampleRate,
"artUri" to artUri
)
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
error
)
mapOf(
"documentId" to documentId,
"titulo" to null,
"artista" to null,
"bitrate" to null,
"sampleRate" to null,
"artUri" to null
)
} finally {
try {
retriever.release()
} catch (_: Throwable) {
// release() failing is not actionable — the retriever is
// being discarded regardless.
}
}
}
/**
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
* static review only — Design ADR-1). Writes [picture] bytes to
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
* file already exists, so re-parsing the same track reuses it), returns
* the `content://` URI served via the EXISTING
* `${applicationId}.fileprovider` authority
* (`AndroidManifest.xml:97-105`, `pluriwave_file_paths.xml`'s
* `cache-path path="."` — confirmed present, zero manifest changes
* needed) and trims `pluriwave_art/` via [trimArtCache]. `hash` uses
* SHA-256 hex because a raw `documentId` may contain `:`/`/`, which are
* illegal in filenames on most filesystems.
*/
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
return try {
val artDir = File(cacheDir, "pluriwave_art").apply { mkdirs() }
val artFile = File(artDir, hashDocumentId(documentId))
if (!artFile.exists()) {
artFile.writeBytes(picture)
}
trimArtCache(artDir)
FileProvider.getUriForFile(this, "$packageName.fileprovider", artFile).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
null
}
}
private fun hashDocumentId(documentId: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
* total, deleting the OLDEST-by-mtime entries first. Kept as a
* trivially reviewable loop — these files are native-owned, so
* round-tripping names to Dart to pick deletions would add channel
* chatter with no testability gain (the `delete()` is native
* regardless, per ADR-1's rationale).
*/
private fun trimArtCache(artDir: File) {
val maxArchivos = 256
val maxBytes = 32L * 1024 * 1024
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
?: return
var totalBytes = archivos.sumOf { it.length() }
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
val masViejo = archivos.removeAt(archivos.size - 1)
totalBytes -= masViejo.length()
masViejo.delete()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
+4
View File
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+34 -8
View File
@@ -98,6 +98,13 @@ class EstadoEcualizador extends ChangeNotifier {
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
/// the handler's current EQ state against our cached copy and adopt it on
/// divergence.
///
/// Since eq-estado-unico this is a DISPLAY concern only. The handler owns
/// the flag and persists it itself, so this subscription no longer closes
/// a persistence gap — it just keeps the phone's toggle showing what the
/// engine is really doing. It also cannot be the fix on its own: it exists
/// only while an [EstadoEcualizador] does, and the headless Android Auto
/// engine that produced the bug report never builds one.
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
PresetEcualizador get presetActual => _presetActual;
@@ -370,8 +377,16 @@ class EstadoEcualizador extends ChangeNotifier {
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
/// handler's own `_actualizarControlesEq()` re-push, which would tick
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
/// Only a local field write, [servicio] persistence and [notifyListeners]
/// happen here, so a divergence is resolved in a single pass.
/// Only a local field write and [notifyListeners] happen here, so a
/// divergence is resolved in a single pass.
///
/// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item
/// B). `PluriWaveAudioHandler` writes its own toggle through the port
/// `registrarHandler` injects, so the value is saved on every engine
/// rather than only on one that happens to have built a widget tree. This
/// method could never have been the owner of that fact: it only runs while
/// an [EstadoEcualizador] exists, and on the headless Android Auto engine
/// behind the bug report none ever does.
///
/// Wrapped in try/catch like every other handler-facing read in this
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
@@ -387,13 +402,10 @@ class EstadoEcualizador extends ChangeNotifier {
if (!activoDiverge && !presetDiverge) return;
if (activoDiverge) {
// Display-only adopt: the handler already persisted this value
// through its own write port before it ever reached us. See the
// doc above.
_activo = activoHandler;
// Closes the persistence gap: `PluriWaveAudioHandler` never
// persists anything itself (it must stay headless-constructible,
// with zero SharedPreferences/Provider access) — [servicio] is the
// only owner of EQ persistence, so a car/notification toggle must
// be saved HERE or it is lost on the next process restart.
await servicio.guardarActivo(activoHandler);
}
if (presetDiverge) {
_presetActual = presetHandler;
@@ -695,12 +707,21 @@ class EstadoEcualizador extends ChangeNotifier {
/// Replaces the whole EQ configuration (backup import path): persists it,
/// re-applies the preset effective for the current station and notifies.
///
/// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When
/// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT
/// toggle is left untouched: an absent flag must never flip the user's live
/// setting to an arbitrary value. When non-null, applies it through
/// [cambiarActivo], the same path a manual toggle uses, so the import
/// persists it AND pushes it to the live audio engine instead of just
/// updating [_activo] in memory.
Future<void> importarConfiguracion({
required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
bool? activo,
}) async {
_presetPrincipal = principal;
_presetsEmisoraMap
@@ -735,6 +756,11 @@ class EstadoEcualizador extends ChangeNotifier {
final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual);
if (activo != null) {
await cambiarActivo(activo);
}
notifyListeners();
}
+2 -2
View File
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
import '../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../servicios/servicio_compras.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
@@ -181,7 +181,7 @@ class EstadoEntitlement extends ChangeNotifier {
// Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind.
notificarDesbloqueoAuto();
invalidarArbolAuto();
}
}
+23 -6
View File
@@ -807,9 +807,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v3 — portabilidad completa
/// con presets por dispositivo y matriz multi-device).
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
/// Genera el JSON de toda la configuración (v4 — portabilidad completa
/// con presets por dispositivo, matriz multi-device y el toggle
/// on/off del ecualizador).
/// La forma del sobre vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
@@ -837,6 +838,8 @@ class EstadoRadio extends ChangeNotifier {
presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
// v4 extension — equalizer global on/off toggle.
ecualizadorActivo: ecualizador.activo,
);
}
@@ -850,10 +853,11 @@ class EstadoRadio extends ChangeNotifier {
/// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
/// v3 (+ presets por dispositivo, presets matriz, toggle multi-device)
/// y v4 (+ toggle on/off del ecualizador).
Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1;
if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
if (version > 4) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await _resolverPrefs();
@@ -932,12 +936,20 @@ class EstadoRadio extends ChangeNotifier {
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
// v4 extension: equalizer on/off toggle. Read unconditionally — the key
// is simply absent on any pre-v4 backup, which resolves to `null` and
// leaves the user's CURRENT toggle untouched (see
// `EstadoEcualizador.importarConfiguracion` doc): an old backup must
// never flip a live setting it never carried.
final ecualizadorActivo = data['ecualizadorActivo'] as bool?;
await ecualizador.importarConfiguracion(
principal: presetPrincipal,
porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
activo: ecualizadorActivo,
);
// ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -945,7 +957,12 @@ class EstadoRadio extends ChangeNotifier {
final alarmasData = data['alarmas'];
if (alarmasData is Map<String, dynamic>) {
// Escribimos el bloque JSON tal como estaba en el dispositivo origen.
// ServicioAlarmas lo leerá con su propio fromJson al siguiente acceso.
// EstadoAlarmas es un ChangeNotifier independiente y de larga vida
// que ya cargó sus alarmas en memoria: NO relee este storage por sí
// solo. El llamador (pantalla_ajustes_backup.dart) es responsable de
// invocar `EstadoAlarmas.cargarPersistidasSinRecalcular()` seguido
// de `refrescarProgramacion()` tras un import exitoso; EstadoRadio
// se mantiene deliberadamente sin depender de EstadoAlarmas.
await prefs.setString(_keyAlarmasConfig, jsonEncode(alarmasData));
}
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "استعادة المشتريات",
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
"premiumActivo": "النسخة المميزة مفعّلة"
"premiumActivo": "النسخة المميزة مفعّلة",
"premiumHojaTitulo": "افتح PluriWave Premium",
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "تسجيل المحطات",
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
"premiumAhoraNo": "ليس الآن"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
"premiumActivo": "প্রিমিয়াম সক্রিয়"
"premiumActivo": "প্রিমিয়াম সক্রিয়",
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
"premiumAhoraNo": "এখন নয়"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Käufe wiederherstellen",
"compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.",
"premiumActivo": "Premium aktiv"
"premiumActivo": "Premium aktiv",
"premiumHojaTitulo": "PluriWave Premium freischalten",
"premiumBeneficioSinAnuncios": "Keine Werbung in der gesamten App",
"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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Restore purchases",
"compraError": "We couldn't complete the purchase. Please try again.",
"restauracionSinCompras": "We didn't find any previous purchase on this account.",
"premiumActivo": "Premium active"
"premiumActivo": "Premium active",
"premiumHojaTitulo": "Unlock PluriWave Premium",
"premiumBeneficioSinAnuncios": "No ads anywhere in the app",
"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"
}
+9 -1
View File
@@ -863,5 +863,13 @@
"restaurarCompras": "Restaurar compras",
"compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
"restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.",
"premiumActivo": "Premium activo"
"premiumActivo": "Premium activo",
"premiumHojaTitulo": "Desbloquea PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sin publicidad en toda la app",
"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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Restaurer les achats",
"compraError": "Impossible de finaliser l'achat. Veuillez réessayer.",
"restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.",
"premiumActivo": "Premium actif"
"premiumActivo": "Premium actif",
"premiumHojaTitulo": "Débloquer PluriWave Premium",
"premiumBeneficioSinAnuncios": "Aucune publicité dans toute l'application",
"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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
"premiumActivo": "प्रीमियम सक्रिय"
"premiumActivo": "प्रीमियम सक्रिय",
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
"premiumAhoraNo": "अभी नहीं"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Pulihkan pembelian",
"compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.",
"restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.",
"premiumActivo": "Premium aktif"
"premiumActivo": "Premium aktif",
"premiumHojaTitulo": "Buka PluriWave Premium",
"premiumBeneficioSinAnuncios": "Tanpa iklan di seluruh aplikasi",
"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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Ripristina acquisti",
"compraError": "Non è stato possibile completare l'acquisto. Riprova.",
"restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.",
"premiumActivo": "Premium attivo"
"premiumActivo": "Premium attivo",
"premiumHojaTitulo": "Sblocca PluriWave Premium",
"premiumBeneficioSinAnuncios": "Nessuna pubblicità in tutta l'app",
"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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "購入を復元",
"compraError": "購入を完了できませんでした。もう一度お試しください。",
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
"premiumActivo": "プレミアム有効"
"premiumActivo": "プレミアム有効",
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "放送局の録音",
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
"premiumAhoraNo": "後で"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Restaurar compras",
"compraError": "Não foi possível concluir a compra. Tente novamente.",
"restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.",
"premiumActivo": "Premium ativo"
"premiumActivo": "Premium ativo",
"premiumHojaTitulo": "Desbloqueie o PluriWave Premium",
"premiumBeneficioSinAnuncios": "Sem anúncios em todo o app",
"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"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "Восстановить покупки",
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
"premiumActivo": "Премиум активен"
"premiumActivo": "Премиум активен",
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "Запись радиостанций",
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
"premiumAhoraNo": "Не сейчас"
}
+9 -1
View File
@@ -904,5 +904,13 @@
"restaurarCompras": "恢复购买",
"compraError": "无法完成购买,请重试。",
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
"premiumActivo": "高级版已解锁"
"premiumActivo": "高级版已解锁",
"premiumHojaTitulo": "解锁 PluriWave Premium",
"premiumBeneficioSinAnuncios": "全应用无广告",
"premiumBeneficioAndroidAuto": "Android Auto",
"premiumBeneficioGrabacion": "电台录音",
"premiumBeneficioVacaciones": "闹钟的假期时间段",
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
"premiumAhoraNo": "以后再说"
}
+48
View File
@@ -3367,6 +3367,54 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Premium activo'**
String get premiumActivo;
/// No description provided for @premiumHojaTitulo.
///
/// In es, this message translates to:
/// **'Desbloquea PluriWave Premium'**
String get premiumHojaTitulo;
/// No description provided for @premiumBeneficioSinAnuncios.
///
/// In es, this message translates to:
/// **'Sin publicidad en toda la app'**
String get premiumBeneficioSinAnuncios;
/// No description provided for @premiumBeneficioAndroidAuto.
///
/// In es, this message translates to:
/// **'Android Auto'**
String get premiumBeneficioAndroidAuto;
/// No description provided for @premiumBeneficioGrabacion.
///
/// In es, this message translates to:
/// **'Grabación de emisoras'**
String get premiumBeneficioGrabacion;
/// No description provided for @premiumBeneficioVacaciones.
///
/// In es, this message translates to:
/// **'Rangos de vacaciones para las alarmas'**
String get premiumBeneficioVacaciones;
/// No description provided for @premiumBeneficioAlarmasIlimitadas.
///
/// In es, this message translates to:
/// **'Alarmas ilimitadas (el plan gratuito permite hasta 5)'**
String get premiumBeneficioAlarmasIlimitadas;
/// No description provided for @premiumPagoUnico.
///
/// In es, this message translates to:
/// **'Pago único, para siempre. No es una suscripción.'**
String get premiumPagoUnico;
/// No description provided for @premiumAhoraNo.
///
/// In es, this message translates to:
/// **'Ahora no'**
String get premiumAhoraNo;
}
class _AppLocalizationsDelegate
+25
View File
@@ -1863,4 +1863,29 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get premiumActivo => 'النسخة المميزة مفعّلة';
@override
String get premiumHojaTitulo => 'افتح PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'بدون إعلانات في التطبيق بالكامل';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'تسجيل المحطات';
@override
String get premiumBeneficioVacaciones => 'فترات إجازة للمنبهات';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)';
@override
String get premiumPagoUnico => 'دفعة واحدة، للأبد. ليس اشتراكًا.';
@override
String get premiumAhoraNo => 'ليس الآن';
}
+26
View File
@@ -1874,4 +1874,30 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
@override
String get premiumHojaTitulo => 'PluriWave Premium আনলক করুন';
@override
String get premiumBeneficioSinAnuncios => 'পুরো অ্যাপে কোনো বিজ্ঞাপন নেই';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'স্টেশন রেকর্ডিং';
@override
String get premiumBeneficioVacaciones => 'অ্যালার্মের জন্য ছুটির সময়কাল';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)';
@override
String get premiumPagoUnico =>
'একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।';
@override
String get premiumAhoraNo => 'এখন নয়';
}
+25
View File
@@ -1888,4 +1888,29 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get premiumActivo => 'Premium aktiv';
@override
String get premiumHojaTitulo => 'PluriWave Premium freischalten';
@override
String get premiumBeneficioSinAnuncios => 'Keine Werbung in der gesamten App';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Sender aufnehmen';
@override
String get premiumBeneficioVacaciones => 'Urlaubszeiträume für Wecker';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)';
@override
String get premiumPagoUnico => 'Einmalzahlung, für immer. Kein Abonnement.';
@override
String get premiumAhoraNo => 'Nicht jetzt';
}
+26
View File
@@ -1867,4 +1867,30 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get premiumActivo => 'Premium active';
@override
String get premiumHojaTitulo => 'Unlock PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'No ads anywhere in the app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Station recording';
@override
String get premiumBeneficioVacaciones => 'Vacation ranges for alarms';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Unlimited alarms (the free plan allows up to 5)';
@override
String get premiumPagoUnico =>
'One-time purchase, forever. Not a subscription.';
@override
String get premiumAhoraNo => 'Not now';
}
+27
View File
@@ -1881,4 +1881,31 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get premiumActivo => 'Premium activo';
@override
String get premiumHojaTitulo => 'Desbloquea PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'Sin publicidad en toda la app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Grabación de emisoras';
@override
String get premiumBeneficioVacaciones =>
'Rangos de vacaciones para las alarmas';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarmas ilimitadas (el plan gratuito permite hasta 5)';
@override
String get premiumPagoUnico =>
'Pago único, para siempre. No es una suscripción.';
@override
String get premiumAhoraNo => 'Ahora no';
}
+28
View File
@@ -1894,4 +1894,32 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get premiumActivo => 'Premium actif';
@override
String get premiumHojaTitulo => 'Débloquer PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios =>
'Aucune publicité dans toute l\'application';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Enregistrement des stations';
@override
String get premiumBeneficioVacaciones =>
'Périodes de vacances pour les alarmes';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarmes illimitées (la version gratuite en autorise jusqu\'à 5)';
@override
String get premiumPagoUnico =>
'Achat unique, pour toujours. Ce n\'est pas un abonnement.';
@override
String get premiumAhoraNo => 'Plus tard';
}
+26
View File
@@ -1867,4 +1867,30 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get premiumActivo => 'प्रीमियम सक्रिय';
@override
String get premiumHojaTitulo => 'PluriWave Premium अनलॉक करें';
@override
String get premiumBeneficioSinAnuncios => 'पूरे ऐप में कोई विज्ञापन नहीं';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'स्टेशन रिकॉर्डिंग';
@override
String get premiumBeneficioVacaciones => 'अलार्म के लिए छुट्टी की अवधि';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)';
@override
String get premiumPagoUnico =>
'एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।';
@override
String get premiumAhoraNo => 'अभी नहीं';
}
+26
View File
@@ -1878,4 +1878,30 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get premiumActivo => 'Premium aktif';
@override
String get premiumHojaTitulo => 'Buka PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'Tanpa iklan di seluruh aplikasi';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Perekaman stasiun';
@override
String get premiumBeneficioVacaciones => 'Rentang liburan untuk alarm';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarm tanpa batas (paket gratis mengizinkan hingga 5)';
@override
String get premiumPagoUnico =>
'Pembelian sekali bayar, untuk selamanya. Bukan langganan.';
@override
String get premiumAhoraNo => 'Nanti saja';
}
+28
View File
@@ -1891,4 +1891,32 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get premiumActivo => 'Premium attivo';
@override
String get premiumHojaTitulo => 'Sblocca PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios =>
'Nessuna pubblicità in tutta l\'app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Registrazione delle stazioni';
@override
String get premiumBeneficioVacaciones =>
'Intervalli di vacanza per le sveglie';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Sveglie illimitate (il piano gratuito ne consente fino a 5)';
@override
String get premiumPagoUnico =>
'Acquisto unico, per sempre. Non è un abbonamento.';
@override
String get premiumAhoraNo => 'Non ora';
}
+24
View File
@@ -1812,4 +1812,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get premiumActivo => 'プレミアム有効';
@override
String get premiumHojaTitulo => 'PluriWave Premiumのロックを解除';
@override
String get premiumBeneficioSinAnuncios => 'アプリ全体で広告なし';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => '放送局の録音';
@override
String get premiumBeneficioVacaciones => 'アラームの休暇期間設定';
@override
String get premiumBeneficioAlarmasIlimitadas => 'アラーム数無制限(無料プランは5個まで)';
@override
String get premiumPagoUnico => '買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。';
@override
String get premiumAhoraNo => '後で';
}
+26
View File
@@ -1878,4 +1878,30 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get premiumActivo => 'Premium ativo';
@override
String get premiumHojaTitulo => 'Desbloqueie o PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => 'Sem anúncios em todo o app';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Gravação de emissoras';
@override
String get premiumBeneficioVacaciones => 'Períodos de férias para os alarmes';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Alarmes ilimitados (o plano gratuito permite até 5)';
@override
String get premiumPagoUnico =>
'Pagamento único, para sempre. Não é uma assinatura.';
@override
String get premiumAhoraNo => 'Agora não';
}
+27
View File
@@ -1884,4 +1884,31 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get premiumActivo => 'Премиум активен';
@override
String get premiumHojaTitulo => 'Разблокировать PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios =>
'Никакой рекламы во всём приложении';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => 'Запись радиостанций';
@override
String get premiumBeneficioVacaciones => 'Периоды отпуска для будильников';
@override
String get premiumBeneficioAlarmasIlimitadas =>
'Неограниченное количество будильников (бесплатный план позволяет до 5)';
@override
String get premiumPagoUnico =>
'Единоразовая покупка, навсегда. Это не подписка.';
@override
String get premiumAhoraNo => 'Не сейчас';
}
+24
View File
@@ -1797,4 +1797,28 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get premiumActivo => '高级版已解锁';
@override
String get premiumHojaTitulo => '解锁 PluriWave Premium';
@override
String get premiumBeneficioSinAnuncios => '全应用无广告';
@override
String get premiumBeneficioAndroidAuto => 'Android Auto';
@override
String get premiumBeneficioGrabacion => '电台录音';
@override
String get premiumBeneficioVacaciones => '闹钟的假期时间段';
@override
String get premiumBeneficioAlarmasIlimitadas => '无限闹钟(免费版最多支持5个)';
@override
String get premiumPagoUnico => '一次性付费,永久使用,不是订阅。';
@override
String get premiumAhoraNo => '以后再说';
}
+106 -8
View File
@@ -16,6 +16,7 @@ import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_ecualizador.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
@@ -88,7 +89,7 @@ Future<void> main() async {
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
// `fuenteLocal != null && await fuenteLocal.estadoCarpeta() != noConfigurada`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
@@ -103,7 +104,7 @@ Future<void> main() async {
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway.
unawaited(aplicarPoliticaOrientacion());
@@ -151,6 +152,18 @@ Future<void> main() async {
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
// eq-estado-unico items A/B: the handler's own link to the equalizer's
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
// `prefs` instance resolved just above — no widget tree, no Provider — so
// it is available on EVERY engine, including the headless one Android Auto
// starts. Before this, the persisted value only reached the handler
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
// runs: the handler played with the equalizer forced on while disk and the
// phone UI both said off, and a toggle made in the car was lost on
// restart. Passed as two narrow function ports, mirroring the
// read-function convention used for the preset folder right above.
final ecualizador = ServicioEcualizador(prefs: prefs);
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches
@@ -179,7 +192,11 @@ Future<void> main() async {
// radio; headphones unplugged pauses it. Shared by both the on-time and
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) {
registrarHandler(handler);
registrarHandler(
handler,
leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention
@@ -189,7 +206,7 @@ Future<void> main() async {
unawaited(sesionAudio.configurar());
}
Widget construirApp() => _OrientacionResponsiveApp(
Widget construirApp() => OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
@@ -268,20 +285,80 @@ Future<void> aplicarPoliticaOrientacion({
}
}
class _OrientacionResponsiveApp extends StatefulWidget {
const _OrientacionResponsiveApp({required this.child});
/// Whether the Android Auto browse tree must be invalidated right now
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
///
/// The trigger used to be `View.maybeOf(context) != null` inside
/// `didChangeDependencies`, latched once, on the premise that «a View means
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
/// wraps the tree in a `View` built from
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
/// on the headless `audio_service` engine — which demonstrably reaches
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
/// the first `didChangeDependencies`. The one-shot latch was spent at the
/// exact moment it could accomplish nothing (`_childrenSubjects` still
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
/// fire again, because `didChangeDependencies` does not re-run when an
/// Activity later attaches to that same cached engine.
///
/// Two conditions replace it, both required:
///
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
/// means «an Activity is attached and in the foreground». It reaches Dart
/// exclusively through `SystemChannels.lifecycle` (or
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
/// it, driven by the Activity's own `onResume`.
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
/// APPLICATION context and runs the Dart entrypoint immediately, with no
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
/// it on the headless engine.
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
/// worth spending, and it is also the belt to `resumed`'s braces: even if
/// a lifecycle event did somehow arrive during a headless cold start,
/// nothing has subscribed yet, so the latch survives for the moment an
/// Activity really does attach.
///
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
/// not send twenty `notifyChildrenChanged` storms to the car.
///
/// Pure, so the whole policy is testable without an engine.
@visibleForTesting
bool debeInvalidarArbolAutoAlReanudar({
required AppLifecycleState estado,
required bool hayCocheSuscrito,
required bool yaInvalidado,
}) =>
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
/// Root wrapper that keeps the orientation policy applied and owns the
/// Android Auto browse-tree recovery hook.
///
/// Public only so a test can mount it and drive real lifecycle events
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
/// trigger shipped broken precisely because nothing could reach it.
@visibleForTesting
class OrientacionResponsiveApp extends StatefulWidget {
const OrientacionResponsiveApp({super.key, required this.child});
final Widget child;
@override
State<_OrientacionResponsiveApp> createState() =>
State<OrientacionResponsiveApp> createState() =>
_OrientacionResponsiveAppState();
}
class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
with WidgetsBindingObserver {
ui.Display? _display;
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
/// coche se dispara UNA sola vez. Ver
/// [debeInvalidarArbolAutoAlReanudar].
bool _arbolAutoInvalidado = false;
@override
void initState() {
super.initState();
@@ -295,6 +372,27 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
unawaited(aplicarPoliticaOrientacion(display: _display));
}
/// `resumed` es lo único que significa de verdad «ya hay Activity
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
/// resolver la música local; Android Auto no vuelve a preguntar por su
/// cuenta, así que se lo decimos aquí. Ver
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (!debeInvalidarArbolAutoAlReanudar(
estado: state,
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
yaInvalidado: _arbolAutoInvalidado,
)) {
return;
}
_arbolAutoInvalidado = true;
invalidarArbolAuto();
}
@override
void didChangeMetrics() {
unawaited(aplicarPoliticaOrientacion(display: _display));
@@ -6,12 +6,45 @@ import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../../estado/estado_alarmas.dart';
import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
/// Applies a successfully-parsed backup to BOTH independent notifiers that
/// own pieces of it (fix/import-alarmas-y-paywall).
///
/// `EstadoRadio.importarConfig` writes the raw alarm/vacation/exception JSON
/// block straight to SharedPreferences, but `EstadoAlarmas` is a separate
/// long-lived `ChangeNotifier` that loaded its alarms into memory at
/// construction and never re-reads storage on its own — `EstadoRadio` stays
/// deliberately free of a dependency on it. Without the two calls below the
/// imported block is invisible to the running app: the UI keeps showing the
/// pre-import alarms, a later edit would persist that stale in-memory list
/// OVER the imported one, and the imported alarms would never be
/// (re)scheduled with the Android native layer even after a restart.
///
/// Extracted as a top-level function (rather than inlined in `_importar`)
/// so this exact production sequence — not a reimplementation of it — is
/// directly unit-testable without depending on the `file_picker` platform
/// channel or the confirmation dialog.
Future<void> aplicarImportacionConfig(
EstadoRadio estado,
EstadoAlarmas alarmas,
Map<String, dynamic> json,
) async {
await estado.importarConfig(json);
// Re-reads from storage — clears ServicioAlarmas' in-memory cache so the
// just-imported alarms/vacations/exceptions (same JSON block, same
// notifier) replace the stale ones.
await alarmas.cargarPersistidasSinRecalcular();
// Recomputes next-run times against the (now fresh) imported data and
// re-syncs every alarm with the Android native scheduler.
await alarmas.refrescarProgramacion();
}
/// APLICACIÓN group · "Copia de seguridad" (design ADR-3). Body moved
/// verbatim from the former `_SeccionBackup` in `pantalla_ajustes.dart` —
/// only the panel header's icon and title were removed (the pushed screen's
@@ -102,8 +135,9 @@ class _CuerpoBackup extends StatelessWidget {
if (confirmar != true) return;
if (context.mounted) {
final estado = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final messenger = ScaffoldMessenger.of(context);
await estado.importarConfig(json);
await aplicarImportacionConfig(estado, alarmas, json);
messenger.showSnackBar(
SnackBar(content: Text(l10n.backupImportSuccess)),
);
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../servicios/musica_local_auto.dart';
import '../../servicios/servicio_audio.dart' show invalidarArbolAuto;
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
@@ -54,6 +55,15 @@ class _CuerpoMusicaLocalState extends State<_CuerpoMusicaLocal> {
final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance.
// fix/android-auto-musica-local, item 4: acaba de aparecer música
// local donde antes no había. Android Auto cachea la raíz y no
// vuelve a preguntar por su cuenta, así que sin esto el coche seguía
// sin ofrecer «Música Local» hasta el siguiente re-bind — que puede
// no llegar en toda la sesión. Fuera del `context.mounted` de abajo:
// el árbol del coche no depende de que esta pantalla siga viva.
invalidarArbolAuto();
if (!context.mounted) return;
setState(() {
_carpetaActual = Future.value(uri);
+11 -5
View File
@@ -5,11 +5,17 @@ import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart';
/// Timeout applied to the `AudioService.init` MediaBrowser handshake (Design
/// "Timeout without re-init"): the vendored `audio_service` plugin's
/// self-bind has no native timeout and an unhandled `onConnectionSuspended`
/// case, so under bind contention (Android Auto cold start) the handshake
/// can hang forever. Top-level const so tests can reference the production
/// value without duplicating it.
/// "Timeout without re-init"): the `audio_service` plugin's self-bind has no
/// native timeout and an unhandled `onConnectionSuspended` case, so under
/// bind contention (Android Auto cold start) the handshake can hang forever.
/// Top-level const so tests can reference the production value without
/// duplicating it.
///
/// This doc called the plugin "vendored". It is not: `pubspec.lock` pins the
/// hosted pub.dev `audio_service` 0.18.18 and `pubspec.yaml` declares no
/// `dependency_overrides`. Anyone reading the sentence above would go looking
/// for a local copy to patch, and there is none — the behaviour described is
/// upstream's, so the workaround has to live here.
const timeoutArranqueAudio = Duration(seconds: 8);
/// Outcome of racing an `AudioService.init` future against
+4 -2
View File
@@ -6,8 +6,10 @@ import '../modelos/pista_local.dart';
/// instance rather than mutating in place, mirroring how
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without
/// the handler (which cannot be instantiated in unit tests — see this
/// module's sibling test file's doc comment).
/// the handler. (That last clause used to read "which cannot be instantiated
/// in unit tests"; it can — see `construirControlesTransporte`'s doc in
/// `servicio_audio.dart`. Keeping the queue logic out of the handler is
/// still worth it, but for design reasons, not for that one.)
class ColaLocal {
const ColaLocal({required this.pistas, this.indice = 0});
+68 -9
View File
@@ -1,5 +1,6 @@
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -71,6 +72,24 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
return recortado.isEmpty ? nombreGenerico : recortado;
}
/// Three-valued answer to «¿hay música local usable?»
/// (fix/android-auto-musica-local).
///
/// Sustituye al `bool` anterior, que colapsaba dos causas MUY distintas en
/// el mismo `false`:
///
/// * [noConfigurada] — no hay URI persistida, o el nativo respondió que el
/// permiso ya no es válido (el usuario nunca eligió carpeta, o la
/// revocó). Es la única respuesta que justifica ocultar el nodo.
/// * [configurada] — hay URI persistida y el nativo confirma el permiso.
/// * [canalNoDisponible] — hay URI persistida pero el canal
/// `pluriwave/file_actions` NO tiene handler nativo, así que no se puede
/// saber nada del permiso. Es lo que ocurre en el motor Flutter headless
/// que `audio_service` levanta cuando Android Auto arranca la app sin
/// Activity: `MainActivity.configureFlutterEngine` (único sitio donde se
/// registra ese canal) nunca corre. NO significa «no hay carpeta».
enum EstadoCarpetaLocal { noConfigurada, configurada, canalNoDisponible }
/// Browse-source abstraction for the local-music branch of the Android Auto
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as
@@ -78,9 +97,11 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
/// browse domain, not a station source.
abstract class FuenteMusicaLocalAuto {
/// Whether a local-music root folder is picked AND its permission is
/// still valid. Never throws — a revoked/never-granted permission
/// degrades to `false` (Spec "Permission revoked or never granted").
Future<bool> hayCarpetaConfigurada();
/// still valid — o si esa pregunta no se puede contestar porque el canal
/// nativo no existe en este motor. Never throws: cualquier fallo degrada
/// a un valor de [EstadoCarpetaLocal], nunca a una excepción (Spec
/// "Permission revoked or never granted").
Future<EstadoCarpetaLocal> estadoCarpeta();
/// Immediate children of [documentId] (`''` = the tree root itself), one
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager
@@ -197,18 +218,56 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
}
@override
Future<bool> hayCarpetaConfigurada() async {
Future<EstadoCarpetaLocal> estadoCarpeta() async {
// Its OWN try, deliberately not merged with the channel one below.
//
// Never-throws restoration: the three-valued refactor moved this read
// outside the try, and the only caller (`getChildren`'s root branch)
// awaits it inline — so a prefs failure took the whole browse root down
// and emptied the car, against this method's own interface doc.
//
// Kept SEPARATE because a prefs failure and a channel failure both
// surface as `MissingPluginException`: one shared `on
// MissingPluginException` clause would answer `canalNoDisponible` —
// «hay carpeta pero no puedo comprobar el permiso» — for a store that
// never told us whether a folder exists at all. That would put an
// unreachable «Música Local» node in the car explaining a channel
// problem that is not happening, which is precisely the collapse the
// three-valued [EstadoCarpetaLocal] exists to prevent.
//
// `noConfigurada` is the honest answer here (the app cannot prove a
// folder was ever picked) and is what this path returned before the
// refactor, when the read still sat inside the catch-all below.
final String? uri;
try {
uri = await _uriPersistida();
} catch (e) {
debugPrint('[PluriWave][musica_local] no se pudo leer la URI local: $e');
return EstadoCarpetaLocal.noConfigurada;
}
if (uri == null || uri.isEmpty) return EstadoCarpetaLocal.noConfigurada;
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
'treeUri': uri,
});
return valido ?? false;
} catch (_) {
return valido == true
? EstadoCarpetaLocal.configurada
: EstadoCarpetaLocal.noConfigurada;
} on MissingPluginException catch (e) {
// El canal no tiene handler en ESTE motor. Antes esto caía en el
// mismo `catch (_)` que un permiso revocado y devolvía `false`, que
// es exactamente por lo que «Música Local» desaparecía del árbol de
// Android Auto cuando el coche arrancaba la app sin Activity.
debugPrint(
'[PluriWave][musica_local] hasPersistedPermission sin handler '
'nativo (motor sin Activity): $e',
);
return EstadoCarpetaLocal.canalNoDisponible;
} catch (e) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted"): never throw, degrade to "not configured".
return false;
debugPrint('[PluriWave][musica_local] hasPersistedPermission ERROR $e');
return EstadoCarpetaLocal.noConfigurada;
}
}
+38 -3
View File
@@ -229,6 +229,17 @@ class ConstructorArbolAuto {
/// hidden.
static const idEcualizador = 'ecualizador';
/// Non-playable "no puedo leer la carpeta desde aquí" item
/// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal]
/// cuando el canal nativo `pluriwave/file_actions` no está disponible en
/// este motor, así que abrir la carpeta tenía que dejar de mostrar una
/// lista vacía: vacío se lee como «no tengo música», que es justo la
/// conclusión equivocada. Este item dice qué pasa de verdad.
///
/// Colisión imposible con los prefijos `carpeta_local:` / `pista:` /
/// `emisora:` / `grupo:` — no lleva ninguno de ellos.
static const idLocalNoLista = 'musica_local_no_disponible';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -331,8 +342,10 @@ 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 passes `fuente.hayCarpetaConfigurada()`,
/// keeping this builder itself synchronous and side-effect free.
/// is configured") — the caller lo deriva de
/// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
/// nodo), keeping this builder itself synchronous and side-effect free.
///
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
/// same visible folder labels for every tier — "keeps the same visible
@@ -370,6 +383,17 @@ class ConstructorArbolAuto {
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: 'Abre PluriWave en el móvil para leer tu música',
playable: false,
extras: _contentStyleLista,
);
MediaItem _carpeta(String id, String titulo) => MediaItem(
id: id,
title: titulo,
@@ -1584,13 +1608,24 @@ Future<List<MediaItem>?> hijosMusicaLocal(
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocales(
final items = await constructor.itemsLocales(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
);
// fix/android-auto-musica-local: si no salió NADA, el motivo importa.
// Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]`
// igual que una carpeta realmente vacía, y una carpeta vacía en el
// coche se lee como «no tengo música». El estado se consulta SOLO en
// ese caso vacío, así que la ruta normal no paga ningún round trip
// extra.
if (items.isEmpty &&
await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) {
return [constructor.itemLocalNoDisponible()];
}
return items;
} catch (_) {
return const [];
}
+561 -129
View File
@@ -36,13 +36,93 @@ enum EstadoReproduccion {
// ─────────────────────────────────────────────────────────────────────────────
PluriWaveAudioHandler? _handlerGlobal;
void registrarHandler(PluriWaveAudioHandler handler) {
/// Read port for the persisted equalizer on/off flag (eq-estado-unico item A).
/// In production `main.dart` binds it to `ServicioEcualizador.leerActivo`,
/// which needs nothing but the `SharedPreferences` instance already resolved
/// before `AudioService.init`. `null` for any caller that has no disk (widget
/// tests, fakes) — seeding is then skipped entirely.
typedef LeerEqActivoPersistido = Future<bool?> Function();
/// Write port for the same flag (eq-estado-unico item B). Bound to
/// `ServicioEcualizador.guardarActivo`.
typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
/// Last value read from disk for the equalizer on/off flag, or `null` while
/// nothing has been read yet.
///
/// This exists purely to close the construction window: `AudioService.init`
/// builds the handler through its `builder` callback, and only AFTER that
/// future resolves does `main.dart` reach [registrarHandler]. A car tap
/// landing inside that window would otherwise hit a handler whose flag had
/// never seen disk. Once one engine has read the value, any handler built
/// afterwards starts from it instead of from a hardcoded default.
bool? _eqActivoPersistido;
/// The equalizer's initial on/off state for a freshly started engine.
///
/// Pure seam (eq-estado-unico item A): [PluriWaveAudioHandler] used to
/// hardcode `_ecualizadorActivo = true`, so a process started HEADLESSLY by
/// Android Auto — no Activity, no Provider tree, so no
/// `EstadoEcualizador.cargarPersistido()` — played with the equalizer forced
/// on while disk and the phone UI both said off. That is the reported «suena
/// muy alto con el boton desactivado».
///
/// `null` means "nothing was ever persisted" (first install, or a wiped
/// preference) and keeps the historical default of ON. It must NOT be
/// confused with "off": a user who has never touched the toggle expects the
/// equalizer on, and the app has always behaved that way.
bool estadoEqInicial({required bool? persistido}) => persistido ?? true;
/// Reads the persisted equalizer flag through [leer] exactly once and seeds
/// [handler] with it, without ever writing back.
///
/// Never throws: an unreadable preference store leaves the handler on
/// [estadoEqInicial]'s default rather than taking down the audio bootstrap.
Future<void> _sembrarEcualizadorDesdeDisco(
PluriWaveAudioHandler handler,
LeerEqActivoPersistido leer,
) async {
bool? persistido;
try {
persistido = await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer el estado EQ persistido: $e',
);
persistido = null;
}
_eqActivoPersistido = persistido;
await handler.sembrarEcualizadorActivo(
estadoEqInicial(persistido: persistido),
);
}
/// Wires the freshly built handler into the module-level seams.
///
/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler
/// its own, UI-independent link to the equalizer's persisted on/off flag
/// (eq-estado-unico items A and B). Before them the flag reached the handler
/// only through `EstadoEcualizador.cargarPersistido()`, i.e. only on an
/// engine that had actually built the widget tree — which a headless Android
/// Auto bind never does. Both are optional so every existing caller (widget
/// tests, fakes) keeps compiling and behaving exactly as before.
void registrarHandler(
PluriWaveAudioHandler handler, {
LeerEqActivoPersistido? leerEqActivoPersistido,
GuardarEqActivoPersistido? guardarEqActivoPersistido,
}) {
_handlerGlobal = handler;
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved):
// on the free -> premium transition, actively invalidate every root-level
// browse id a head unit may have cached while locked, rather than waiting
// for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc.
registrarNotificacionDesbloqueoAuto(() {
// Registered BEFORE the seeding below is awaited so that a toggle arriving
// during the disk read is still persisted.
handler.registrarPersistenciaEq(guardarEqActivoPersistido);
if (leerEqActivoPersistido != null) {
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
}
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved),
// generalizado en fix/android-auto-musica-local item 4: invalida
// activamente todo id de nivel raíz que un head unit pueda tener cacheado
// en vez de esperar a su propio re-bind — ver [registrarInvalidacionArbolAuto].
registrarInvalidacionArbolAuto(() {
handler.notificarHijosCambiaron(AudioService.browsableRootId);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas);
@@ -153,29 +233,56 @@ void registrarLimpiezaArranque(Future<void> Function() limpieza) {
_limpiezaArranqueGlobal = limpieza;
}
/// Free -> premium Android Auto cache-invalidation hook (design.md Open
/// Questions, orchestrator-resolved): registered from [registrarHandler] so
/// `estado_entitlement.dart` can trigger it WITHOUT ever touching
/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a
/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a
/// handler registers (headless cold bind, or a widget-only test that never
/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently.
void Function()? _alDesbloquearAutoGlobal;
/// Android Auto browse-cache invalidation hook (design.md Open Questions,
/// orchestrator-resolved): registered from [registrarHandler] so callers
/// can trigger it WITHOUT ever touching `PluriWaveAudioHandler` directly (a
/// layering choice — the entitlement layer has no business knowing the
/// handler type; it is not, as this doc used to claim, because the handler
/// cannot be constructed in a unit test, which is false — see
/// [construirControlesTransporte]). `null` until a handler registers
/// (headless cold bind, or a widget-only test that never wires audio) —
/// [invalidarArbolAuto] tolerates that silently.
///
/// GENERALIZADO (fix/android-auto-musica-local, item 4): nació atado a la
/// transición free -> premium, y ese nombre escondía para qué sirve de
/// verdad. Android Auto CACHEA la raíz, así que hay que invalidarla cada
/// vez que el árbol pasa a poder mostrar algo que antes no podía. Hoy lo
/// disparan tres sitios: la compra premium (`estado_entitlement.dart`), la
/// primera vez que existe una View de verdad — es decir, cuando por fin hay
/// Activity y con ella el handler nativo de `pluriwave/file_actions`
/// (`main.dart`) — y la elección de carpeta de música local
/// (`pantalla_ajustes_musica_local.dart`).
void Function()? _invalidarArbolAutoGlobal;
/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module
/// Registers the hook [invalidarArbolAuto] invokes. Exposed at module
/// level (like every other `registrar*` seam in this file) purely so tests
/// can inject a fake hook and assert it fires, without instantiating a real
/// [PluriWaveAudioHandler].
void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) {
_alDesbloquearAutoGlobal = alDesbloquear;
void registrarInvalidacionArbolAuto(void Function() alInvalidar) {
_invalidarArbolAutoGlobal = alInvalidar;
}
/// Fires the registered free -> premium Android Auto invalidation hook, if
/// Fires the registered Android Auto browse-cache invalidation hook, if
/// any. A no-op before a handler ever registers — never throws.
void notificarDesbloqueoAuto() {
_alDesbloquearAutoGlobal?.call();
void invalidarArbolAuto() {
_invalidarArbolAutoGlobal?.call();
}
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
///
/// This is the precondition that makes [invalidarArbolAuto] worth firing at
/// all: [PluriWaveAudioHandler.notificarHijosCambiaron] is
/// `_childrenSubjects[id]?.add(...)`, so invalidating before the car has
/// subscribed to ANYTHING is provably a silent no-op — which is exactly how
/// the old `View.maybeOf(context) != null` trigger managed to burn its
/// one-shot latch during the headless cold start and never fire again.
///
/// Module-level, like every other seam in this file, so `main.dart` can ask
/// the question without importing the handler type, and `false` when no
/// handler has registered yet (headless cold bind, widget-only tests).
bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false;
/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design
/// ADR-4): whether a station-switch dispatch (`playFromMediaId`,
/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for
@@ -291,6 +398,95 @@ AudioProcessingState mapearEstadoProceso(
/// media id).
const accionEqToggle = 'eq_toggle';
/// What an [accionEqToggle] tap resolves to (eq-estado-unico item C).
class DecisionToggleEq {
const DecisionToggleEq({
required this.nuevoActivo,
required this.requiereLlamadaNativa,
});
/// The on/off value the handler must end up holding.
final bool nuevoActivo;
/// Whether the native `AndroidEqualizer` effect must also be told. `false`
/// on a device with no usable Equalizer effect: the flag still flips (so
/// the car button never looks inert and the label still updates) but
/// nothing is pushed to the platform.
final bool requiereLlamadaNativa;
@override
bool operator ==(Object other) =>
other is DecisionToggleEq &&
other.nuevoActivo == nuevoActivo &&
other.requiereLlamadaNativa == requiereLlamadaNativa;
@override
int get hashCode => Object.hash(nuevoActivo, requiereLlamadaNativa);
@override
String toString() =>
'DecisionToggleEq(nuevoActivo: $nuevoActivo, '
'requiereLlamadaNativa: $requiereLlamadaNativa)';
}
/// The equalizer toggle decision, extracted out of `customAction` and
/// `setEcualizadorActivo` so it can be tested on its own (eq-estado-unico
/// item C — this dispatch had ZERO tests: `rg "customAction\(" test/`
/// returned nothing).
///
/// Reported: «pulsando sobre el boton de ecualizar en Android Auto tampoco
/// activaba ni desactivaba». Note what this function deliberately does NOT
/// do: gate the flip on [eqDisponible]. The flag always flips, because the
/// notification/car label is built from it — a tap that changed nothing at
/// all is exactly the "the button does nothing" symptom.
DecisionToggleEq decidirToggleEq({
required bool activoActual,
required bool eqDisponible,
}) => DecisionToggleEq(
nuevoActivo: !activoActual,
requiereLlamadaNativa: eqDisponible,
);
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
/// device's native equalizer actually reports
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
/// `Equalizer.getBandLevelRange()`).
///
/// Top-level and pure so the mapping is testable without a device.
///
/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
/// alto»: the previous implementation normalised across the whole range and
/// interpolated linearly,
///
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
///
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
/// the range is symmetric, and Android guarantees no such thing — the
/// Equalizer contract only promises a min/max pair. On a device reporting,
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
/// real boost: audibly louder, with the on/off button still reading "off"
/// and nothing in the UI to explain it.
///
/// The contract here instead: 0 dB is always exactly 0, and each side of the
/// scale is stretched independently against its own end of the native range,
/// so a cut can never become a boost. A range with no headroom on one side
/// (or none at all) collapses that side to 0 rather than inverting it.
double mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final limitado = db.clamp(-12.0, 12.0);
if (limitado == 0) return 0;
if (limitado > 0) {
// Only genuine headroom above unity counts as boost.
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return (limitado / 12.0) * techo;
}
final suelo = minDecibels < 0 ? minDecibels : 0.0;
return (limitado.abs() / 12.0) * suelo;
}
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
/// after the last one. When [actual] is not found in [presets] (e.g. a
@@ -381,9 +577,18 @@ List<MediaControl> controlesEcualizadorPersonalizados({
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
/// re-declare the list inline, which meant it stayed green while asserting a
/// shape `lib/` no longer produced — a guard that cannot see the thing it
/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit
/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so
/// pulling the pure part out is the only way to test the real thing.
/// guards.
///
/// This doc used to add that `PluriWaveAudioHandler` "cannot be instantiated
/// in a unit test (a real `just_audio.AudioPlayer` needs platform
/// MethodChannels)". That is NOT true with just_audio 0.9.46:
/// `AudioPlayer`'s constructor resolves its platform lazily and only becomes
/// `_active` on a `setUrl`, so the handler constructs fine under
/// `flutter test` and `servicio_audio_eq_estado_unico_test.dart` drives its
/// real `customAction` dispatch. Only calls that reach the native effect stay
/// out of reach (they sit behind `_eqDisponible`, `false` off-device).
/// Extracting the pure part is still worth it — it is cheaper and states the
/// contract explicitly — but it is no longer the ONLY way.
///
/// ORDER MATTERS, and only for the car.
///
@@ -582,11 +787,18 @@ class ServicioAudio {
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
PresetEcualizador get presetActual => _handler.presetActual;
/// Forwards the handler's own on/off flag (eq-sync-superficies): a
/// car/notification toggle (`accionEqToggle`) mutates
/// `PluriWaveAudioHandler._ecualizadorActivo` directly, bypassing
/// [setEcualizadorActivo] entirely. [EstadoEcualizador] polls this getter
/// on every [estadoStream] tick to detect and resync that divergence.
/// Forwards the handler's own on/off flag, which since eq-estado-unico is
/// the flag's SINGLE in-memory owner: `EstadoEcualizador._activo` is a
/// display mirror of this getter and `ServicioEcualizador` is its durable
/// copy.
///
/// Corrects a stale claim that stood here: a car/notification toggle does
/// NOT bypass [setEcualizadorActivo]. `PluriWaveAudioHandler.customAction`
/// resolves `accionEqToggle` through `decidirToggleEq` and then calls
/// `setEcualizadorActivo` — the same entry point the phone settings screen
/// uses — so every surface shares one write path, and that path is what
/// persists the value. [EstadoEcualizador] still polls this getter on every
/// [estadoStream] tick, but only to keep its own display in sync.
bool get ecualizadorActivo => _handler.ecualizadorActivo;
Future<void> aplicarPreset(PresetEcualizador preset) =>
@@ -702,13 +914,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// `BehaviorSubject` per id, created lazily on first subscription;
/// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic)
/// value to trigger the platform notification for that id.
///
/// SIN semilla (fix/android-auto-musica-local, item 5). Antes se creaba
/// con `.seeded(<String, dynamic>{})`, y un `BehaviorSubject` reenvía su
/// valor actual a cada nuevo suscriptor: el listener interno de
/// `audio_service` se suscribe la primera vez que el head unit navega un
/// id, recibía esa semilla al instante y la reenviaba como
/// `notifyChildrenChanged` — o sea, el primer browse de CADA id disparaba
/// un `getChildren` extra que nadie pidió. En la raíz eso era un segundo
/// round trip de permisos por `pluriwave/file_actions`, justo en la ruta
/// que ya estaba fallando en el motor sin Activity. Sin semilla no hay
/// nada que reenviar y la invalidación explícita sigue igual.
final _childrenSubjects = <String, BehaviorSubject<Map<String, dynamic>>>{};
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
_childrenSubjects.putIfAbsent(
parentMediaId,
() => BehaviorSubject<Map<String, dynamic>>.seeded(<String, dynamic>{}),
BehaviorSubject<Map<String, dynamic>>.new,
);
/// Invalidates a head unit's cached browse listing for [parentMediaId]
@@ -719,6 +942,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_childrenSubjects[parentMediaId]?.add(<String, dynamic>{});
}
/// True once a head unit has subscribed to at least one browse id, i.e.
/// once [notificarHijosCambiaron] can actually reach the car. Read through
/// the module-level [hayCocheSuscritoAlArbol]; see its doc for why the
/// browse-tree invalidation is gated on it.
bool get hayCocheSuscrito => _childrenSubjects.isNotEmpty;
/// True while the handler is inside the reconnect window. [ServicioAudio]
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
/// indicator instead of an error during retries (S7-R3).
@@ -728,9 +957,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler
AndroidEqualizer? get ecualizador => _eq;
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
bool _ecualizadorActivo = true;
/// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE
/// in-memory owner. `EstadoEcualizador._activo` is now a pure display
/// mirror of this field, and `ServicioEcualizador` is its durable copy.
///
/// It used to be an unconditional `true`, which is exactly why a headless
/// Android Auto engine played with the equalizer on while both the phone
/// UI and disk said off. It now starts from whatever the last disk read
/// produced ([_eqActivoPersistido]); [registrarHandler] then seeds it
/// again from the read port, which is the authoritative path.
bool _ecualizadorActivo = estadoEqInicial(persistido: _eqActivoPersistido);
bool get ecualizadorActivo => _ecualizadorActivo;
/// Write port for [_ecualizadorActivo] (eq-estado-unico item B). Injected
/// by [registrarHandler] so a car/notification toggle is persisted even
/// when no `EstadoEcualizador` has ever been built — which is precisely
/// the headless-bind case where the divergence used to be created.
GuardarEqActivoPersistido? _persistirEqActivo;
/// See [_persistirEqActivo]. Accepts `null` to clear the port (the default
/// for every caller that has no disk).
void registrarPersistenciaEq(GuardarEqActivoPersistido? guardar) {
_persistirEqActivo = guardar;
}
/// The player's live position, used to keep `updatePosition` honest on
/// every `playbackState` push. Exposed so tests can assert the re-push
/// without reaching into the private player.
Duration get posicionActual => _player.position;
/// True while the platform player is attached, i.e. while `just_audio`
/// actually forwards `AudioEffect.setEnabled` to the device
/// (`just_audio.dart:3842-3848` gates it on `_player._active`). Tracked so
/// [debeReasertarEcualizadorNativo] can spot the idle -> active edge.
bool _reproductorActivo = false;
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
int? get androidAudioSessionId => _androidAudioSessionId;
@@ -758,94 +1020,123 @@ class PluriWaveAudioHandler extends BaseAudioHandler
);
}
void _conectarStreamsPlayer() {
_estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing;
final proc = state.processingState;
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
// double-gated on `completed` + an active local queue, so this is a
// no-op for radio (which never emits `completed`) and for
// single-track local playback (which never sets `_colaLocal`).
_manejarFinPista(proc);
if (playing && proc == ProcessingState.ready) {
// Successful (re)connection: reset the backoff so the next stall
// starts over, and leave the reconnect window (S7-R7).
_reconexion.restablecer();
_reconectando = false;
// Local queue (Design ADR-3): the next queued track reached a
// stable playing state — clear the re-entry latch so a LATER
// completion can advance again. A no-op for radio, which never
// sets `_avanzandoCola`.
_avanzandoCola = false;
}
// Local queue transport (Design "Transport wiring"): skip controls
// are only offered while a queue is active — when `_colaLocal` is
// `null` this list/set/index is byte-identical to the pre-change
// radio behavior (regression guard).
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
),
// Android for Cars, "Enable playback control": «Android Auto and
// AAOS display playback controls based on the actions that are
// enabled in the PlaybackStateCompat object. By default, your app
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
//
// This set had carried only `seek` + `stop` since the very first
// commit, so the required transport actions were never advertised.
// The car got away with it for a long time — but Android Auto is a
// separate app that updates itself, so a tolerance it used to have
// can disappear without a single line changing on our side. That
// matches the report exactly: "it used to work, and in the latest
// versions it doesn't", with no audio commit in between that could
// explain it.
//
// The phone notification never depended on any of this: it builds
// its play/pause button from `controls`, which is why the symptom
// is car-only.
systemActions: {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
// Previous/next are advertised ALWAYS now, not only for a local
// queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
),
/// The `playerStateStream` listener's whole body, as a named method.
///
/// Extracted verbatim so a test can drive a real player-state transition
/// through the REAL handler. It used to be an anonymous closure, which is
/// why the equalizer's idle -> active re-assert below shipped with
/// producer-only coverage: [debeReasertarEcualizadorNativo] had five tests
/// and not one of them could reach this wiring, so deleting the re-assert
/// block left the suite green. The only thing left outside a test's reach
/// is the one-line `.listen(manejarEstadoPlayer)` subscription in
/// [_conectarStreamsPlayer].
@visibleForTesting
void manejarEstadoPlayer(PlayerState state) {
final playing = state.playing;
final proc = state.processingState;
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
// double-gated on `completed` + an active local queue, so this is a
// no-op for radio (which never emits `completed`) and for
// single-track local playback (which never sets `_colaLocal`).
_manejarFinPista(proc);
// eq-estado-unico item D: `AudioEffect.setEnabled` is a no-op while
// the platform player is detached, so any toggle made while stopped
// never landed natively. Re-assert the value we own on the idle ->
// active edge. See [debeReasertarEcualizadorNativo].
if (debeReasertarEcualizadorNativo(
estado: proc,
reproductorActivoAntes: _reproductorActivo,
eqDisponible: _eqDisponible,
)) {
unawaited(_reasertarEcualizadorNativo());
}
// Turns a stream of many events into a single idle -> active EDGE: the
// re-assert above fires once per activation, not on every event.
_reproductorActivo = proc != ProcessingState.idle;
if (playing && proc == ProcessingState.ready) {
// Successful (re)connection: reset the backoff so the next stall
// starts over, and leave the reconnect window (S7-R7).
_reconexion.restablecer();
_reconectando = false;
// Local queue (Design ADR-3): the next queued track reached a
// stable playing state — clear the re-entry latch so a LATER
// completion can advance again. A no-op for radio, which never
// sets `_avanzandoCola`.
_avanzandoCola = false;
}
// Local queue transport (Design "Transport wiring"): skip controls
// are only offered while a queue is active — when `_colaLocal` is
// `null` this list/set/index is byte-identical to the pre-change
// radio behavior (regression guard).
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
_trazarEstadoPublicado();
});
// Android for Cars, "Enable playback control": «Android Auto and
// AAOS display playback controls based on the actions that are
// enabled in the PlaybackStateCompat object. By default, your app
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
//
// This set had carried only `seek` + `stop` since the very first
// commit, so the required transport actions were never advertised.
// The car got away with it for a long time — but Android Auto is a
// separate app that updates itself, so a tolerance it used to have
// can disappear without a single line changing on our side. That
// matches the report exactly: "it used to work, and in the latest
// versions it doesn't", with no audio commit in between that could
// explain it.
//
// The phone notification never depended on any of this: it builds
// its play/pause button from `controls`, which is why the symptom
// is car-only.
systemActions: {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
// Previous/next are advertised ALWAYS now, not only for a local
// queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
),
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
_trazarEstadoPublicado();
}
void _conectarStreamsPlayer() {
_estadoPlayerSub = _player.playerStateStream.listen(
manejarEstadoPlayer,
);
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(
@@ -964,10 +1255,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
colaActiva: _colaLocal != null,
playing: playbackState.value.playing,
),
// Must ride along, exactly as in the two sibling emissions in
// `_conectarStreamsPlayer`: `copyWith` stamps a fresh `updateTime`
// but keeps the OLD `updatePosition`, so a push without it tells the
// client "you are at <stale position>, as of right now". Every
// equalizer tap therefore snapped the car's progress bar backwards
// to wherever it stood at the last real player event.
updatePosition: _player.position,
),
);
}
/// Re-states [_ecualizadorActivo] (and the current preset's gains) on the
/// native effect now that the platform player is attached again
/// (eq-estado-unico item D). Delegates to [_activarEcualizador], which is
/// already idempotent and already re-asserts the CURRENT value rather than
/// forcing the equalizer on.
Future<void> _reasertarEcualizadorNativo() async {
_reasercionesEcualizador++;
debugPrint(
'[PluriWave][ServicioAudio] reasertando EQ nativo '
'activo=$_ecualizadorActivo',
);
await _activarEcualizador();
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
///
/// Network-class failures while the user still intends to play enter the
@@ -1360,6 +1672,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_eq = AndroidEqualizer();
_eqDisponible = false;
// Resets alongside its siblings above: the fresh player starts detached,
// so the next non-idle event is a genuine idle -> active edge that
// [debeReasertarEcualizadorNativo] must see. A value stuck at `true`
// across the rebuild would swallow exactly the re-assert this exists for.
_reproductorActivo = false;
_androidAudioSessionId = null;
_ultimaSessionIdEq = null;
_player = _crearPlayer();
@@ -1384,6 +1701,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
try {
final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty;
// eq-estado-unico item E: the ONE number that decides whether
// [mapearGananciaNativa] can be silently boosting a FLAT preset on
// this device. `Equalizer.getBandLevelRange()` is not required to be
// symmetric, and nothing else in the app can observe what it returned.
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
// the release build, which is the only one that ever runs in a car:
//
// adb logcat | grep PluriWave
debugPrint(
'[PluriWave][ServicioAudio] eq rango bandas=${params.bands.length} '
'minDecibels=${params.minDecibels} maxDecibels=${params.maxDecibels} '
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
);
if (_eqDisponible) {
await _eq.setEnabled(_ecualizadorActivo);
await aplicarPreset(_presetActual);
@@ -1411,6 +1741,58 @@ class PluriWaveAudioHandler extends BaseAudioHandler
required bool eqDisponible,
}) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
/// Pure decision for re-asserting the on/off state on the NATIVE effect
/// when the platform player becomes active again (eq-estado-unico item D).
/// No side effects.
///
/// Why it is needed: `just_audio`'s `AudioEffect.setEnabled`
/// (`just_audio.dart:3842-3848`) only reaches the platform while
/// `_player._active` is true. After a `stop()` — or any transition to
/// `idle` — the Dart-side intent is updated but the native effect is not.
/// A user who turns the equalizer off while stopped, then presses play,
/// would get audio that is still equalized with the button reading "off".
///
/// The native effect is treated as WRITE-ONLY throughout: `just_audio`
/// exposes no read-back of `Equalizer.getEnabled()`, so this never
/// compares against the device — it simply re-states the value the app
/// already owns, which is idempotent and cheap.
///
/// [reproductorActivoAntes] is the tracked state BEFORE [estado] arrived,
/// so only the idle -> active edge fires; a player already active does not
/// re-assert on every one of its many events.
@visibleForTesting
static bool debeReasertarEcualizadorNativo({
required ProcessingState estado,
required bool reproductorActivoAntes,
required bool eqDisponible,
}) =>
eqDisponible &&
!reproductorActivoAntes &&
estado != ProcessingState.idle;
/// Forces [_eqDisponible] for a test.
///
/// `_eqDisponible` is only ever set from `AndroidEqualizer.parameters`
/// (see [_activarEcualizador]), whose future only completes on a real
/// device, so off-device it is permanently `false` — and every EQ path
/// worth testing is gated on it. Without this seam
/// [manejarEstadoPlayer]'s re-assert can only ever be exercised on its
/// false branch.
@visibleForTesting
void simularEcualizadorDisponible(bool disponible) {
_eqDisponible = disponible;
}
/// How many times [_reasertarEcualizadorNativo] has actually run.
///
/// The native call it makes is unobservable off-device (see
/// [simularEcualizadorDisponible]), so this counter is the only evidence a
/// test can assert on that the re-assert HAPPENED, rather than that the
/// predicate would have said yes.
@visibleForTesting
int get reasercionesEcualizador => _reasercionesEcualizador;
int _reasercionesEcualizador = 0;
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
@@ -1425,7 +1807,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
@@ -1453,7 +1835,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
final params = await _eq.parameters;
if (index < params.bands.length) {
await params.bands[index].setGain(
_mapearGananciaNativa(
mapearGananciaNativa(
db,
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
@@ -1463,16 +1845,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} catch (_) {}
}
double _mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0);
return minDecibels + (normalizado * (maxDecibels - minDecibels));
}
/// Sets the equalizer on/off state AND persists it — the single entry
/// point every surface goes through (phone settings via
/// `EstadoEcualizador`, the notification, and the car's [accionEqToggle]).
Future<void> setEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: true);
Future<void> setEcualizadorActivo(bool activo) async {
/// Adopts a value that came FROM disk (eq-estado-unico item A). Identical
/// to [setEcualizadorActivo] except that it does not write back — seeding
/// is a read, and echoing it to disk would only add a pointless write on
/// every engine start.
Future<void> sembrarEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: false);
Future<void> _aplicarEcualizadorActivo(
bool activo, {
required bool persistir,
}) async {
_ecualizadorActivo = activo;
if (_eqDisponible) {
try {
@@ -1486,6 +1875,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// of WHO toggled it (a car customAction tap or the phone settings
// screen via EstadoEcualizador).
_actualizarControlesEq();
if (!persistir) return;
_eqActivoPersistido = activo;
final guardar = _persistirEqActivo;
if (guardar == null) return;
// eq-estado-unico item B: the handler owns this write now. It used to
// be `EstadoEcualizador._resincronizarConHandler`'s job, which meant a
// toggle made in the car or from the notification was only saved if a
// phone UI object happened to exist — on a headless Android Auto engine
// it never did, so the car toggle was silently lost on every restart.
//
// Failures are swallowed on purpose: a full disk must not turn the
// equalizer button into a crash.
try {
await guardar(activo);
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo persistir el estado EQ: $e',
);
}
}
Future<void> setVolumen(double vol) async {
@@ -1717,7 +2125,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
]) async {
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
final decision = decidirToggleEq(
activoActual: _ecualizadorActivo,
eqDisponible: _eqDisponible,
);
debugPrint(
'[PluriWave][ServicioAudio] customAction $name -> '
'activo=${decision.nuevoActivo} '
'nativo=${decision.requiereLlamadaNativa}',
);
await setEcualizadorActivo(decision.nuevoActivo);
}
}
@@ -1777,8 +2194,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (bloqueada != null) return bloqueada;
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (parentMediaId == AudioService.browsableRootId) {
// fix/android-auto-musica-local: la RAÍZ ya no se decide con el
// round trip de permisos. Ese round trip viaja por
// `pluriwave/file_actions`, cuyo handler nativo solo se registra en
// `MainActivity.configureFlutterEngine` — en el motor headless que
// Android Auto levanta sin Activity no existe, la llamada lanzaba
// `MissingPluginException` y el nodo desaparecía del árbol. Y como
// el head unit CACHEA la raíz, seguía desaparecido toda la sesión.
//
// Ahora solo `noConfigurada` (sin URI persistida, o permiso
// revocado confirmado por el nativo) oculta el nodo;
// `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica
// el problema (`hijosMusicaLocal`) en vez de dejar una carpeta
// vacía.
final incluirMusicaLocal =
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
fuenteLocal != null &&
await fuenteLocal.estadoCarpeta() !=
EstadoCarpetaLocal.noConfigurada;
return constructor.raiz(
incluirMusicaLocal: incluirMusicaLocal,
premium: premium,
+13 -6
View File
@@ -127,12 +127,19 @@ class ServicioComprasPlayBilling implements PuertoCompras {
void _alRecibirCompras(List<PurchaseDetails> compras) {
if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore completes without ever
// pushing a PurchaseDetails (Spec "Restore finds nothing") — there is
// no per-call correlation in this stream, so this fires on ANY empty
// batch. In practice `queryPastPurchases`/`restorePurchases` on an
// account with nothing to restore is the only source of an empty
// batch this stream would ever emit.
// `restorePurchases()` with nothing to restore pushes an EMPTY batch
// (`in_app_purchase_android` does `_purchaseUpdatedController.add(
// pastPurchases)` unconditionally) — there is no per-call correlation
// in this stream, so this fires on ANY empty batch. In practice
// `restorePurchases` on an account with nothing to restore is the only
// source of an empty batch this stream would ever emit.
//
// Returning silently here (as this did before) left
// [TipoEventoCompra.noEncontrada] NEVER emitted, so
// `EstadoEntitlement._compraEnCurso` stayed `true` forever and
// `hoja_premium.dart` kept BOTH buttons disabled — restore AND buy.
// A paywall that cannot be paid.
_eventos.add(const EventoCompra(TipoEventoCompra.noEncontrada));
return;
}
for (final compra in compras) {
+18
View File
@@ -238,6 +238,24 @@ class ServicioEcualizador {
await prefs.setBool(_keyActivo, activo);
}
/// The persisted equalizer on/off flag, or `null` when the user has never
/// touched the toggle.
///
/// Deliberately narrower than [cargar] (eq-estado-unico item A): it reads
/// ONE key and runs none of the migrations, because 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. It must stay cheap
/// and it must never mutate anything.
///
/// `null` is preserved rather than collapsed to a default so that
/// `estadoEqInicial` — not this service — owns the "never persisted"
/// policy in exactly one place.
Future<bool?> leerActivo() async {
final prefs = await _resolverPrefs();
return prefs.getBool(_keyActivo);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
+35 -10
View File
@@ -7,26 +7,33 @@ import '../modelos/preset_ecualizador.dart';
/// Owns the backup (export/import) JSON serialization (S4-R4).
///
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
/// export stays at v2 for backward compat with the old app. State APPLICATION
/// (writing favorites, EQ, alarms back into the app) stays in
/// `eqMultiDeviceEnabled`. v4 extends v3 with `ecualizadorActivo` (the
/// equalizer's global ON/OFF toggle). When the version-N extension
/// parameters are all omitted the export stays at the lower version for
/// backward compat with older app builds. State APPLICATION (writing
/// favorites, EQ, alarms back into the app) stays in
/// `EstadoRadio.importarConfig` — this service only owns serialization,
/// parsing and the envelope shape.
class ServicioExportImport {
const ServicioExportImport();
/// Current backup schema version (v3multi-device EQ).
static const int versionActual = 3;
/// Current backup schema version (v4equalizer on/off toggle).
static const int versionActual = 4;
/// v3 version constant (multi-device EQ) kept for clarity.
static const int versionV3 = 3;
/// Legacy v2 version constant kept for clarity.
static const int versionV2 = 2;
/// Builds the export envelope.
///
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
/// [versionActual] (3) is written. When both are omitted the call behaves
/// identically to the original v2 format (version key stays 2) so old
/// backups keep round-tripping without version bumps.
/// When [presetsPorDispositivo] or [presetsMatriz] or
/// [eqMultiDeviceEnabled] are provided (non-null), at least [versionV3] (3)
/// is written. When [ecualizadorActivo] is ALSO provided (non-null),
/// [versionActual] (4) is written. Omitting all of them behaves identically
/// to the original v2 format (version key stays 2) so old backups keep
/// round-tripping without version bumps.
///
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// and passes through untouched (no re-parsing here).
@@ -45,14 +52,27 @@ class ServicioExportImport {
Map<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
// v4 extension — the equalizer's global ON/OFF toggle. Omitting it
// produces a v3 (or v2)-compatible export.
bool? ecualizadorActivo,
}) {
final tieneExtensionesV3 =
presetsPorDispositivo != null ||
presetsMatriz != null ||
eqMultiDeviceEnabled != null;
final tieneExtensionV4 = ecualizadorActivo != null;
final int version;
if (tieneExtensionV4) {
version = versionActual;
} else if (tieneExtensionesV3) {
version = versionV3;
} else {
version = versionV2;
}
final envelope = <String, dynamic>{
'version': tieneExtensionesV3 ? versionActual : versionV2,
'version': version,
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
// Favorites + groups (preserves grupo_id assignments per station).
// The protected "sin asignar" group is implicit and never exported.
@@ -88,6 +108,11 @@ class ServicioExportImport {
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
}
// v4 extension: only written when explicitly provided.
if (tieneExtensionV4) {
envelope['ecualizadorActivo'] = ecualizadorActivo;
}
return envelope;
}
+69 -1
View File
@@ -53,14 +53,42 @@ class HojaPremium extends StatelessWidget {
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.funcionPremium,
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.
_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
@@ -143,9 +171,49 @@ class HojaPremium extends StatelessWidget {
: () => 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),
),
],
],
),
),
);
}
}
/// One concrete, honest value-list row (fix/import-alarmas-y-paywall).
class _BeneficioPremium extends StatelessWidget {
const _BeneficioPremium({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle_rounded,
size: 18,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 8),
Expanded(
child: Text(texto, style: Theme.of(context).textTheme.bodyMedium),
),
],
),
);
}
}
@@ -0,0 +1,44 @@
group = "es.freetimelab.pluriwave.fileactions"
version = "1.0-SNAPSHOT"
// Sin bloque `buildscript` a proposito: este modulo solo se construye desde
// `android/settings.gradle.kts` de la app, cuyo `pluginManagement` ya pone
// AGP 8.11.1 y Kotlin 2.2.20 en el classpath compartido. Declarar aqui otro
// classpath de AGP arriesga un choque de versiones con el de la app.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "es.freetimelab.pluriwave.fileactions"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
}
defaultConfig {
// Igual que `flutter.minSdkVersion` en Flutter 3.44 (FlutterExtension.kt).
minSdk = 24
}
}
dependencies {
// `androidx.core.content.FileProvider`, para servir la caratula embebida
// cacheada desde la autoridad `${applicationId}.fileprovider` que declara
// el manifiesto del modulo de app.
implementation("androidx.core:core-ktx:1.16.0")
}
@@ -0,0 +1 @@
rootProject.name = 'pluriwave_file_actions'
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,363 @@
package es.freetimelab.pluriwave.fileactions
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.content.FileProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/**
* Activity-FREE half of the `pluriwave/file_actions` channel
* (fix/android-auto-musica-local, item 3).
*
* These four methods -- `hasPersistedPermission`, `listAudioChildren`,
* `resolvePlayableUri`, `readAudioMetadataBatch` -- only ever needed a
* [ContentResolver][android.content.ContentResolver], which is an
* app-scoped API: they never touch an Activity, a window, or
* `startActivityForResult`. They were nevertheless trapped inside
* `MainActivity.configureFlutterEngine`, the ONE place in the whole repo
* that installed a handler on this channel.
*
* That is the reported bug: when Android Auto binds the MediaBrowserService
* before the phone app has been opened, `audio_service` builds a bare
* `FlutterEngine` with no Activity, `configureFlutterEngine` never runs, the
* channel has no handler at all, and every `invokeMethod` on it throws
* `MissingPluginException`. Dart could not tell that apart from "permission
* revoked" and silently dropped "Musica Local" from the car's browse tree.
*
* Living in a real plugin package is what makes them registerable on ANY
* engine: [PluriWaveFileActionsPlugin] is listed in
* `GeneratedPluginRegistrant`, which the `FlutterEngine(Context)` constructor
* runs by itself, headless engine included. An app-module class never could.
*
* `pickMusicFolder` and the recordings-folder intents are deliberately NOT
* here: they need `startActivityForResult` / `startActivity` plus an
* `onActivityResult` callback, so they stay on `MainActivity` in the app
* module, which delegates everything else to this same class -- so both
* engines answer the four SAF methods identically, from ONE implementation.
*/
class FileActionsHandler(private val context: Context) {
private val tag = "PluriWave"
/**
* Answers [call] if it is one of the Activity-free methods, replying
* through [result] and returning `true`. Returns `false` -- WITHOUT
* touching [result] -- for anything else, so `MainActivity` can fall
* through to its own Activity-bound methods on the same channel.
*/
fun manejar(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
"readAudioMetadataBatch" -> {
val treeUri = call.argument<String>("treeUri")
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
Log.d(
tag,
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any?>>())
} else {
result.success(readAudioMetadataBatch(treeUri, documentIds))
}
}
else -> return false
}
return true
}
/**
* Traza el rechazo de un metodo que exige Activity, para que el log
* distinga "no hay Activity aqui" de "el canal no existe". Lo usa
* [PluriWaveFileActionsPlugin] antes de responder `notImplemented()`.
*/
fun trazarNoDisponibleSinActividad(metodo: String) {
Log.d(tag, "file_actions.$metodo needs an Activity; not available here")
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only -- Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* audio MIME types at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) -- used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*
* Persisted URI grants are taken by the app, not by the Activity, so
* this answers identically on an engine with no Activity -- which is
* exactly why it belongs in this class.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
context.contentResolver.persistedUriPermissions.any {
it.uri == parsed && it.isReadPermission
}
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
/**
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
* static review only -- Design "Interfaces / Contracts"): for each of
* [documentIds], extracts title/artist/bitrate/sample-rate and the
* embedded picture via [extraerMetadatosPista]. Never throws across the
* channel boundary -- a malformed [treeUri] (or any other unexpected
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
* already isolated inside [extraerMetadatosPista].
*/
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
return try {
val parsedTree = Uri.parse(treeUri)
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
} catch (error: Throwable) {
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
emptyList()
}
}
/**
* Extracts one [documentId]'s embedded metadata via
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
* review only -- mirrors [listAudioChildren]/[resolvePlayableUri]'s
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
* public constant below API 31) is gated behind
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
* available since API 10 and read unconditionally. A resolvable
* embedded picture is handed to [cachearArteEmbebido]; art-cache
* failures degrade that single field to `null` without failing the
* whole entry. On ANY failure for this [documentId] (unsupported
* format, permission edge case, corrupt file), the row degrades to an
* all-null-but-`documentId` entry instead of throwing --
* `retriever.release()` always runs via `finally`.
*/
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
val retriever = MediaMetadataRetriever()
return try {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
retriever.setDataSource(context, documentUri)
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
val bitrate = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull()
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
// public constant exists on this minSdk, so the raw key is
// used directly, guarded by the version check above.
retriever.extractMetadata(38)?.toIntOrNull()
} else {
null
}
val artUri = try {
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
error
)
null
}
mapOf(
"documentId" to documentId,
"titulo" to titulo,
"artista" to artista,
"bitrate" to bitrate,
"sampleRate" to sampleRate,
"artUri" to artUri
)
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
error
)
mapOf(
"documentId" to documentId,
"titulo" to null,
"artista" to null,
"bitrate" to null,
"sampleRate" to null,
"artUri" to null
)
} finally {
try {
retriever.release()
} catch (_: Throwable) {
// release() failing is not actionable -- the retriever is
// being discarded regardless.
}
}
}
/**
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
* static review only -- Design ADR-1). Writes [picture] bytes to
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
* file already exists, so re-parsing the same track reuses it), returns
* the `content://` URI served via the EXISTING
* `${applicationId}.fileprovider` authority
* (`AndroidManifest.xml`, `pluriwave_file_paths.xml`'s
* `cache-path path="."`) and trims `pluriwave_art/` via [trimArtCache].
* `hash` uses SHA-256 hex because a raw `documentId` may contain
* `:`/`/`, which are illegal in filenames on most filesystems.
*/
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
return try {
val artDir = File(context.cacheDir, "pluriwave_art").apply { mkdirs() }
val artFile = File(artDir, hashDocumentId(documentId))
if (!artFile.exists()) {
artFile.writeBytes(picture)
}
trimArtCache(artDir)
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
artFile
).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
null
}
}
private fun hashDocumentId(documentId: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
* total, deleting the OLDEST-by-mtime entries first. Kept as a
* trivially reviewable loop -- these files are native-owned, so
* round-tripping names to Dart to pick deletions would add channel
* chatter with no testability gain (the `delete()` is native
* regardless, per ADR-1's rationale).
*/
private fun trimArtCache(artDir: File) {
val maxArchivos = 256
val maxBytes = 32L * 1024 * 1024
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
?: return
var totalBytes = archivos.sumOf { it.length() }
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
val masViejo = archivos.removeAt(archivos.size - 1)
totalBytes -= masViejo.length()
masViejo.delete()
}
}
companion object {
const val CHANNEL = "pluriwave/file_actions"
}
}
@@ -0,0 +1,76 @@
package es.freetimelab.pluriwave.fileactions
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
/**
* Registra `pluriwave/file_actions` en TODOS los FlutterEngine de la app.
*
* ## Por que un paquete plugin y no una clase del modulo de app
*
* `AudioServicePlugin.getFlutterEngine` (audio_service 0.18.18,
* `AudioServicePlugin.java:70-75`) construye el engine compartido con
* `new FlutterEngine(context.getApplicationContext())`. Ese constructor
* encadena hasta el maestro con `automaticallyRegisterPlugins = true`
* (verificado en el bytecode de `FlutterEngine`: `FlutterEngine(Context)` ->
* `FlutterEngine(Context, String[])` con `iconst_1`) y ejecuta
* `GeneratedPluginRegister.registerGeneratedPlugins(this)`, que reflexiona
* sobre `io.flutter.plugins.GeneratedPluginRegistrant`.
*
* Es decir: el engine headless registra PLUGINS por si mismo. Por eso
* `shared_preferences` y `just_audio` ya funcionan cuando Android Auto arranca
* la app con el movil bloqueado, y por eso un handler instalado unicamente en
* `MainActivity.configureFlutterEngine` no podia funcionar nunca ahi: sin
* Activity, `configureFlutterEngine` jamas se ejecuta, el canal se queda sin
* handler y cada `invokeMethod` lanza `MissingPluginException`. El nodo
* "Musica Local" desaparecia del arbol del coche.
*
* ## Reparto con MainActivity (decision deliberada)
*
* Este plugin atiende SOLO los cuatro metodos que no necesitan Activity
* ([FileActionsHandler.manejar]) y responde `notImplemented()` al resto, que es
* la respuesta correcta en un engine sin Activity: `pickMusicFolder`,
* `openDirectory`, `viewDirectory` y `openFile` no pueden funcionar sin una.
*
* En el engine CON Activity ambos escriben en el mismo canal, y gana el
* ultimo: el orden es estructural, no casual. `GeneratedPluginRegistrant` corre
* dentro del constructor de `FlutterEngine`, o sea antes de que el engine
* exista como argumento; `FlutterActivityAndFragmentDelegate.onAttach` llama a
* `host.configureFlutterEngine(flutterEngine)` despues, necesariamente con un
* engine ya construido. Asi que en una Activity siempre gana el handler
* combinado de `MainActivity`, que es el superconjunto: delega los cuatro
* metodos SAF en esta MISMA clase [FileActionsHandler] y añade los suyos.
*
* Se descarto hacer el plugin `ActivityAware` y moverle tambien los metodos con
* Activity: obligaria a trasladar ~400 lineas de malabares de `Intent`
* (FileProvider, DocumentsUI, fallbacks de `ACTION_VIEW`) mas el round trip de
* `onActivityResult`, todo ello sin cobertura de `flutter test`, para arreglar
* un bug que no los toca. El reparto de arriba deja UNA sola implementacion de
* la logica compartida, que era el objetivo real.
*/
class PluriWaveFileActionsPlugin : FlutterPlugin {
private var canal: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// applicationContext a proposito: los cuatro metodos solo usan el
// ContentResolver y la cacheDir del proceso, asi que sobreviven a
// cualquier Activity y valen igual en el engine headless.
val handler = FileActionsHandler(binding.applicationContext)
canal = MethodChannel(binding.binaryMessenger, FileActionsHandler.CHANNEL).apply {
setMethodCallHandler { call, result ->
if (!handler.manejar(call, result)) {
handler.trazarNoDisponibleSinActividad(call.method)
result.notImplemented()
}
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// Solo ocurre al destruir el engine, de modo que nunca puede pisar el
// handler combinado que instala MainActivity sobre este mismo canal.
canal?.setMethodCallHandler(null)
canal = null
}
}
@@ -0,0 +1,20 @@
/// Este paquete NO expone API Dart.
///
/// Existe por una sola razon estructural: un `MethodChannel` registrado desde
/// el modulo de aplicacion (`MainActivity.configureFlutterEngine`) solo vive en
/// el engine que tiene Activity. `audio_service` construye ademas un
/// FlutterEngine *headless* (`AudioServicePlugin.getFlutterEngine`, que llama a
/// `new FlutterEngine(context.getApplicationContext())`) cuando Android Auto
/// enlaza el `MediaBrowserService` con la app cerrada. Ese constructor invoca
/// `GeneratedPluginRegister.registerGeneratedPlugins`, que reflexiona sobre
/// `io.flutter.plugins.GeneratedPluginRegistrant`; es decir, registra los
/// PLUGINS, nunca una clase suelta del modulo de app.
///
/// Empaquetando el lado nativo aqui, `GeneratedPluginRegistrant` lo instala en
/// los dos engines sin tocar el manifiesto ni forkear `audio_service`.
///
/// Los llamantes Dart siguen usando `MethodChannel('pluriwave/file_actions')`
/// directamente (`lib/servicios/musica_local_auto.dart`,
/// `lib/estado/estado_grabacion.dart`), asi que este fichero se queda vacio a
/// proposito: cualquier fachada aqui seria una segunda forma de decir lo mismo.
library;
@@ -0,0 +1,23 @@
name: pluriwave_file_actions
description: >-
Canal nativo `pluriwave/file_actions` de PluriWave empaquetado como plugin
Flutter, para que quede registrado en TODOS los FlutterEngine de la app --
incluido el engine headless que audio_service crea cuando Android Auto
arranca el MediaBrowserService sin Activity.
version: 0.0.1
publish_to: 'none'
environment:
sdk: ^3.7.0
flutter: '>=3.3.0'
dependencies:
flutter:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: es.freetimelab.pluriwave.fileactions
pluginClass: PluriWaveFileActionsPlugin
+7
View File
@@ -629,6 +629,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pluriwave_file_actions:
dependency: "direct main"
description:
path: "packages/pluriwave_file_actions"
relative: true
source: path
version: "0.0.1"
provider:
dependency: "direct main"
description:
+10 -1
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.0+154
version: 1.3.1+157
environment:
sdk: ^3.7.0
@@ -56,6 +56,15 @@ dependencies:
# In-app purchase
in_app_purchase: ^3.2.0
# Canal nativo SAF (`pluriwave/file_actions`) empaquetado como plugin para
# que GeneratedPluginRegistrant lo instale TAMBIEN en el FlutterEngine
# headless que audio_service crea al arrancar desde Android Auto. Sin esto
# el canal solo existia en el engine de MainActivity y "Musica Local"
# desaparecia del arbol del coche. No expone API Dart: los llamantes siguen
# usando MethodChannel('pluriwave/file_actions').
pluriwave_file_actions:
path: packages/pluriwave_file_actions
# Song recognition (activar con AudD key)
# permission_handler: ^11.3.1
@@ -0,0 +1,203 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/main.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
///
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
/// `didChangeDependencies`, con un latch de un solo uso y este comentario:
/// «Que exista una View significa que hay Activity». La premisa es FALSA.
///
/// `runApp` envuelve SIEMPRE el árbol en una `View` construida a partir de
/// `platformDispatcher.implicitView`, y lanza `StateError` si no la hay
/// (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). Así que
/// en el motor headless que `audio_service` levanta sin Activity —el mismo
/// que demostrablemente llega a `runApp`, ver la doc de
/// `aplicarPoliticaOrientacion`— `View.maybeOf(context)` ya es no-nulo en el
/// PRIMER `didChangeDependencies`.
///
/// Consecuencia: el latch se gastaba durante el arranque headless, justo en
/// el instante en que no podía conseguir nada (`_childrenSubjects` sigue
/// vacío, y `notificarHijosCambiaron` es `_childrenSubjects[id]?.add(...)`,
/// un no-op silencioso). Y no podía volver a dispararse nunca, porque
/// `didChangeDependencies` no se re-ejecuta cuando más tarde se adjunta una
/// Activity al MISMO motor cacheado. La vía de recuperación estaba muerta en
/// los dos motores.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
test('resumed + coche ya suscrito + latch libre invalida', () {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: AppLifecycleState.resumed,
hayCocheSuscrito: true,
yaInvalidado: false,
),
isTrue,
);
});
test('sin suscripción del coche NO invalida — y por tanto no gasta el '
'latch en el arranque headless', () {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: AppLifecycleState.resumed,
hayCocheSuscrito: false,
yaInvalidado: false,
),
isFalse,
reason:
'notificarHijosCambiaron solo empuja a un sujeto que ya existe, '
'así que invalidar antes de que el coche se suscriba a NADA es '
'demostrablemente un no-op',
);
});
test('ningún estado del ciclo de vida distinto de resumed invalida', () {
for (final estado in [
AppLifecycleState.detached,
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
]) {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: estado,
hayCocheSuscrito: true,
yaInvalidado: false,
),
isFalse,
reason:
'$estado no significa «hay una Activity adjunta en primer '
'plano»; solo resumed lo significa',
);
}
});
test('con el latch ya gastado no vuelve a invalidar (nada de tormenta '
'de notificaciones)', () {
expect(
debeInvalidarArbolAutoAlReanudar(
estado: AppLifecycleState.resumed,
hayCocheSuscrito: true,
yaInvalidado: true,
),
isFalse,
);
});
});
group('OrientacionResponsiveApp — cableado real del disparador', () {
testWidgets('bajo pumpWidget/runApp SIEMPRE existe una View, que es '
'exactamente por qué el disparador anterior no valía', (tester) async {
await tester.pumpWidget(
const OrientacionResponsiveApp(child: SizedBox.shrink()),
);
expect(
View.maybeOf(tester.element(find.byType(SizedBox))),
isNotNull,
reason:
'wrapWithDefaultView envuelve el árbol en una View o lanza '
'StateError: no hay ningún motor bajo runApp sin View',
);
});
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 = PluriWaveAudioHandler();
registrarHandler(handler);
var invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
await tester.pumpWidget(
const OrientacionResponsiveApp(child: SizedBox.shrink()),
);
await tester.pump();
expect(
invalidaciones,
0,
reason: 'el primer frame no prueba que haya Activity',
);
// Incluso si un evento de ciclo de vida llegara en frío: el coche no
// ha navegado nada todavía, así que no hay ningún sujeto al que
// empujar y el latch debe sobrevivir.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(invalidaciones, 0);
// Ahora el coche navega la raíz (esto es lo que crea el sujeto), y la
// siguiente vuelta a primer plano sí encuentra algo que invalidar.
handler.subscribeToChildren(AudioService.browsableRootId);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(invalidaciones, 1);
});
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 = PluriWaveAudioHandler();
registrarHandler(handler);
// El coche navegó la raíz durante el arranque headless: el sujeto
// existe y el head unit tiene el listado cacheado.
final eventos = <Map<String, dynamic>>[];
final sub = handler
.subscribeToChildren(AudioService.browsableRootId)
.listen(eventos.add);
addTearDown(sub.cancel);
await tester.pumpWidget(
const OrientacionResponsiveApp(child: SizedBox.shrink()),
);
await tester.pump();
expect(
eventos,
isEmpty,
reason: 'todavía no hay Activity, solo una View',
);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(
eventos,
hasLength(1),
reason:
'esta es la ÚNICA vía de recuperación cuando el registro del '
'canal pluriwave/file_actions falló en el motor headless',
);
// Y no una por cada rebote de ciclo de vida.
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(eventos, hasLength(1));
});
});
group('hayCocheSuscritoAlArbol', () {
test('es false sin handler suscrito y true en cuanto el coche navega un '
'id', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
expect(hayCocheSuscritoAlArbol(), isFalse);
handler.subscribeToChildren(AudioService.browsableRootId);
expect(hayCocheSuscritoAlArbol(), isTrue);
});
});
}
+284
View File
@@ -0,0 +1,284 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_backup.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Regression coverage for the data-loss bug (fix/import-alarmas-y-paywall):
/// `EstadoRadio.importarConfig` writes the imported alarm block straight to
/// SharedPreferences, but `EstadoAlarmas` is a separate long-lived
/// `ChangeNotifier` that loaded its alarms into memory at construction and
/// never re-reads on its own. These tests exercise the EXACT sequence the
/// real call site (`pantalla_ajustes_backup.dart`'s `_importar`) now runs
/// after a successful import: `EstadoRadio.importarConfig` followed by
/// `EstadoAlarmas.cargarPersistidasSinRecalcular()` +
/// `EstadoAlarmas.refrescarProgramacion()` — bypassing the file_picker
/// platform channel and the confirmation dialog, which are pure UI
/// plumbing already covered by `pantalla_ajustes_backup_test.dart`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
// A PRIVATE per-test file, never the shared `test/fixtures/` one:
// `EstadoRadio.importarConfig` unconditionally calls
// `_guardarEmisorasCustom()`, which WRITES to whatever
// `resolverArchivoCustom` resolves to — pointing that at the shared
// fixture previously clobbered its committed BOM on disk as a side
// effect of running this file's tests.
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_estado_alarmas_import_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
Future<File> archivoCustomVacio() async {
final file = File('${tempDir.path}/emisoras_custom.json');
if (!file.existsSync()) {
await file.writeAsString('[]');
}
return file;
}
Map<String, dynamic> jsonAlarma(AlarmaMusical a) => {
'id': a.id,
'nombre': a.nombre,
'activa': a.activa,
'hora': a.hora,
'minuto': a.minuto,
'tipoProgramacion': a.tipoProgramacion.name,
'diasSemana': a.diasSemana,
};
const alarmaVieja = AlarmaMusical(
id: 'vieja',
nombre: 'Alarma vieja (pre-import)',
hora: 6,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [1, 2, 3, 4, 5],
);
const alarmaImportada = AlarmaMusical(
id: 'importada',
nombre: 'Alarma importada',
hora: 8,
minuto: 15,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [6, 7],
);
/// Builds the pair the app wires together: `EstadoRadio` (owns
/// `importarConfig`) and `EstadoAlarmas` (owns the alarm reload +
/// re-scheduling this bugfix adds), sharing ONE `SharedPreferences`
/// instance exactly like the real app's provider tree does.
Future<
({
EstadoRadio radio,
EstadoAlarmas alarmas,
FakePuertoAlarmasAndroid android,
})
>
crearPar() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
'alarmas_musicales_v1',
jsonEncode({
'alarmas': [jsonAlarma(alarmaVieja)],
'vacaciones': [],
'excepciones': [],
}),
);
final android = FakePuertoAlarmasAndroid();
final alarmas = EstadoAlarmas(
esPremium: () => true,
servicio: ServicioAlarmas(prefs: prefs),
android: android,
prefs: prefs,
iniciarAutomaticamente: false,
);
// Loads + native-syncs the pre-import alarm WITHOUT arming
// `inicializar()`'s periodic timers (irrelevant to this bugfix and a
// needless liability for a `flutter test` run).
await alarmas.refrescarProgramacion();
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: archivoCustomVacio,
prefs: prefs,
iniciarAutomaticamente: false,
);
return (radio: radio, alarmas: alarmas, android: android);
}
Map<String, dynamic> backupCon({
required List<AlarmaMusical> alarmas,
List<Map<String, dynamic>> vacaciones = const [],
List<Map<String, dynamic>> excepciones = const [],
String ordenListas = 'nombre',
}) => {
'version': 2,
'gruposFavoritos': [],
'favoritos': [],
'emisorasCustom': [],
'presetsEcualizador': {},
'alarmas': {
'alarmas': alarmas.map(jsonAlarma).toList(),
'vacaciones': vacaciones,
'excepciones': excepciones,
},
'emisoraPreferidaUuid': null,
'ordenListas': ordenListas,
'timerSuenoPresetsSegundos': <int>[300, 600],
};
test('after import, EstadoAlarmas reflects the imported alarms, not the '
'pre-import ones', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada]),
);
expect(par.alarmas.alarmas.map((a) => a.id), ['importada']);
expect(par.alarmas.alarmas.single.nombre, 'Alarma importada');
});
test('native re-scheduling is triggered after an import', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
// Sanity: the pre-import alarm was already scheduled.
expect(par.android.programadas.map((a) => a.id), contains('vieja'));
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada]),
);
// The imported alarm was handed to the native Android bridge — this is
// what makes it actually ring, not just appear in the list.
expect(par.android.programadas.map((a) => a.id), contains('importada'));
});
test(
'vacation ranges and alarm exceptions in the same block come back too',
() async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
expect(par.alarmas.vacaciones, isEmpty);
expect(par.alarmas.excepciones, isEmpty);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(
alarmas: [alarmaImportada],
vacaciones: [
{
'id': 'vac1',
'nombre': 'Verano',
'inicio': '2026-07-01T00:00:00.000',
'fin': '2026-07-15T00:00:00.000',
'activo': true,
},
],
excepciones: [
{
'alarmaId': 'importada',
'ejecucion': '2026-08-30T08:15:00.000',
'tipo': 'skipNext',
},
],
),
);
expect(par.alarmas.vacaciones.map((v) => v.id), ['vac1']);
expect(par.alarmas.excepciones.map((e) => e.alarmaId), ['importada']);
},
);
test('a failed import (e.g. malformed/unsupported version) leaves existing '
'alarms untouched', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
final backupNoSoportado = backupCon(alarmas: [alarmaImportada])
..['version'] = 99;
// Runs the SAME production function the call site uses: a throw from
// `importarConfig` must propagate before either reload call runs.
await expectLater(
aplicarImportacionConfig(par.radio, par.alarmas, backupNoSoportado),
throwsA(anything),
);
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
expect(par.android.programadas.map((a) => a.id), ['vieja']);
});
test('a cancelled import (dialog declined, importarConfig never called) '
'leaves existing alarms untouched', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
// Simulates the user declining the confirm dialog: the call site
// returns before `importarConfig` and the two reload calls ever run.
expect(par.alarmas.alarmas.map((a) => a.id), ['vieja']);
expect(par.android.programadas.map((a) => a.id), ['vieja']);
});
test('regression: importing still restores preferences (ordenListas) '
'exactly as before', () async {
final par = await crearPar();
addTearDown(par.radio.dispose);
addTearDown(par.alarmas.dispose);
addTearDown(par.android.dispose);
await aplicarImportacionConfig(
par.radio,
par.alarmas,
backupCon(alarmas: [alarmaImportada], ordenListas: 'nombre'),
);
expect(par.radio.ordenListas.name, 'nombre');
});
}
+94 -4
View File
@@ -1569,6 +1569,83 @@ void main() {
});
});
group(
'EstadoEcualizador — importarConfiguracion(activo:) '
'(equalizer on/off export/import gap)',
() {
test(
'activo: false turns the equalizer off — persisted AND pushed to '
'the live audio engine (reuses cambiarActivo, not a bare field set)',
() async {
final servicio = FakeServicioEcualizador(activo: true);
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
await eq.importarConfiguracion(
principal: PresetEcualizador.flat,
porEmisora: {},
activo: false,
);
expect(eq.activo, isFalse);
expect(audio.cambiosEcualizadorActivo, contains(false));
expect(servicio.config.activo, isFalse);
expect(servicio.guardarActivoLlamadas, 1);
eq.dispose();
},
);
test(
'activo: true turns the equalizer on — persisted AND pushed to '
'the live audio engine',
() async {
final servicio = FakeServicioEcualizador(activo: false);
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
await eq.importarConfiguracion(
principal: PresetEcualizador.flat,
porEmisora: {},
activo: true,
);
expect(eq.activo, isTrue);
expect(audio.cambiosEcualizadorActivo, contains(true));
expect(servicio.config.activo, isTrue);
expect(servicio.guardarActivoLlamadas, 1);
eq.dispose();
},
);
test(
'activo: null (old backup, no field) leaves the current toggle '
'untouched and does not persist anything for it',
() async {
final servicio = FakeServicioEcualizador(activo: false);
final audio = FakeServicioAudio();
final eq = EstadoEcualizador(audio: audio, servicio: servicio);
await eq.cargarPersistido();
audio.cambiosEcualizadorActivo.clear();
await eq.importarConfiguracion(
principal: PresetEcualizador.flat,
porEmisora: {},
// activo omitted — simulates a pre-v4 backup.
);
expect(eq.activo, isFalse);
expect(audio.cambiosEcualizadorActivo, isEmpty);
expect(servicio.guardarActivoLlamadas, 0);
eq.dispose();
},
);
},
);
group('EstadoEcualizador — bonded Bluetooth names', () {
const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF';
@@ -1791,8 +1868,8 @@ void main() {
);
test(
'a handler-initiated toggle is persisted through ServicioEcualizador '
'(survives a restart)',
'a handler-initiated toggle is ADOPTED for display and NOT written '
'again from here (eq-estado-unico: the handler owns the write)',
() async {
final fakeAudio = FakeServicioAudio();
final fakeServicio = FakeServicioEcualizador(activo: true);
@@ -1802,8 +1879,21 @@ void main() {
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeServicio.config.activo, isFalse);
expect(fakeServicio.guardarActivoLlamadas, equals(1));
expect(
eq.activo,
isFalse,
reason: 'the phone toggle must show what the engine is really doing',
);
expect(
fakeServicio.guardarActivoLlamadas,
equals(0),
reason:
'persistence moved to PluriWaveAudioHandler itself, because '
'this resync only exists while an EstadoEcualizador does — and '
'on the headless Android Auto engine that produced the bug, '
'none ever does. A second write from here would be a second '
'owner of the same fact.',
);
eq.dispose();
},
);
+28
View File
@@ -159,6 +159,34 @@ void main() {
expect(estado.compraEnCurso, isFalse);
});
test('tras restaurar() sin compras el paywall NO queda bloqueado: se '
'puede volver a comprar', () async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
final estado = EstadoEntitlement(
prefs: await SharedPreferences.getInstance(),
compras: compras,
);
addTearDown(estado.dispose);
await Future<void>.delayed(Duration.zero);
unawaited(estado.restaurar());
await Future<void>.delayed(Duration.zero);
expect(estado.compraEnCurso, isTrue);
compras.emitir(const EventoCompra(TipoEventoCompra.noEncontrada));
await Future<void>.delayed(Duration.zero);
// `compraEnCurso` deshabilita AMBOS botones de `hoja_premium.dart`
// (comprar y restaurar): si se queda pegado en `true`, el usuario ya no
// puede pagar nunca más.
expect(estado.compraEnCurso, isFalse);
unawaited(estado.comprar());
await Future<void>.delayed(Duration.zero);
expect(compras.comprasIntentadas, 1);
});
test(
'un error en el flujo de compra no bloquea al pagador (fail-open)',
() async {
@@ -0,0 +1,171 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Closes the last remaining export/import gap: `EstadoEcualizador._activo`
/// (the equalizer's global ON/OFF toggle) was not part of the backup
/// envelope at all, so restoring a backup on another device silently kept
/// whatever that device's toggle happened to be. These tests exercise the
/// flag end to end through `EstadoRadio.exportarConfig`/`importarConfig`,
/// the real call sites `pantalla_ajustes_backup.dart` uses.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
SharedPreferences.setMockInitialValues({});
// A PRIVATE per-test file, never the shared `test/fixtures/` one — see
// `estado_alarmas_import_test.dart` for why: `importarConfig`
// unconditionally writes to whatever `resolverArchivoCustom` resolves to.
tempDir = await Directory.systemTemp.createTemp(
'pluriwave_eq_activo_export_test',
);
});
tearDown(() async {
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
Future<EstadoRadio> crearRadio({bool ecualizadorActivo = true}) async {
final prefs = await SharedPreferences.getInstance();
final archivoCustom = File('${tempDir.path}/emisoras_custom.json');
if (!archivoCustom.existsSync()) {
await archivoCustom.writeAsString('[]');
}
final radio = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(activo: ecualizadorActivo),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
resolverArchivoCustom: () async => archivoCustom,
prefs: prefs,
iniciarAutomaticamente: false,
);
await radio.ecualizador.cargarPersistido();
return radio;
}
Map<String, dynamic> backupBase({
required int version,
bool? ecualizadorActivo,
}) {
final data = <String, dynamic>{
'version': version,
'gruposFavoritos': [],
'favoritos': [],
'emisorasCustom': [],
'presetsEcualizador': {},
'alarmas': null,
'emisoraPreferidaUuid': null,
'ordenListas': 'nombre',
'timerSuenoPresetsSegundos': <int>[300, 600],
};
if (ecualizadorActivo != null) {
data['ecualizadorActivo'] = ecualizadorActivo;
}
return data;
}
group('EstadoRadio export/import — equalizer on/off toggle (v4)', () {
test('exportarConfig includes the flag when the equalizer is ON', () async {
final radio = await crearRadio(ecualizadorActivo: true);
addTearDown(radio.dispose);
final exportado = await radio.exportarConfig();
expect(exportado['ecualizadorActivo'], isTrue);
expect(exportado['version'], 4);
});
test(
'exportarConfig includes the flag when the equalizer is OFF',
() async {
final radio = await crearRadio(ecualizadorActivo: false);
addTearDown(radio.dispose);
final exportado = await radio.exportarConfig();
expect(exportado['ecualizadorActivo'], isFalse);
expect(exportado['version'], 4);
},
);
test('importarConfig(activo: false) turns the equalizer off — persisted '
'and reflected in EstadoEcualizador.activo', () async {
final radio = await crearRadio(ecualizadorActivo: true);
addTearDown(radio.dispose);
expect(radio.ecualizador.activo, isTrue);
await radio.importarConfig(
backupBase(version: 4, ecualizadorActivo: false),
);
expect(radio.ecualizador.activo, isFalse);
});
test('importarConfig(activo: true) turns the equalizer on — persisted '
'and reflected in EstadoEcualizador.activo', () async {
final radio = await crearRadio(ecualizadorActivo: false);
addTearDown(radio.dispose);
expect(radio.ecualizador.activo, isFalse);
await radio.importarConfig(
backupBase(version: 4, ecualizadorActivo: true),
);
expect(radio.ecualizador.activo, isTrue);
});
test('importing an OLD backup (no ecualizadorActivo field) does not throw '
'and leaves the current toggle untouched', () async {
final radio = await crearRadio(ecualizadorActivo: false);
addTearDown(radio.dispose);
expect(radio.ecualizador.activo, isFalse);
await radio.importarConfig(backupBase(version: 2));
expect(radio.ecualizador.activo, isFalse);
});
test(
'full round-trip: export -> import restores the equalizer toggle',
() async {
final origen = await crearRadio(ecualizadorActivo: false);
addTearDown(origen.dispose);
final exportado = await origen.exportarConfig();
final destino = await crearRadio(ecualizadorActivo: true);
addTearDown(destino.dispose);
expect(destino.ecualizador.activo, isTrue);
await destino.importarConfig(exportado);
expect(destino.ecualizador.activo, isFalse);
},
);
test('regression: other fields still round-trip exactly as before '
'(ordenListas, timerSuenoPresetsSegundos)', () async {
final origen = await crearRadio();
addTearDown(origen.dispose);
await origen.guardarTimerSuenoPresetsSegundos([120, 900]);
final exportado = await origen.exportarConfig();
final destino = await crearRadio();
addTearDown(destino.dispose);
await destino.importarConfig(exportado);
expect(destino.ordenListas.name, origen.ordenListas.name);
});
});
}
+11
View File
@@ -414,6 +414,17 @@ class FakeServicioEcualizador extends ServicioEcualizador {
);
}
/// Overridden like every other write below: without this, calls fall
/// through to the real `ServicioEcualizador.guardarConfiguracion`, which
/// hits real SharedPreferences and needs a Flutter test binding — a
/// footgun for any test that reaches `EstadoEcualizador.importarConfiguracion`
/// (the backup-import path) through this Fake without also wiring
/// `TestWidgetsFlutterBinding`/mock prefs.
@override
Future<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
_config = config;
}
@override
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final mapa = Map<String, PresetEcualizador>.from(_config.porEmisora);
+14
View File
@@ -46,6 +46,20 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
('pt', 'welcomeBullet2Title'),
('ru', 'welcomeBullet2Title'),
('zh', 'welcomeBullet2Title'),
(
'ar',
'premiumBeneficioAndroidAuto',
), // "Android Auto" -- Google product name (fix/import-alarmas-y-paywall)
('bn', 'premiumBeneficioAndroidAuto'),
('de', 'premiumBeneficioAndroidAuto'),
('fr', 'premiumBeneficioAndroidAuto'),
('hi', 'premiumBeneficioAndroidAuto'),
('id', 'premiumBeneficioAndroidAuto'),
('it', 'premiumBeneficioAndroidAuto'),
('ja', 'premiumBeneficioAndroidAuto'),
('pt', 'premiumBeneficioAndroidAuto'),
('ru', 'premiumBeneficioAndroidAuto'),
('zh', 'premiumBeneficioAndroidAuto'),
// ---------------------------------------------------------------------
// Pure symbols / placeholders -- no translatable text at all.
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_musica_local.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'
show registrarInvalidacionArbolAuto;
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -65,4 +68,81 @@ void main() {
expect(find.text('No folder selected'), findsOneWidget);
});
/// fix/android-auto-musica-local, item 4 — the browse-tree invalidation
/// after a successful folder pick had ZERO coverage and no testability
/// excuse: this file already mounts the screen, `registrarInvalidacionArbolAuto`
/// already takes a fake hook, and `pickMusicFolder` mocks exactly like
/// `hasPersistedPermission` does in `musica_local_auto_test.dart`.
///
/// It matters because Android Auto CACHES the browse root and never asks
/// again on its own: without the call, a driver who picks a folder on the
/// phone keeps getting a car with no «Música Local» entry for the rest of
/// the session.
group('invalidación del árbol de Android Auto tras elegir carpeta', () {
const canal = MethodChannel('pluriwave/file_actions');
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
});
Future<int> pulsarElegirCarpeta(
WidgetTester tester, {
required String? uriDevuelta,
}) async {
SharedPreferences.setMockInitialValues({});
var invalidaciones = 0;
registrarInvalidacionArbolAuto(() => invalidaciones++);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
expect(call.method, 'pickMusicFolder');
return uriDevuelta;
});
await tester.pumpWidget(buildScreen());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('Choose folder'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
return invalidaciones;
}
testWidgets('elegir una carpeta invalida el árbol cacheado del coche', (
tester,
) async {
final invalidaciones = await pulsarElegirCarpeta(
tester,
uriDevuelta: 'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FMyFolder',
);
expect(
invalidaciones,
1,
reason:
'acaba de aparecer música local donde antes no había, y el head '
'unit no vuelve a preguntar por su cuenta',
);
});
testWidgets('cancelar el selector NO invalida nada (el `if (uri == null) '
'return` es deliberado)', (tester) async {
final invalidaciones = await pulsarElegirCarpeta(
tester,
uriDevuelta: null,
);
expect(
invalidaciones,
0,
reason:
'nada cambió, así que forzar un re-browse del árbol entero sería '
'trabajo gratis para el coche',
);
});
});
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/formato_fechas.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
@@ -137,16 +138,29 @@ void main() {
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
// The pill only renders for the ACTIVE or NEXT range
// (`EstadoAlarmas.vacacionesProximas` keeps `inicioDia.isAfter(hoy)`), so
// the range has to be in the future RELATIVE TO THE RUN. Hardcoded
// calendar dates silently rot into the past and turn this into a
// date-dependent failure; anchor on next month instead, days 4-18 so the
// range never straddles a month boundary and the label stays "dd MON".
final ahora = DateTime.now();
final mesSiguiente = DateTime(ahora.year, ahora.month + 1);
final inicio = DateTime(mesSiguiente.year, mesSiguiente.month, 4);
final fin = DateTime(mesSiguiente.year, mesSiguiente.month, 18);
await estado.crearRangoVacaciones(
estado.servicio.crearRangoVacaciones(
inicio: DateTime(2026, 8, 4),
fin: DateTime(2026, 8, 18),
inicio: inicio,
fin: fin,
nombre: 'Summer',
),
);
await montar(tester, estadoAlarmas: estado);
expect(find.text('418 AUG'), findsOneWidget);
// Assert against the same pure formatter the widget uses, so this stays
// a check that the pill IS rendered in the right shape rather than a
// duplicate of the formatter's own logic.
expect(find.text(rangoFechasCorto('en', inicio, fin)), findsOneWidget);
});
});
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/musica_local_auto.dart';
/// `FuenteMusicaLocalAuto.estadoCarpeta` promises, in its own interface doc,
/// «Never throws: cualquier fallo degrada a un valor de
/// [EstadoCarpetaLocal], nunca a una excepción». The refactor to the
/// three-valued enum moved `await _uriPersistida()` OUTSIDE the try/catch,
/// so a SharedPreferences failure escaped again — and the only caller,
/// `PluriWaveAudioHandler.getChildren`'s root branch, awaits it inline, so
/// the throw takes the whole browse root down and empties the car.
///
/// DELIBERATE FILE SEPARATION: nothing here may call
/// `SharedPreferences.setMockInitialValues`. That call swaps
/// `SharedPreferencesStorePlatform.instance` for an in-memory store for the
/// rest of the isolate, and an in-memory store cannot fail. Left alone, the
/// default store answers a real `getAll` platform call that nothing handles
/// under `flutter test`, which is exactly the failure being exercised —
/// hence its own file, not a group inside `musica_local_auto_test.dart`.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('FuenteMusicaLocalAutoImpl.estadoCarpeta — fallo de prefs', () {
test('un fallo leyendo SharedPreferences degrada a noConfigurada en vez '
'de propagar y vaciar la raíz del coche', () async {
// Sin prefs inyectadas: `_resolverPrefs` cae en
// `SharedPreferences.getInstance()`, que aquí lanza.
final fuente = FuenteMusicaLocalAutoImpl();
await expectLater(
fuente.estadoCarpeta(),
completion(EstadoCarpetaLocal.noConfigurada),
);
});
test('ese fallo NO se reporta como canalNoDisponible, que es la única '
'respuesta que significa «hay carpeta pero no puedo comprobar el '
'permiso»', () async {
final fuente = FuenteMusicaLocalAutoImpl();
final estado = await fuente.estadoCarpeta();
expect(
estado,
isNot(EstadoCarpetaLocal.canalNoDisponible),
reason:
'el fallo de prefs es una MissingPluginException igual que la '
'del canal `pluriwave/file_actions`, así que un único try que '
'las capturase juntas borraría la distinción de 3 valores: el '
'árbol mostraría «Música Local» con un subárbol que explica un '
'problema de canal inexistente',
);
});
});
}
+126
View File
@@ -163,6 +163,132 @@ void main() {
},
);
});
/// fix/android-auto-musica-local — «Muchísimas veces (la mayoría) no
/// aparece la opción de reproducir música local, no aparece ni el menú».
///
/// El usuario TIENE la compra PRO, así que no es un problema de
/// entitlement. La causa real: `hasPersistedPermission` viaja por
/// `MethodChannel('pluriwave/file_actions')`, cuyo ÚNICO registro de
/// handler vive en `MainActivity.configureFlutterEngine`. Cuando Android
/// Auto levanta el MediaBrowserService sin que la app se haya abierto,
/// `audio_service` construye un FlutterEngine SIN Activity, ese método
/// nunca corre, el canal se queda sin handler y `invokeMethod` lanza
/// `MissingPluginException` — indistinguible hasta ahora de «permiso
/// revocado».
///
/// Estos tests fijan la distinción: «el canal no está disponible» NO es
/// «no hay carpeta».
group('FuenteMusicaLocalAutoImpl.estadoCarpeta', () {
const canal = MethodChannel('pluriwave/file_actions');
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
});
test(
'con URI persistida y SIN handler nativo (motor headless de Android '
'Auto) reporta canalNoDisponible, no noConfigurada',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
// Sin handler: `invokeMethod` lanza MissingPluginException, que es
// exactamente lo que pasa en el motor sin Activity.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, null);
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(
await fuente.estadoCarpeta(),
EstadoCarpetaLocal.canalNoDisponible,
);
},
);
test(
'con URI persistida y handler nativo que responde false (permiso '
'revocado de verdad) reporta noConfigurada',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
expect(call.method, 'hasPersistedPermission');
return false;
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
},
);
test(
'con URI persistida y handler nativo que responde true reporta '
'configurada',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async => true);
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.configurada);
},
);
test(
'sin URI persistida reporta noConfigurada sin invocar el canal',
() async {
SharedPreferences.setMockInitialValues({});
var llamadas = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
llamadas++;
return true;
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
expect(llamadas, 0);
},
);
test(
'un PlatformException del canal (el handler SÍ existe, la llamada '
'falla) reporta noConfigurada, no canalNoDisponible',
() async {
SharedPreferences.setMockInitialValues({
'musica_local_uri': 'content://tree/x',
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(canal, (call) async {
throw PlatformException(code: 'ERROR');
});
final fuente = FuenteMusicaLocalAutoImpl(
prefs: await SharedPreferences.getInstance(),
);
expect(await fuente.estadoCarpeta(), EstadoCarpetaLocal.noConfigurada);
},
);
});
group('esArchivoAudio', () {
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
+70 -4
View File
@@ -2130,6 +2130,72 @@ void main() {
expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull);
});
/// fix/android-auto-musica-local: la raíz ya no oculta el nodo cuando
/// el canal nativo no está disponible, así que el subárbol tiene que
/// 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 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(
estado: EstadoCarpetaLocal.canalNoDisponible,
);
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(items!.map((i) => i.id), [ConstructorArbolAuto.idLocalNoLista]);
expect(items.single.playable, isFalse);
expect(items.single.title, isNotEmpty);
});
test('canalNoDisponible pero CON pistas resueltas: no se entromete, se '
'listan las pistas normalmente', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
estado: EstadoCarpetaLocal.canalNoDisponible,
hijosPorDocId: {
'': const [
NodoLocal(
documentId: 'd1',
nombre: 'Cancion.mp3',
esDirectorio: false,
),
],
},
);
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(
items!.map((i) => i.id),
isNot(contains(ConstructorArbolAuto.idLocalNoLista)),
);
expect(items.any((i) => i.id == 'pista:d1'), isTrue);
});
test('carpeta genuinamente vacía con el canal SÍ disponible: sigue '
'devolviendo lista vacía, sin item explicativo', () async {
final fuente = _FakeFuenteMusicaLocalAuto();
final items = await hijosMusicaLocal(
ConstructorArbolAuto.idMusicaLocal,
fuente: fuente,
);
expect(items, isNotNull);
expect(items, isEmpty);
});
test('fuente null (cold-start, nunca registrada) devuelve lista vacía '
'para un id de música local válido, no null y sin lanzar', () async {
final resultado = await hijosMusicaLocal(
@@ -3630,7 +3696,7 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
_FakeFuenteMusicaLocalAuto({
bool configurada = true,
EstadoCarpetaLocal estado = EstadoCarpetaLocal.configurada,
Map<String, List<NodoLocal>>? hijosPorDocId,
Map<String, String?>? uriPorDocId,
Map<String, MetadatosPista>? metadatosPorDocId,
@@ -3638,7 +3704,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
Object? errorEnUriContenido,
Object? errorEnMetadatosDe,
Set<String>? idsConErrorEnHijos,
}) : _configurada = configurada,
}) : _estado = estado,
_hijosPorDocId = hijosPorDocId ?? const {},
_uriPorDocId = uriPorDocId ?? const {},
_metadatosPorDocId = metadatosPorDocId ?? const {},
@@ -3647,7 +3713,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
_errorEnMetadatosDe = errorEnMetadatosDe,
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
final bool _configurada;
final EstadoCarpetaLocal _estado;
final Map<String, List<NodoLocal>> _hijosPorDocId;
final Map<String, String?> _uriPorDocId;
final Map<String, MetadatosPista> _metadatosPorDocId;
@@ -3666,7 +3732,7 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
final List<List<String>> llamadasMetadatosDe = [];
@override
Future<bool> hayCarpetaConfigurada() async => _configurada;
Future<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
@override
Future<List<NodoLocal>> hijos(String documentId) async {
@@ -0,0 +1,474 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState;
import 'package:pluriwave/servicios/servicio_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
/// (suena muy alto) pero con el boton desactivado», and «pulsando sobre el
/// boton de ecualizar en Android Auto tampoco activaba ni desactivaba».
///
/// The flag used to live in three independent copies — the handler's
/// hardcoded `_ecualizadorActivo = true`, `EstadoEcualizador._activo`, and
/// SharedPreferences — and the persisted value only ever reached the handler
/// through `EstadoEcualizador.cargarPersistido()`, which a headless Android
/// Auto engine (no Activity, no Provider tree, no `EstadoRadio._init`) never
/// runs. So in the car the handler played with the equalizer forced ON while
/// disk and the phone UI both said OFF.
///
/// NOTE on testability: the long-standing comment in `servicio_audio.dart`
/// claiming `PluriWaveAudioHandler` "cannot be instantiated in a unit test
/// (a real just_audio.AudioPlayer needs platform MethodChannels)" is WRONG
/// as of just_audio 0.9.46 — `AudioPlayer`'s constructor resolves its
/// platform lazily and never becomes active without a `setUrl`, so the
/// handler constructs fine here and every EQ path that does not touch the
/// native effect is directly exercisable. That is what the dispatch tests
/// below rely on; only the native `setEnabled`/`setGain` calls stay out of
/// reach (they sit behind `_eqDisponible`, which is `false` off-device).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
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);
expect(estadoEqInicial(persistido: true), isTrue);
});
test('defaults to ON only when nothing was ever persisted', () {
expect(
estadoEqInicial(persistido: null),
isTrue,
reason: 'a first install keeps the historical default (EQ on)',
);
});
});
group('registrarHandler (A — seeding)', () {
test('consults the injected read port exactly once and seeds the handler '
'with the persisted value', () async {
final handler = PluriWaveAudioHandler();
var lecturas = 0;
registrarHandler(
handler,
leerEqActivoPersistido: () async {
lecturas++;
return false;
},
);
await Future<void>.delayed(Duration.zero);
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.ecualizadorActivo,
isFalse,
reason: 'the handler must adopt what the phone UI persisted',
);
});
test('a read failure leaves the handler on the safe default instead of '
'propagating', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(
handler,
leerEqActivoPersistido: () async => throw StateError('sin disco'),
);
await Future<void>.delayed(Duration.zero);
expect(handler.ecualizadorActivo, isTrue);
});
test('without a read port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = PluriWaveAudioHandler();
await handler.setEcualizadorActivo(false);
registrarHandler(handler);
await Future<void>.delayed(Duration.zero);
expect(handler.ecualizadorActivo, isFalse);
});
});
/// A, CONSTRUCTION-WINDOW half. `_eqActivoPersistido` (the module-level
/// cache behind `_ecualizadorActivo = estadoEqInicial(persistido: ...)`)
/// had zero coverage on BOTH sides: replacing that initialiser with the old
/// hardcoded `= true` left the suite green, and so did deleting the
/// `_eqActivoPersistido = activo` write in `_aplicarEcualizadorActivo`.
///
/// The window it closes is real: `AudioService.init` builds the handler
/// through its `builder` callback and only AFTER that future resolves does
/// `main.dart` reach `registrarHandler`. A car tap landing inside that
/// window would otherwise hit a handler whose flag had never seen disk.
group('A (construction window) — a handler built after a disk read', () {
test('a handler constructed AFTER a read port has already answered '
'starts from the persisted value, not from a hardcoded default',
() async {
// One engine does the read `registrarHandler` performs in main.dart.
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
// have left the cache on `false`, so deleting the disk→cache write in
// `_sembrarEcualizadorDesdeDisco` would still leave this green. Seeding
// does NOT write the cache (`_aplicarEcualizadorActivo` returns before
// it when `persistir: false`), so after this pin that write is the only
// path that can bring the cache back down to `false`.
await primero.setEcualizadorActivo(true);
registrarHandler(primero, leerEqActivoPersistido: () async => false);
await Future<void>.delayed(Duration.zero);
expect(primero.ecualizadorActivo, isFalse);
// Now the construction window: a handler built by `AudioService.init`'s
// builder, with no port of its own yet.
final segundo = PluriWaveAudioHandler();
expect(
segundo.ecualizadorActivo,
isFalse,
reason:
'a car tap landing before registrarHandler must not find the '
'equalizer forced on while disk says off',
);
});
test('the cache follows what the handler itself writes, in both '
'directions', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(false);
expect(
PluriWaveAudioHandler().ecualizadorActivo,
isFalse,
reason:
'the write side of the cache: a toggle must be visible to the '
'next handler built on this engine',
);
await handler.setEcualizadorActivo(true);
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 = PluriWaveAudioHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
await handler.setEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
[false, true],
reason:
'a car/notification toggle must survive a process restart '
'without any UI object existing',
);
});
test('seeding from disk does NOT write back to disk', () async {
final handler = PluriWaveAudioHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
leerEqActivoPersistido: () async => false,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
await Future<void>.delayed(Duration.zero);
expect(handler.ecualizadorActivo, isFalse);
expect(escrituras, isEmpty);
});
test('a failing write port never breaks the toggle', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(
handler,
guardarEqActivoPersistido: (_) async => throw StateError('disco lleno'),
);
await handler.setEcualizadorActivo(false);
expect(handler.ecualizadorActivo, isFalse);
});
});
group('decidirToggleEq (C — the customAction decision)', () {
test('flips the current value', () {
expect(
decidirToggleEq(activoActual: true, eqDisponible: true).nuevoActivo,
isFalse,
);
expect(
decidirToggleEq(activoActual: false, eqDisponible: true).nuevoActivo,
isTrue,
);
});
test('a native call is required only when the effect is attached', () {
expect(
decidirToggleEq(
activoActual: true,
eqDisponible: true,
).requiereLlamadaNativa,
isTrue,
);
expect(
decidirToggleEq(
activoActual: true,
eqDisponible: false,
).requiereLlamadaNativa,
isFalse,
reason:
'with no native Equalizer effect the flag still flips, but '
'nothing is pushed to the platform',
);
});
test('the flag still flips with no native effect — the car button must '
'never look inert', () {
expect(
decidirToggleEq(activoActual: false, eqDisponible: false).nuevoActivo,
isTrue,
);
});
});
group('customAction dispatch (C — zero coverage before this)', () {
test('the accionEqToggle literal routes through decidirToggleEq', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
await handler.setEcualizadorActivo(true);
await handler.customAction(accionEqToggle);
expect(handler.ecualizadorActivo, isFalse);
await handler.customAction(accionEqToggle);
expect(handler.ecualizadorActivo, isTrue);
});
test('a car toggle persists through the same write port as a phone '
'toggle', () async {
final handler = PluriWaveAudioHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
// Explicit starting state. A fresh handler seeds `_ecualizadorActivo`
// from the module-level `_eqActivoPersistido` cache, which any earlier
// test in this file leaves at whatever it last wrote. This assertion
// is about what the TOGGLE does, not about what the previous test
// happened to leave behind — without these two lines, simply
// reordering the tests silently flips the expectation to `[true]`.
await handler.setEcualizadorActivo(true);
escrituras.clear();
await handler.customAction(accionEqToggle);
expect(escrituras, [false]);
});
test('an unknown custom action is a silent no-op', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
final antes = handler.ecualizadorActivo;
await handler.customAction('accion.inexistente');
expect(handler.ecualizadorActivo, antes);
});
});
group('debeReasertarEcualizadorNativo (D — re-assert on activation)', () {
test('an idle -> active transition with the effect attached re-asserts', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.ready,
reproductorActivoAntes: false,
eqDisponible: true,
),
isTrue,
reason:
"just_audio's AudioEffect.setEnabled only reaches the platform "
'while the player is active, so a toggle made while stopped '
'never landed natively',
);
});
test('staying active does not re-assert on every event', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.ready,
reproductorActivoAntes: true,
eqDisponible: true,
),
isFalse,
);
});
test('going idle does not re-assert', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.idle,
reproductorActivoAntes: true,
eqDisponible: true,
),
isFalse,
);
});
test('no attached effect never re-asserts', () {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: ProcessingState.ready,
reproductorActivoAntes: false,
eqDisponible: false,
),
isFalse,
);
});
test('buffering/loading/completed already count as active', () {
for (final estado in [
ProcessingState.loading,
ProcessingState.buffering,
ProcessingState.completed,
]) {
expect(
PluriWaveAudioHandler.debeReasertarEcualizadorNativo(
estado: estado,
reproductorActivoAntes: false,
eqDisponible: true,
),
isTrue,
reason:
'$estado is a non-idle state, i.e. the platform player is '
'attached and accepts effect calls',
);
}
});
});
/// D, WIRING half. Everything above this group tests the pure
/// [PluriWaveAudioHandler.debeReasertarEcualizadorNativo] predicate and
/// nothing else: deleting the `playerStateStream` listener's whole re-assert
/// block — the `if (debeReasertar...) unawaited(_reasertarEcualizadorNativo())`
/// call, the `_reproductorActivo = proc != ProcessingState.idle` edge
/// tracking — left the suite green. That is the SAME producer-only hole that
/// let a dead Android Auto EQ button ship, so it gets closed here rather than
/// re-tested at the predicate.
///
/// [PluriWaveAudioHandler.manejarEstadoPlayer] IS the listener body — the
/// same method `playerStateStream.listen` is subscribed to — so these
/// drive the real handler through real player-state transitions.
group('D (wiring) — the playerState idle -> active edge', () {
PlayerState estado(ProcessingState proc, {bool playing = false}) =>
PlayerState(playing, proc);
test('the first non-idle event re-asserts the native effect exactly '
'once, and staying active never re-asserts again', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
expect(handler.reasercionesEcualizador, 0);
handler.manejarEstadoPlayer(estado(ProcessingState.loading));
expect(
handler.reasercionesEcualizador,
1,
reason:
"just_audio's AudioEffect.setEnabled is a no-op while the "
'platform player is detached, so a toggle made while stopped '
'only lands on this edge',
);
handler.manejarEstadoPlayer(estado(ProcessingState.buffering));
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
handler.manejarEstadoPlayer(estado(ProcessingState.completed));
expect(
handler.reasercionesEcualizador,
1,
reason:
'the player emits many events while active; re-asserting on '
'each one would be a native call storm',
);
});
test('going idle re-arms the edge, so stop + play re-asserts again — '
'this is the `_reproductorActivo = proc != idle` line', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
expect(handler.reasercionesEcualizador, 1);
handler.manejarEstadoPlayer(estado(ProcessingState.idle));
expect(
handler.reasercionesEcualizador,
1,
reason: 'going idle itself never re-asserts',
);
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
expect(
handler.reasercionesEcualizador,
2,
reason:
'without the edge-tracking assignment the flag would stay true '
'and the toggle made while stopped would never land natively',
);
});
test('with no native effect attached nothing is ever re-asserted', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
// `_eqDisponible` is false off-device, which is also the real
// "device has no Equalizer effect" case.
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
handler.manejarEstadoPlayer(estado(ProcessingState.idle));
handler.manejarEstadoPlayer(estado(ProcessingState.ready, playing: true));
expect(handler.reasercionesEcualizador, 0);
});
});
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 = PluriWaveAudioHandler();
registrarHandler(handler);
handler.playbackState.add(
handler.playbackState.value.copyWith(
updatePosition: const Duration(minutes: 3),
),
);
await handler.setEcualizadorActivo(false);
expect(
handler.playbackState.value.updatePosition,
handler.posicionActual,
reason:
'copyWith stamps a fresh updateTime but keeps the OLD '
'updatePosition, so an EQ tap told the car "you are at 3:00, as '
'of right now" and the bar snapped backwards',
);
});
});
}
@@ -0,0 +1,125 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// 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.
///
/// 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:
///
/// normalizado = (db.clamp(-12, 12) + 12) / 24
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
///
/// 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".
///
/// 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', () {
expect(
mapearGananciaNativa(0, minDecibels: -15, maxDecibels: 15),
0,
);
});
test('asymmetric range no longer boosts a FLAT preset', () {
// The reported symptom: on a device reporting [-12, +19] the old
// midpoint mapping turned every 0 dB band into +3.5 dB of real boost.
expect(
mapearGananciaNativa(0, minDecibels: -12, maxDecibels: 19),
0,
reason: 'a FLAT preset must be inaudible, on every device',
);
});
test('a wholly positive range still cannot boost a FLAT preset', () {
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
});
test('a wholly negative range still cannot cut a FLAT preset', () {
expect(mapearGananciaNativa(0, minDecibels: -19, maxDecibels: -3), 0);
});
});
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 maps to the native minimum', () {
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -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 — each side scales against its own end', () {
test('half boost is half of the positive headroom', () {
expect(
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
closeTo(10, 1e-9),
);
});
test('half cut is half of the negative headroom', () {
expect(
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
closeTo(-6, 1e-9),
);
});
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(
db,
minDecibels: -12,
maxDecibels: 19,
);
expect(
nativo.sign,
db.sign,
reason: 'a cut must never become a boost ($db dB -> $nativo)',
);
}
});
});
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
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(-12, minDecibels: -15, maxDecibels: 0), -15);
});
test('a zero-width range collapses everything to 0', () {
expect(mapearGananciaNativa(12, minDecibels: 0, maxDecibels: 0), 0);
expect(mapearGananciaNativa(-12, minDecibels: 0, maxDecibels: 0), 0);
});
test('the result never escapes the native range', () {
for (final db in [-12.0, -3.0, 0.0, 3.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -3,
maxDecibels: 19,
);
expect(nativo, greaterThanOrEqualTo(-3));
expect(nativo, lessThanOrEqualTo(19));
}
});
});
}
+169 -4
View File
@@ -1,5 +1,10 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.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';
import 'package:shared_preferences/shared_preferences.dart';
/// Android Auto play-path backstop (design.md ADR-4, android-auto-media
/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station
@@ -13,6 +18,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
/// handler's dispatch methods delegate to (mirrors `mapearEstadoProceso`
/// and every other pure helper in this file).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('free tier: bloquea cualquier cambio de emisora/salto', () {
expect(debeBloquearCambioDeEmisora(premium: false), isTrue);
});
@@ -21,18 +28,176 @@ void main() {
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
});
group('notificarDesbloqueoAuto / registrarNotificacionDesbloqueoAuto', () {
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
/// transición free -> premium». Android Auto cachea la raíz, así que
/// CUALQUIER momento en el que el árbol pasa a poder mostrar algo que
/// antes no podía tiene que invalidarla — muy en particular, que aparezca
/// por fin una Activity (y con ella el handler nativo del canal
/// `pluriwave/file_actions`) o que el usuario acabe de elegir carpeta.
/// De ahí el nombre neutro.
group('invalidarArbolAuto / registrarInvalidacionArbolAuto', () {
test('sin hook registrado, es un no-op seguro', () {
expect(() => notificarDesbloqueoAuto(), returnsNormally);
expect(() => invalidarArbolAuto(), returnsNormally);
});
test('invoca el hook registrado exactamente una vez por llamada', () {
var llamadas = 0;
registrarNotificacionDesbloqueoAuto(() => llamadas++);
registrarInvalidacionArbolAuto(() => llamadas++);
notificarDesbloqueoAuto();
invalidarArbolAuto();
expect(llamadas, 1);
});
test('registrarHandler conecta la invalidación al handler: una llamada '
'notifica la raíz Y Música Local', () async {
final handler = PluriWaveAudioHandler();
registrarHandler(handler);
final raiz = <Map<String, dynamic>>[];
final local = <Map<String, dynamic>>[];
final subRaiz = handler
.subscribeToChildren(AudioService.browsableRootId)
.listen(raiz.add);
final subLocal = handler
.subscribeToChildren(ConstructorArbolAuto.idMusicaLocal)
.listen(local.add);
invalidarArbolAuto();
await Future<void>.delayed(Duration.zero);
await subRaiz.cancel();
await subLocal.cancel();
expect(raiz, hasLength(1));
expect(local, hasLength(1));
});
});
/// fix/android-auto-musica-local, item 5.
///
/// `subscribeToChildren` sembraba el `BehaviorSubject` con un mapa vacío.
/// El listener interno de `audio_service` se suscribe en cuanto el head
/// unit navega un id, recibe ESE valor semilla de inmediato y lo reenvía
/// como `notifyChildrenChanged` — o sea, el primer browse de cada id
/// provocaba un segundo `getChildren` espurio. En la raíz eso era un
/// SEGUNDO round trip de permisos justo en la ruta que ya estaba
/// fallando. Sin semilla no hay valor que reenviar, y la invalidación
/// explícita (`notificarHijosCambiaron`) sigue funcionando igual.
group('subscribeToChildren', () {
test('el sujeto arranca SIN valor: nada que reenviar en la primera '
'suscripción, así que no hay notifyChildrenChanged espurio', () {
final handler = PluriWaveAudioHandler();
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
});
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
final handler = PluriWaveAudioHandler();
expect(
identical(
handler.subscribeToChildren('musica_local'),
handler.subscribeToChildren('musica_local'),
),
isTrue,
);
expect(
identical(
handler.subscribeToChildren('musica_local'),
handler.subscribeToChildren('favoritos'),
),
isFalse,
);
});
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
() async {
final handler = PluriWaveAudioHandler();
final stream = handler.subscribeToChildren('musica_local');
final recibidos = <Map<String, dynamic>>[];
final sub = stream.listen(recibidos.add);
handler.notificarHijosCambiaron('musica_local');
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(recibidos, hasLength(1));
});
});
/// fix/android-auto-musica-local — «no aparece la opción de reproducir
/// música local, no aparece ni el menú», con la compra PRO hecha.
///
/// La raíz decidía la existencia del nodo con un round trip de permisos
/// por `MethodChannel`. En el motor headless que Android Auto levanta sin
/// Activity ese canal no tiene handler, la llamada lanzaba
/// `MissingPluginException` y el nodo se omitía — y Android Auto CACHEA
/// la raíz, así que se quedaba fuera toda la sesión.
///
/// La pertenencia a la raíz ya no depende de poder contestar esa
/// pregunta: basta con que el estado NO sea [EstadoCarpetaLocal.noConfigurada].
group('getChildren(root): pertenencia de Música Local', () {
setUp(() {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
});
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
final handler = PluriWaveAudioHandler();
final items = await handler.getChildren(AudioService.browsableRootId);
return items.map((i) => i.id).toList();
}
test(
'canalNoDisponible (motor sin Activity, pero el usuario SÍ eligió '
'carpeta): la raíz sigue ofreciendo Música Local',
() async {
expect(
await idsRaizCon(EstadoCarpetaLocal.canalNoDisponible),
contains(ConstructorArbolAuto.idMusicaLocal),
);
},
);
test('configurada: la raíz ofrece Música Local', () async {
expect(
await idsRaizCon(EstadoCarpetaLocal.configurada),
contains(ConstructorArbolAuto.idMusicaLocal),
);
});
test(
'noConfigurada (nunca se eligió carpeta, o el permiso está revocado '
'de verdad): la raíz sigue omitiendo Música Local',
() async {
expect(
await idsRaizCon(EstadoCarpetaLocal.noConfigurada),
isNot(contains(ConstructorArbolAuto.idMusicaLocal)),
);
},
);
});
}
/// Misma forma que `_FakeFuenteMusicaLocalAuto` en
/// `navegacion_auto_test.dart`, reducida a lo que esta suite necesita: solo
/// el estado de la carpeta decide la raíz.
class _FakeFuenteMusicaLocalGating implements FuenteMusicaLocalAuto {
_FakeFuenteMusicaLocalGating(this._estado);
final EstadoCarpetaLocal _estado;
@override
Future<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
@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 {};
}
+102 -6
View File
@@ -1,14 +1,57 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
/// Pure port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] is the ONLY
/// piece of `ServicioComprasPlayBilling` that is unit-testable without a
/// real `in_app_purchase` platform channel — [ServicioComprasPlayBilling]
/// itself is the sole call site (Design ADR-2), exercised instead through
/// `EstadoEntitlement` + a fake `PuertoCompras`
/// Port-boundary tests (Design ADR-2 "keeps Strict TDD viable with zero
/// plugin channels in unit tests"): [eventoDesdeEstadoCompra] es la parte
/// pura, y [ServicioComprasPlayBilling] se ejercita inyectando
/// [_InAppPurchaseFalso] — sin ningún platform channel real.
/// `EstadoEntitlement` se prueba aparte con un `PuertoCompras` falso
/// (`estado_entitlement_test.dart`).
/// Fake [InAppPurchase]: deja que cada test empuje lotes por
/// [purchaseStream] a mano. `noSuchMethod` cubre el resto de la API del
/// plugin, que estos tests no ejercitan.
class _InAppPurchaseFalso implements InAppPurchase {
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
int restauracionesPedidas = 0;
final completadas = <PurchaseDetails>[];
@override
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
@override
Future<void> restorePurchases({String? applicationUserName}) async {
restauracionesPedidas++;
}
@override
Future<void> completePurchase(PurchaseDetails purchase) async {
completadas.add(purchase);
}
void emitir(List<PurchaseDetails> compras) => _compras.add(compras);
Future<void> dispose() => _compras.close();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
PurchaseDetails _compraFalsa(PurchaseStatus status) => PurchaseDetails(
purchaseID: 'compra-1',
productID: ServicioComprasPlayBilling.idProducto,
verificationData: PurchaseVerificationData(
localVerificationData: 'local',
serverVerificationData: 'server',
source: 'google_play',
),
transactionDate: null,
status: status,
);
void main() {
group('eventoDesdeEstadoCompra', () {
test('purchased -> comprada', () {
@@ -50,6 +93,59 @@ void main() {
});
});
group('ServicioComprasPlayBilling.purchaseStream', () {
test('un lote vacio emite noEncontrada (restaurar sin compras)', () async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final tipos = <TipoEventoCompra>[];
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
addTearDown(sub.cancel);
await servicio.restaurar();
iap.emitir(const <PurchaseDetails>[]);
await Future<void>.delayed(Duration.zero);
// Sin este evento `EstadoEntitlement._compraEnCurso` se queda en `true`
// para siempre y `hoja_premium.dart` deshabilita AMBOS botones
// (comprar y restaurar): el usuario no puede pagar.
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.noEncontrada]);
expect(iap.restauracionesPedidas, 1);
});
test('un lote con compras NO emite noEncontrada', () async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final tipos = <TipoEventoCompra>[];
final sub = servicio.eventos.listen((e) => tipos.add(e.tipo));
addTearDown(sub.cancel);
iap.emitir(<PurchaseDetails>[_compraFalsa(PurchaseStatus.restored)]);
await Future<void>.delayed(Duration.zero);
expect(tipos, <TipoEventoCompra>[TipoEventoCompra.restaurada]);
});
test('completa las compras pendientes de confirmar', () async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
final compra =
_compraFalsa(PurchaseStatus.purchased)..pendingCompletePurchase = true;
iap.emitir(<PurchaseDetails>[compra]);
await Future<void>.delayed(Duration.zero);
expect(iap.completadas, <PurchaseDetails>[compra]);
});
});
test('idProducto es el identificador unico no-consumible', () {
expect(ServicioComprasPlayBilling.idProducto, 'pluriwave_premium');
});
@@ -232,4 +232,107 @@ void main() {
},
);
});
group('ServicioExportImport v4 — equalizer on/off toggle', () {
test('v4 export includes ecualizadorActivo=true and schemaVersion 4', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
eqMultiDeviceEnabled: false,
ecualizadorActivo: true,
);
expect(config['version'], 4);
expect(config['ecualizadorActivo'], isTrue);
});
test('v4 export includes ecualizadorActivo=false and schemaVersion 4', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
eqMultiDeviceEnabled: false,
ecualizadorActivo: false,
);
expect(config['version'], 4);
expect(config['ecualizadorActivo'], isFalse);
});
test('v4 round-trip preserves the equalizer toggle exactly', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.jazz,
presetsPorEmisora: {'fav-1': PresetEcualizador.rock},
alarmas: alarmasFixture,
emisoraPreferidaUuid: 'fav-1',
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300, 1800],
eqMultiDeviceEnabled: true,
ecualizadorActivo: false,
);
final json = servicio.exportar(config);
final importado = servicio.importar(json);
expect(importado, isNotNull);
expect(importado!['version'], 4);
expect(importado['ecualizadorActivo'], isFalse);
});
test('omitting ecualizadorActivo keeps the export at v3 (backward compat) '
'and no key is written', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
eqMultiDeviceEnabled: true,
// ecualizadorActivo omitted — emulates a v3 export.
);
expect(config['version'], 3);
expect(config.containsKey('ecualizadorActivo'), isFalse);
});
test('omitting every v3/v4 extension keeps the export at v2 (backward '
'compat)', () {
final config = servicio.construirExportacion(
gruposFavoritos: gruposFavoritosFixture,
favoritos: favoritosFixture,
emisorasCustom: emisorasCustomFixture,
presetPrincipal: PresetEcualizador.flat,
presetsPorEmisora: {},
alarmas: null,
emisoraPreferidaUuid: null,
ordenListas: 'calidad',
timerSuenoPresetsSegundos: const [300],
// No v3/v4 extensions at all.
);
expect(config['version'], 2);
expect(config.containsKey('ecualizadorActivo'), isFalse);
expect(config.containsKey('eqMultiDeviceEnabled'), isFalse);
});
});
}
+223
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_compras.dart';
@@ -9,6 +10,12 @@ import 'package:pluriwave/widgets/hoja_premium.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// fix/import-alarmas-y-paywall: a purchase sheet the user cannot escape is
/// a dark pattern and a Play policy risk. These cover the dismiss
/// affordance, the honest/concrete feature list, and the equalizer guard
/// (the phone equalizer is free for everyone and must never be presented as
/// a premium feature — this has regressed conceptually before).
/// Fake [PuertoCompras] (mirrors `app_test.dart`'s own fake): lets a test
/// drive [EstadoEntitlement]'s purchase-stream events without touching
/// `in_app_purchase`.
@@ -29,6 +36,28 @@ class _PuertoComprasFalso implements PuertoCompras {
Future<void> dispose() => _eventos.close();
}
/// Fake [InAppPurchase]: permite montar el paywall sobre el
/// `ServicioComprasPlayBilling` REAL (no un `PuertoCompras` falso) para cubrir
/// el bloqueo del paywall de extremo a extremo. `noSuchMethod` cubre el resto
/// de la API del plugin, que este test no ejercita.
class _InAppPurchaseFalso implements InAppPurchase {
final _compras = StreamController<List<PurchaseDetails>>.broadcast();
@override
Stream<List<PurchaseDetails>> get purchaseStream => _compras.stream;
@override
Future<void> restorePurchases({String? applicationUserName}) async {
// Play Billing publica un lote VACÍO cuando no hay nada que restaurar.
_compras.add(const <PurchaseDetails>[]);
}
Future<void> dispose() => _compras.close();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// FIX 3 / FIX 9 (code review): the paywall must show localized feedback for
/// a failed purchase/restore, a distinct non-error confirmation when a
/// restore finds nothing, and its own dedicated "premium active" string
@@ -68,6 +97,150 @@ void main() {
return estado;
}
/// Presents `HojaPremium` through the REAL `mostrarHojaPremium` modal
/// route (unlike [bombear], which embeds it directly with no route to
/// pop) so the dismiss controls can be exercised end-to-end exactly as a
/// user would encounter them.
Future<void> bombearComoHoja(
WidgetTester tester, {
required _PuertoComprasFalso compras,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null, compras: compras),
),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder:
(context) => Scaffold(
body: TextButton(
onPressed: () => mostrarHojaPremium(context),
child: const Text('abrir'),
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
}
group('fix/import-alarmas-y-paywall — dismissibility', () {
testWidgets('the close (X) control closes the sheet without purchasing or '
'restoring', (tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('hoja-premium-cerrar')));
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
});
testWidgets(
'the "not now" secondary action closes the sheet without purchasing '
'or restoring',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('hoja-premium-ahora-no')));
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
},
);
testWidgets('the system back gesture also closes the sheet (default '
'isDismissible/enableDrag, no PopScope blocking it)', (tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(find.byType(HojaPremium), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(HojaPremium), findsNothing);
});
testWidgets(
'an already-premium user still gets the close control (no decline '
'needed) and no "not now" button',
(tester) async {
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombearComoHoja(tester, compras: compras);
expect(
find.byKey(const ValueKey('hoja-premium-cerrar')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('hoja-premium-ahora-no')),
findsNothing,
);
},
);
});
group('fix/import-alarmas-y-paywall — honest, concrete copy', () {
testWidgets('lists the 5 features premium actually unlocks', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.premiumBeneficioSinAnuncios), findsOneWidget);
expect(find.text(l10n.premiumBeneficioAndroidAuto), findsOneWidget);
expect(find.text(l10n.premiumBeneficioGrabacion), findsOneWidget);
expect(find.text(l10n.premiumBeneficioVacaciones), findsOneWidget);
expect(find.text(l10n.premiumBeneficioAlarmasIlimitadas), findsOneWidget);
});
testWidgets('states this is a one-time purchase, not a subscription', (
tester,
) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.premiumPagoUnico), findsOneWidget);
});
testWidgets(
'GUARD: the phone equalizer is never presented as a premium feature',
(tester) async {
final compras = _PuertoComprasFalso();
addTearDown(compras.dispose);
await bombear(tester, compras: compras);
expect(find.text(l10n.equalizerTitle), findsNothing);
expect(find.text(l10n.equalizerActive), findsNothing);
expect(find.textContaining('cualizador'), findsNothing);
expect(find.textContaining('qualizer'), findsNothing);
},
);
});
group(
'FIX 9 — la etiqueta de premium activo es propia, no la del ecualizador',
() {
@@ -148,4 +321,54 @@ void main() {
},
);
});
group('bloqueo del paywall — "Restaurar compras" sin compras previas', () {
testWidgets('extremo a extremo (ServicioComprasPlayBilling real): un lote '
'vacío reactiva AMBOS botones, comprar y restaurar', (tester) async {
final iap = _InAppPurchaseFalso();
addTearDown(iap.dispose);
final servicio = ServicioComprasPlayBilling(inAppPurchase: iap);
addTearDown(servicio.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create:
(_) => EstadoEntitlement(prefs: null, compras: servicio),
),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: HojaPremium()),
),
),
);
await tester.pump();
l10n = AppLocalizations.of(tester.element(find.byType(HojaPremium)));
final restaurar = find.byKey(const ValueKey('hoja-premium-restaurar'));
final comprar = find.byKey(const ValueKey('hoja-premium-comprar'));
await tester.tap(restaurar);
await tester.pump();
await tester.pump();
// El botón sigue vivo: si `noEncontrada` nunca llega, `compraEnCurso`
// se queda en `true` y el usuario no puede pagar nunca más.
expect(
tester.widget<OutlinedButton>(restaurar).onPressed,
isNotNull,
reason: 'restaurar debe volver a estar habilitado',
);
expect(
tester.widget<FilledButton>(comprar).onPressed,
isNotNull,
reason: 'comprar debe volver a estar habilitado',
);
expect(find.text(l10n.restauracionSinCompras), findsOneWidget);
});
});
}