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