fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall

Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

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

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

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

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

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

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

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

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

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

3. El paywall bloqueaba las compras

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

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
This commit is contained in:
2026-09-04 13:26:00 +02:00
parent a5572d2cbd
commit 575ba793ae
34 changed files with 2948 additions and 473 deletions
@@ -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)