feat(auto): real metadata, quality sort and name buckets for local music [size:exception]

Local tracks now show embedded title/artist/album art (via native
MediaMetadataRetriever, cached through the existing FileProvider)
instead of the raw filename, falling back gracefully when a file
has no usable tags. Adds two navigable entry points per folder: sort
by audio quality (bitrate, capped at 150 tracks per folder to bound
worst-case latency) and alphabetical name buckets -- the closest
realistic form of "filtering" given Android Auto has no text-search
UI in this integration.

Metadata resolves only for the page actually being browsed (same
slice-cheap-then-map discipline as the paging change), backed by a
flat 256-entry LRU session cache that survives across pages. No new
permission, no new pub dependency, no l10n changes (car-tree labels
stay hardcoded Spanish, matching every existing label in the tree).
This commit is contained in:
2026-07-19 23:52:08 +02:00
parent e030a0975d
commit 352eb9fc37
13 changed files with 2470 additions and 82 deletions
@@ -9,6 +9,7 @@ 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
@@ -311,6 +312,20 @@ class MainActivity : AudioServiceActivity() {
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))
}
}
else -> result.notImplemented()
}
}
@@ -443,6 +458,158 @@ class MainActivity : AudioServiceActivity() {
}
}
/**
* 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)