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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user