fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s

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-08-31 14:34:49 +02:00
parent 10bb017f4c
commit 3449e2cb79
34 changed files with 2948 additions and 473 deletions
@@ -0,0 +1,44 @@
group = "es.freetimelab.pluriwave.fileactions"
version = "1.0-SNAPSHOT"
// Sin bloque `buildscript` a proposito: este modulo solo se construye desde
// `android/settings.gradle.kts` de la app, cuyo `pluginManagement` ya pone
// AGP 8.11.1 y Kotlin 2.2.20 en el classpath compartido. Declarar aqui otro
// classpath de AGP arriesga un choque de versiones con el de la app.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "es.freetimelab.pluriwave.fileactions"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
sourceSets {
getByName("main") {
java.srcDirs("src/main/kotlin")
}
}
defaultConfig {
// Igual que `flutter.minSdkVersion` en Flutter 3.44 (FlutterExtension.kt).
minSdk = 24
}
}
dependencies {
// `androidx.core.content.FileProvider`, para servir la caratula embebida
// cacheada desde la autoridad `${applicationId}.fileprovider` que declara
// el manifiesto del modulo de app.
implementation("androidx.core:core-ktx:1.16.0")
}
@@ -0,0 +1 @@
rootProject.name = 'pluriwave_file_actions'
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,363 @@
package es.freetimelab.pluriwave.fileactions
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.content.FileProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/**
* Activity-FREE half of the `pluriwave/file_actions` channel
* (fix/android-auto-musica-local, item 3).
*
* These four methods -- `hasPersistedPermission`, `listAudioChildren`,
* `resolvePlayableUri`, `readAudioMetadataBatch` -- only ever needed a
* [ContentResolver][android.content.ContentResolver], which is an
* app-scoped API: they never touch an Activity, a window, or
* `startActivityForResult`. They were nevertheless trapped inside
* `MainActivity.configureFlutterEngine`, the ONE place in the whole repo
* that installed a handler on this channel.
*
* That is the reported bug: when Android Auto binds the MediaBrowserService
* before the phone app has been opened, `audio_service` builds a bare
* `FlutterEngine` with no Activity, `configureFlutterEngine` never runs, the
* channel has no handler at all, and every `invokeMethod` on it throws
* `MissingPluginException`. Dart could not tell that apart from "permission
* revoked" and silently dropped "Musica Local" from the car's browse tree.
*
* Living in a real plugin package is what makes them registerable on ANY
* engine: [PluriWaveFileActionsPlugin] is listed in
* `GeneratedPluginRegistrant`, which the `FlutterEngine(Context)` constructor
* runs by itself, headless engine included. An app-module class never could.
*
* `pickMusicFolder` and the recordings-folder intents are deliberately NOT
* here: they need `startActivityForResult` / `startActivity` plus an
* `onActivityResult` callback, so they stay on `MainActivity` in the app
* module, which delegates everything else to this same class -- so both
* engines answer the four SAF methods identically, from ONE implementation.
*/
class FileActionsHandler(private val context: Context) {
private val tag = "PluriWave"
/**
* Answers [call] if it is one of the Activity-free methods, replying
* through [result] and returning `true`. Returns `false` -- WITHOUT
* touching [result] -- for anything else, so `MainActivity` can fall
* through to its own Activity-bound methods on the same channel.
*/
fun manejar(call: MethodCall, result: MethodChannel.Result): Boolean {
when (call.method) {
"listAudioChildren" -> {
val treeUri = call.argument<String>("treeUri")
val parentDocumentId = call.argument<String>("parentDocumentId") ?: ""
Log.d(
tag,
"file_actions.listAudioChildren treeUri=$treeUri parentDocumentId=$parentDocumentId"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any>>())
} else {
result.success(listAudioChildren(treeUri, parentDocumentId))
}
}
"resolvePlayableUri" -> {
val treeUri = call.argument<String>("treeUri")
val documentId = call.argument<String>("documentId")
Log.d(
tag,
"file_actions.resolvePlayableUri treeUri=$treeUri documentId=$documentId"
)
if (treeUri.isNullOrBlank() || documentId.isNullOrBlank()) {
result.success(null)
} else {
result.success(resolvePlayableUri(treeUri, documentId))
}
}
"hasPersistedPermission" -> {
val treeUri = call.argument<String>("treeUri")
Log.d(tag, "file_actions.hasPersistedPermission treeUri=$treeUri")
result.success(
if (treeUri.isNullOrBlank()) false else hasPersistedPermission(treeUri)
)
}
"readAudioMetadataBatch" -> {
val treeUri = call.argument<String>("treeUri")
val documentIds = call.argument<List<String>>("documentIds") ?: emptyList()
Log.d(
tag,
"file_actions.readAudioMetadataBatch treeUri=$treeUri count=${documentIds.size}"
)
if (treeUri.isNullOrBlank()) {
result.success(emptyList<Map<String, Any?>>())
} else {
result.success(readAudioMetadataBatch(treeUri, documentIds))
}
}
else -> return false
}
return true
}
/**
* Traza el rechazo de un metodo que exige Activity, para que el log
* distinga "no hay Activity aqui" de "el canal no existe". Lo usa
* [PluriWaveFileActionsPlugin] antes de responder `notImplemented()`.
*/
fun trazarNoDisponibleSinActividad(metodo: String) {
Log.d(tag, "file_actions.$metodo needs an Activity; not available here")
}
/**
* Walks ONE level of the SAF tree rooted at [treeUri] (android-auto-local-music,
* static review only -- Design "Lazy per-folder enumeration, never an
* eager tree dump"): [parentDocumentId] blank means the tree root
* itself, otherwise the given subfolder's documentId. Filters files to
* audio MIME types at the native layer (lean payload); each returned row
* also carries `mime` so the Dart side can re-validate via
* `esArchivoAudio` (defense-in-depth). Any query failure degrades to an
* empty list rather than throwing.
*/
private fun listAudioChildren(treeUri: String, parentDocumentId: String): List<Map<String, Any>> {
return try {
val parsedTree = Uri.parse(treeUri)
val parentId = parentDocumentId.ifBlank {
DocumentsContract.getTreeDocumentId(parsedTree)
}
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parsedTree, parentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE
)
val resultado = mutableListOf<Map<String, Any>>()
context.contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idxDocId = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val idxNombre = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val idxMime = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idxDocId) ?: continue
val nombre = cursor.getString(idxNombre) ?: continue
val mime = cursor.getString(idxMime) ?: ""
val esDirectorio = mime == DocumentsContract.Document.MIME_TYPE_DIR
if (!esDirectorio && !mime.startsWith("audio/")) continue
resultado.add(
mapOf(
"documentId" to documentId,
"nombre" to nombre,
"esDirectorio" to esDirectorio,
"mime" to mime
)
)
}
}
resultado
} catch (error: Throwable) {
Log.e(tag, "file_actions.listAudioChildren failed treeUri=$treeUri parentDocumentId=$parentDocumentId", error)
emptyList()
}
}
/**
* Resolves a leaf [documentId] within [treeUri] to its playable
* `content://` URI (android-auto-local-music, static review only).
* Returns `null` on any failure instead of throwing.
*/
private fun resolvePlayableUri(treeUri: String, documentId: String): String? {
return try {
val parsedTree = Uri.parse(treeUri)
DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.resolvePlayableUri failed treeUri=$treeUri documentId=$documentId", error)
null
}
}
/**
* Checks whether [treeUri]'s read permission is still among
* [android.content.ContentResolver.getPersistedUriPermissions]
* (android-auto-local-music, static review only) -- used for cold-start
* / revoked-permission detection (Spec "Permission revoked or never
* granted"). Returns `false` (never throws) on a malformed [treeUri] or
* any other failure.
*
* Persisted URI grants are taken by the app, not by the Activity, so
* this answers identically on an engine with no Activity -- which is
* exactly why it belongs in this class.
*/
private fun hasPersistedPermission(treeUri: String): Boolean {
return try {
val parsed = Uri.parse(treeUri)
context.contentResolver.persistedUriPermissions.any {
it.uri == parsed && it.isReadPermission
}
} catch (error: Throwable) {
Log.e(tag, "file_actions.hasPersistedPermission failed treeUri=$treeUri", error)
false
}
}
/**
* Batched embedded-metadata extraction (android-auto-local-music-phase2,
* static review only -- Design "Interfaces / Contracts"): for each of
* [documentIds], extracts title/artist/bitrate/sample-rate and the
* embedded picture via [extraerMetadatosPista]. Never throws across the
* channel boundary -- a malformed [treeUri] (or any other unexpected
* failure) degrades the WHOLE call to `[]`; a per-entry failure is
* already isolated inside [extraerMetadatosPista].
*/
private fun readAudioMetadataBatch(treeUri: String, documentIds: List<String>): List<Map<String, Any?>> {
return try {
val parsedTree = Uri.parse(treeUri)
documentIds.map { documentId -> extraerMetadatosPista(parsedTree, documentId) }
} catch (error: Throwable) {
Log.e(tag, "file_actions.readAudioMetadataBatch failed treeUri=$treeUri", error)
emptyList()
}
}
/**
* Extracts one [documentId]'s embedded metadata via
* [MediaMetadataRetriever] (android-auto-local-music-phase2, static
* review only -- mirrors [listAudioChildren]/[resolvePlayableUri]'s
* never-throws shape). `METADATA_KEY_SAMPLERATE` (raw key `38`, no
* public constant below API 31) is gated behind
* `Build.VERSION.SDK_INT >= 31` (Design ADR-5); every other field is
* available since API 10 and read unconditionally. A resolvable
* embedded picture is handed to [cachearArteEmbebido]; art-cache
* failures degrade that single field to `null` without failing the
* whole entry. On ANY failure for this [documentId] (unsupported
* format, permission edge case, corrupt file), the row degrades to an
* all-null-but-`documentId` entry instead of throwing --
* `retriever.release()` always runs via `finally`.
*/
private fun extraerMetadatosPista(parsedTree: Uri, documentId: String): Map<String, Any?> {
val retriever = MediaMetadataRetriever()
return try {
val documentUri = DocumentsContract.buildDocumentUriUsingTree(parsedTree, documentId)
retriever.setDataSource(context, documentUri)
val titulo = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
val artista = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
val bitrate = retriever
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull()
val sampleRate = if (Build.VERSION.SDK_INT >= 31) {
// METADATA_KEY_SAMPLERATE = 38 (API 31+, Design ADR-5); no
// public constant exists on this minSdk, so the raw key is
// used directly, guarded by the version check above.
retriever.extractMetadata(38)?.toIntOrNull()
} else {
null
}
val artUri = try {
retriever.embeddedPicture?.let { cachearArteEmbebido(documentId, it) }
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch embeddedPicture failed documentId=$documentId",
error
)
null
}
mapOf(
"documentId" to documentId,
"titulo" to titulo,
"artista" to artista,
"bitrate" to bitrate,
"sampleRate" to sampleRate,
"artUri" to artUri
)
} catch (error: Throwable) {
Log.e(
tag,
"file_actions.readAudioMetadataBatch entry failed documentId=$documentId",
error
)
mapOf(
"documentId" to documentId,
"titulo" to null,
"artista" to null,
"bitrate" to null,
"sampleRate" to null,
"artUri" to null
)
} finally {
try {
retriever.release()
} catch (_: Throwable) {
// release() failing is not actionable -- the retriever is
// being discarded regardless.
}
}
}
/**
* Embedded-art cache write + LRU trim (android-auto-local-music-phase2,
* static review only -- Design ADR-1). Writes [picture] bytes to
* `cacheDir/pluriwave_art/<hash(documentId)>` (skips the write when the
* file already exists, so re-parsing the same track reuses it), returns
* the `content://` URI served via the EXISTING
* `${applicationId}.fileprovider` authority
* (`AndroidManifest.xml`, `pluriwave_file_paths.xml`'s
* `cache-path path="."`) and trims `pluriwave_art/` via [trimArtCache].
* `hash` uses SHA-256 hex because a raw `documentId` may contain
* `:`/`/`, which are illegal in filenames on most filesystems.
*/
private fun cachearArteEmbebido(documentId: String, picture: ByteArray): String? {
return try {
val artDir = File(context.cacheDir, "pluriwave_art").apply { mkdirs() }
val artFile = File(artDir, hashDocumentId(documentId))
if (!artFile.exists()) {
artFile.writeBytes(picture)
}
trimArtCache(artDir)
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
artFile
).toString()
} catch (error: Throwable) {
Log.e(tag, "file_actions.cachearArteEmbebido failed documentId=$documentId", error)
null
}
}
private fun hashDocumentId(documentId: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(documentId.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
* LRU-by-`lastModified` trim of the embedded-art cache dir (Design
* ADR-1): after each write, keeps at most 256 files AND at most 32 MB
* total, deleting the OLDEST-by-mtime entries first. Kept as a
* trivially reviewable loop -- these files are native-owned, so
* round-tripping names to Dart to pick deletions would add channel
* chatter with no testability gain (the `delete()` is native
* regardless, per ADR-1's rationale).
*/
private fun trimArtCache(artDir: File) {
val maxArchivos = 256
val maxBytes = 32L * 1024 * 1024
val archivos = artDir.listFiles()?.sortedByDescending { it.lastModified() }?.toMutableList()
?: return
var totalBytes = archivos.sumOf { it.length() }
while (archivos.isNotEmpty() && (archivos.size > maxArchivos || totalBytes > maxBytes)) {
val masViejo = archivos.removeAt(archivos.size - 1)
totalBytes -= masViejo.length()
masViejo.delete()
}
}
companion object {
const val CHANNEL = "pluriwave/file_actions"
}
}
@@ -0,0 +1,76 @@
package es.freetimelab.pluriwave.fileactions
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
/**
* Registra `pluriwave/file_actions` en TODOS los FlutterEngine de la app.
*
* ## Por que un paquete plugin y no una clase del modulo de app
*
* `AudioServicePlugin.getFlutterEngine` (audio_service 0.18.18,
* `AudioServicePlugin.java:70-75`) construye el engine compartido con
* `new FlutterEngine(context.getApplicationContext())`. Ese constructor
* encadena hasta el maestro con `automaticallyRegisterPlugins = true`
* (verificado en el bytecode de `FlutterEngine`: `FlutterEngine(Context)` ->
* `FlutterEngine(Context, String[])` con `iconst_1`) y ejecuta
* `GeneratedPluginRegister.registerGeneratedPlugins(this)`, que reflexiona
* sobre `io.flutter.plugins.GeneratedPluginRegistrant`.
*
* Es decir: el engine headless registra PLUGINS por si mismo. Por eso
* `shared_preferences` y `just_audio` ya funcionan cuando Android Auto arranca
* la app con el movil bloqueado, y por eso un handler instalado unicamente en
* `MainActivity.configureFlutterEngine` no podia funcionar nunca ahi: sin
* Activity, `configureFlutterEngine` jamas se ejecuta, el canal se queda sin
* handler y cada `invokeMethod` lanza `MissingPluginException`. El nodo
* "Musica Local" desaparecia del arbol del coche.
*
* ## Reparto con MainActivity (decision deliberada)
*
* Este plugin atiende SOLO los cuatro metodos que no necesitan Activity
* ([FileActionsHandler.manejar]) y responde `notImplemented()` al resto, que es
* la respuesta correcta en un engine sin Activity: `pickMusicFolder`,
* `openDirectory`, `viewDirectory` y `openFile` no pueden funcionar sin una.
*
* En el engine CON Activity ambos escriben en el mismo canal, y gana el
* ultimo: el orden es estructural, no casual. `GeneratedPluginRegistrant` corre
* dentro del constructor de `FlutterEngine`, o sea antes de que el engine
* exista como argumento; `FlutterActivityAndFragmentDelegate.onAttach` llama a
* `host.configureFlutterEngine(flutterEngine)` despues, necesariamente con un
* engine ya construido. Asi que en una Activity siempre gana el handler
* combinado de `MainActivity`, que es el superconjunto: delega los cuatro
* metodos SAF en esta MISMA clase [FileActionsHandler] y añade los suyos.
*
* Se descarto hacer el plugin `ActivityAware` y moverle tambien los metodos con
* Activity: obligaria a trasladar ~400 lineas de malabares de `Intent`
* (FileProvider, DocumentsUI, fallbacks de `ACTION_VIEW`) mas el round trip de
* `onActivityResult`, todo ello sin cobertura de `flutter test`, para arreglar
* un bug que no los toca. El reparto de arriba deja UNA sola implementacion de
* la logica compartida, que era el objetivo real.
*/
class PluriWaveFileActionsPlugin : FlutterPlugin {
private var canal: MethodChannel? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// applicationContext a proposito: los cuatro metodos solo usan el
// ContentResolver y la cacheDir del proceso, asi que sobreviven a
// cualquier Activity y valen igual en el engine headless.
val handler = FileActionsHandler(binding.applicationContext)
canal = MethodChannel(binding.binaryMessenger, FileActionsHandler.CHANNEL).apply {
setMethodCallHandler { call, result ->
if (!handler.manejar(call, result)) {
handler.trazarNoDisponibleSinActividad(call.method)
result.notImplemented()
}
}
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
// Solo ocurre al destruir el engine, de modo que nunca puede pisar el
// handler combinado que instala MainActivity sobre este mismo canal.
canal?.setMethodCallHandler(null)
canal = null
}
}
@@ -0,0 +1,20 @@
/// Este paquete NO expone API Dart.
///
/// Existe por una sola razon estructural: un `MethodChannel` registrado desde
/// el modulo de aplicacion (`MainActivity.configureFlutterEngine`) solo vive en
/// el engine que tiene Activity. `audio_service` construye ademas un
/// FlutterEngine *headless* (`AudioServicePlugin.getFlutterEngine`, que llama a
/// `new FlutterEngine(context.getApplicationContext())`) cuando Android Auto
/// enlaza el `MediaBrowserService` con la app cerrada. Ese constructor invoca
/// `GeneratedPluginRegister.registerGeneratedPlugins`, que reflexiona sobre
/// `io.flutter.plugins.GeneratedPluginRegistrant`; es decir, registra los
/// PLUGINS, nunca una clase suelta del modulo de app.
///
/// Empaquetando el lado nativo aqui, `GeneratedPluginRegistrant` lo instala en
/// los dos engines sin tocar el manifiesto ni forkear `audio_service`.
///
/// Los llamantes Dart siguen usando `MethodChannel('pluriwave/file_actions')`
/// directamente (`lib/servicios/musica_local_auto.dart`,
/// `lib/estado/estado_grabacion.dart`), asi que este fichero se queda vacio a
/// proposito: cualquier fachada aqui seria una segunda forma de decir lo mismo.
library;
@@ -0,0 +1,23 @@
name: pluriwave_file_actions
description: >-
Canal nativo `pluriwave/file_actions` de PluriWave empaquetado como plugin
Flutter, para que quede registrado en TODOS los FlutterEngine de la app --
incluido el engine headless que audio_service crea cuando Android Auto
arranca el MediaBrowserService sin Activity.
version: 0.0.1
publish_to: 'none'
environment:
sdk: ^3.7.0
flutter: '>=3.3.0'
dependencies:
flutter:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: es.freetimelab.pluriwave.fileactions
pluginClass: PluriWaveFileActionsPlugin