Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed33c7dbb | ||
|
|
a5572d2cbd | ||
|
|
98b24d84cd | ||
|
|
72c5777508 | ||
|
|
b69041f32a | ||
|
|
9681a47e83 | ||
|
|
4ca2813267 | ||
|
|
a2bed18937 | ||
|
|
e57f7bb17b | ||
|
|
fdddd95199 | ||
|
|
1bfd5a2348 | ||
|
|
4ea5d2056c | ||
|
|
b5940b2758 | ||
|
|
9efa6d8937 | ||
|
|
186ff45105 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "استعادة المشتريات",
|
||||
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
|
||||
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
|
||||
"premiumActivo": "النسخة المميزة مفعّلة"
|
||||
"premiumActivo": "النسخة المميزة مفعّلة",
|
||||
"premiumHojaTitulo": "افتح PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "بدون إعلانات في التطبيق بالكامل",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "تسجيل المحطات",
|
||||
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
||||
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
||||
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
|
||||
"premiumAhoraNo": "ليس الآن"
|
||||
}
|
||||
|
||||
+9
-1
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
|
||||
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
|
||||
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়"
|
||||
"premiumActivo": "প্রিমিয়াম সক্রিয়",
|
||||
"premiumHojaTitulo": "PluriWave Premium আনলক করুন",
|
||||
"premiumBeneficioSinAnuncios": "পুরো অ্যাপে কোনো বিজ্ঞাপন নেই",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "স্টেশন রেকর্ডিং",
|
||||
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
||||
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
||||
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
|
||||
"premiumAhoraNo": "এখন নয়"
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
|
||||
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
|
||||
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
|
||||
"premiumActivo": "प्रीमियम सक्रिय"
|
||||
"premiumActivo": "प्रीमियम सक्रिय",
|
||||
"premiumHojaTitulo": "PluriWave Premium अनलॉक करें",
|
||||
"premiumBeneficioSinAnuncios": "पूरे ऐप में कोई विज्ञापन नहीं",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "स्टेशन रिकॉर्डिंग",
|
||||
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
||||
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
||||
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
|
||||
"premiumAhoraNo": "अभी नहीं"
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "購入を復元",
|
||||
"compraError": "購入を完了できませんでした。もう一度お試しください。",
|
||||
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
|
||||
"premiumActivo": "プレミアム有効"
|
||||
"premiumActivo": "プレミアム有効",
|
||||
"premiumHojaTitulo": "PluriWave Premiumのロックを解除",
|
||||
"premiumBeneficioSinAnuncios": "アプリ全体で広告なし",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "放送局の録音",
|
||||
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
||||
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
||||
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
|
||||
"premiumAhoraNo": "後で"
|
||||
}
|
||||
|
||||
+9
-1
@@ -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
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "Восстановить покупки",
|
||||
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
|
||||
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
|
||||
"premiumActivo": "Премиум активен"
|
||||
"premiumActivo": "Премиум активен",
|
||||
"premiumHojaTitulo": "Разблокировать PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "Никакой рекламы во всём приложении",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "Запись радиостанций",
|
||||
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
||||
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
|
||||
"premiumAhoraNo": "Не сейчас"
|
||||
}
|
||||
|
||||
+9
-1
@@ -904,5 +904,13 @@
|
||||
"restaurarCompras": "恢复购买",
|
||||
"compraError": "无法完成购买,请重试。",
|
||||
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
|
||||
"premiumActivo": "高级版已解锁"
|
||||
"premiumActivo": "高级版已解锁",
|
||||
"premiumHojaTitulo": "解锁 PluriWave Premium",
|
||||
"premiumBeneficioSinAnuncios": "全应用无广告",
|
||||
"premiumBeneficioAndroidAuto": "Android Auto",
|
||||
"premiumBeneficioGrabacion": "电台录音",
|
||||
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
||||
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
||||
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
|
||||
"premiumAhoraNo": "以后再说"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => 'ليس الآن';
|
||||
}
|
||||
|
||||
@@ -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 => 'এখন নয়';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'अभी नहीं';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '後で';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'Не сейчас';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (v3 — multi-device EQ).
|
||||
static const int versionActual = 3;
|
||||
/// Current backup schema version (v4 — equalizer 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
+363
@@ -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"
|
||||
}
|
||||
}
|
||||
+76
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 "d–d 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('4–18 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user